create-brainerce-store 1.62.0 → 1.63.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/LICENSE +21 -21
- package/README.md +71 -71
- package/dist/index.js +5 -2
- package/messages/en.json +502 -502
- package/messages/he.json +502 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -60
- package/templates/nextjs/base/.env.local.ejs +24 -24
- package/templates/nextjs/base/.eslintrc.json +57 -57
- package/templates/nextjs/base/.mcp.json +8 -8
- package/templates/nextjs/base/AI-GUIDE.md +174 -174
- package/templates/nextjs/base/TRANSLATIONS.md +200 -200
- package/templates/nextjs/base/package.json.ejs +52 -52
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +262 -262
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
- package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +106 -106
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
- package/templates/nextjs/base/src/app/globals.css +36 -36
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +10 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +44 -44
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
- package/templates/nextjs/base/src/app/page.tsx +64 -64
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +175 -175
- package/templates/nextjs/base/src/app/products/page.tsx +19 -19
- package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +179 -179
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
- package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
- package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
- package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
- package/templates/nextjs/base/src/core/lib/store-info.ts +9 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
- package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
- package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
- package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -20
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
- package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
- package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
- package/templates/nextjs/base/tailwind.config.ts +69 -69
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +10 -0
- package/templates/nextjs/designs/atelier/globals.css +402 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
- package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -27
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -19
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
- package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
- package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
|
@@ -1,74 +1,74 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { TaxBreakdown } from 'brainerce';
|
|
4
|
-
import { formatPrice } from 'brainerce';
|
|
5
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
-
import { useCurrency } from '@/core/lib/use-currency';
|
|
7
|
-
import { cn } from '@/core/lib/utils';
|
|
8
|
-
|
|
9
|
-
interface TaxDisplayProps {
|
|
10
|
-
/** Whether shipping address has been set */
|
|
11
|
-
addressSet: boolean;
|
|
12
|
-
/** Tax amount string from checkout (only available after address is set) */
|
|
13
|
-
taxAmount?: string;
|
|
14
|
-
/** Detailed tax breakdown (optional) */
|
|
15
|
-
taxBreakdown?: TaxBreakdown | null;
|
|
16
|
-
className?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
|
|
20
|
-
const t = useTranslations('checkout');
|
|
21
|
-
const tc = useTranslations('common');
|
|
22
|
-
const currency = useCurrency();
|
|
23
|
-
|
|
24
|
-
// Before address is set
|
|
25
|
-
if (!addressSet) {
|
|
26
|
-
return (
|
|
27
|
-
<div className={cn('flex items-center justify-between text-sm', className)}>
|
|
28
|
-
<span className="text-muted-foreground">{tc('tax')}</span>
|
|
29
|
-
<span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
|
|
30
|
-
</div>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Inclusive-pricing stores back the tax out of the displayed price, so
|
|
35
|
-
// `checkout.taxAmount` is stored as 0 and the real VAT lives on the
|
|
36
|
-
// aggregated breakdown. Surface whichever is present.
|
|
37
|
-
const explicitTax = taxAmount ? parseFloat(taxAmount) : 0;
|
|
38
|
-
const tax =
|
|
39
|
-
explicitTax > 0
|
|
40
|
-
? explicitTax
|
|
41
|
-
: typeof taxBreakdown?.totalTax === 'number'
|
|
42
|
-
? taxBreakdown.totalTax
|
|
43
|
-
: 0;
|
|
44
|
-
|
|
45
|
-
// When there's a per-rate breakdown, prefer per-row display ("VAT 18% ₪3.05").
|
|
46
|
-
// Otherwise show a single aggregated tax line.
|
|
47
|
-
const hasBreakdown = !!taxBreakdown?.breakdown && taxBreakdown.breakdown.length > 0;
|
|
48
|
-
|
|
49
|
-
if (hasBreakdown) {
|
|
50
|
-
return (
|
|
51
|
-
<div className={cn('space-y-1', className)}>
|
|
52
|
-
{taxBreakdown!.breakdown.map((item, index) => (
|
|
53
|
-
<div key={index} className="flex items-center justify-between text-sm">
|
|
54
|
-
<span className="text-muted-foreground">
|
|
55
|
-
{item.name} ({(item.rate * 100).toFixed(1)}%)
|
|
56
|
-
</span>
|
|
57
|
-
<span className="text-foreground font-medium">
|
|
58
|
-
{formatPrice(item.amount, { currency }) as string}
|
|
59
|
-
</span>
|
|
60
|
-
</div>
|
|
61
|
-
))}
|
|
62
|
-
</div>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return (
|
|
67
|
-
<div className={cn('flex items-center justify-between text-sm', className)}>
|
|
68
|
-
<span className="text-muted-foreground">{tc('tax')}</span>
|
|
69
|
-
<span className="text-foreground font-medium">
|
|
70
|
-
{tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
|
|
71
|
-
</span>
|
|
72
|
-
</div>
|
|
73
|
-
);
|
|
74
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TaxBreakdown } from 'brainerce';
|
|
4
|
+
import { formatPrice } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
7
|
+
import { cn } from '@/core/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface TaxDisplayProps {
|
|
10
|
+
/** Whether shipping address has been set */
|
|
11
|
+
addressSet: boolean;
|
|
12
|
+
/** Tax amount string from checkout (only available after address is set) */
|
|
13
|
+
taxAmount?: string;
|
|
14
|
+
/** Detailed tax breakdown (optional) */
|
|
15
|
+
taxBreakdown?: TaxBreakdown | null;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
|
|
20
|
+
const t = useTranslations('checkout');
|
|
21
|
+
const tc = useTranslations('common');
|
|
22
|
+
const currency = useCurrency();
|
|
23
|
+
|
|
24
|
+
// Before address is set
|
|
25
|
+
if (!addressSet) {
|
|
26
|
+
return (
|
|
27
|
+
<div className={cn('flex items-center justify-between text-sm', className)}>
|
|
28
|
+
<span className="text-muted-foreground">{tc('tax')}</span>
|
|
29
|
+
<span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Inclusive-pricing stores back the tax out of the displayed price, so
|
|
35
|
+
// `checkout.taxAmount` is stored as 0 and the real VAT lives on the
|
|
36
|
+
// aggregated breakdown. Surface whichever is present.
|
|
37
|
+
const explicitTax = taxAmount ? parseFloat(taxAmount) : 0;
|
|
38
|
+
const tax =
|
|
39
|
+
explicitTax > 0
|
|
40
|
+
? explicitTax
|
|
41
|
+
: typeof taxBreakdown?.totalTax === 'number'
|
|
42
|
+
? taxBreakdown.totalTax
|
|
43
|
+
: 0;
|
|
44
|
+
|
|
45
|
+
// When there's a per-rate breakdown, prefer per-row display ("VAT 18% ₪3.05").
|
|
46
|
+
// Otherwise show a single aggregated tax line.
|
|
47
|
+
const hasBreakdown = !!taxBreakdown?.breakdown && taxBreakdown.breakdown.length > 0;
|
|
48
|
+
|
|
49
|
+
if (hasBreakdown) {
|
|
50
|
+
return (
|
|
51
|
+
<div className={cn('space-y-1', className)}>
|
|
52
|
+
{taxBreakdown!.breakdown.map((item, index) => (
|
|
53
|
+
<div key={index} className="flex items-center justify-between text-sm">
|
|
54
|
+
<span className="text-muted-foreground">
|
|
55
|
+
{item.name} ({(item.rate * 100).toFixed(1)}%)
|
|
56
|
+
</span>
|
|
57
|
+
<span className="text-foreground font-medium">
|
|
58
|
+
{formatPrice(item.amount, { currency }) as string}
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={cn('flex items-center justify-between text-sm', className)}>
|
|
68
|
+
<span className="text-muted-foreground">{tc('tax')}</span>
|
|
69
|
+
<span className="text-foreground font-medium">
|
|
70
|
+
{tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
|
|
71
|
+
</span>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
import type { BlogPost } from 'brainerce';
|
|
2
|
-
import { buildArticleJsonLd, buildBreadcrumbJsonLd } from 'brainerce';
|
|
3
|
-
import { getNonce } from '@/core/lib/nonce';
|
|
4
|
-
|
|
5
|
-
interface ArticleJsonLdProps {
|
|
6
|
-
post: BlogPost;
|
|
7
|
-
url: string;
|
|
8
|
-
/** Store display name — feeds Article.publisher (and the author fallback). */
|
|
9
|
-
organizationName?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Article + BreadcrumbList structured data for a blog post page.
|
|
14
|
-
*
|
|
15
|
-
* Uses the SDK builders for the schema shape, but renders the <script> here so
|
|
16
|
-
* we can attach the CSP nonce (the SDK's jsonLdScriptProps can't).
|
|
17
|
-
*/
|
|
18
|
-
export async function ArticleJsonLd({ post, url, organizationName }: ArticleJsonLdProps) {
|
|
19
|
-
const nonce = await getNonce();
|
|
20
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
21
|
-
|
|
22
|
-
const articleJsonLd = buildArticleJsonLd(post, {
|
|
23
|
-
siteUrl: baseUrl,
|
|
24
|
-
path: url,
|
|
25
|
-
organizationName,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const breadcrumbJsonLd = buildBreadcrumbJsonLd([
|
|
29
|
-
{ name: 'Home', url: baseUrl || '/' },
|
|
30
|
-
{ name: 'Blog', url: `${baseUrl}/blog` },
|
|
31
|
-
{ name: post.title, url },
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
return (
|
|
35
|
-
<>
|
|
36
|
-
<script
|
|
37
|
-
type="application/ld+json"
|
|
38
|
-
nonce={nonce}
|
|
39
|
-
suppressHydrationWarning
|
|
40
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(articleJsonLd) }}
|
|
41
|
-
/>
|
|
42
|
-
<script
|
|
43
|
-
type="application/ld+json"
|
|
44
|
-
nonce={nonce}
|
|
45
|
-
suppressHydrationWarning
|
|
46
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
47
|
-
/>
|
|
48
|
-
</>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Escape `<`, `>`, `&` so AI-generated article copy can't break out of the
|
|
53
|
-
// <script> element or inject HTML.
|
|
54
|
-
function serializeJsonLd(value: unknown): string {
|
|
55
|
-
return JSON.stringify(value)
|
|
56
|
-
.replace(/</g, '\\u003c')
|
|
57
|
-
.replace(/>/g, '\\u003e')
|
|
58
|
-
.replace(/&/g, '\\u0026');
|
|
59
|
-
}
|
|
1
|
+
import type { BlogPost } from 'brainerce';
|
|
2
|
+
import { buildArticleJsonLd, buildBreadcrumbJsonLd } from 'brainerce';
|
|
3
|
+
import { getNonce } from '@/core/lib/nonce';
|
|
4
|
+
|
|
5
|
+
interface ArticleJsonLdProps {
|
|
6
|
+
post: BlogPost;
|
|
7
|
+
url: string;
|
|
8
|
+
/** Store display name — feeds Article.publisher (and the author fallback). */
|
|
9
|
+
organizationName?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Article + BreadcrumbList structured data for a blog post page.
|
|
14
|
+
*
|
|
15
|
+
* Uses the SDK builders for the schema shape, but renders the <script> here so
|
|
16
|
+
* we can attach the CSP nonce (the SDK's jsonLdScriptProps can't).
|
|
17
|
+
*/
|
|
18
|
+
export async function ArticleJsonLd({ post, url, organizationName }: ArticleJsonLdProps) {
|
|
19
|
+
const nonce = await getNonce();
|
|
20
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
21
|
+
|
|
22
|
+
const articleJsonLd = buildArticleJsonLd(post, {
|
|
23
|
+
siteUrl: baseUrl,
|
|
24
|
+
path: url,
|
|
25
|
+
organizationName,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const breadcrumbJsonLd = buildBreadcrumbJsonLd([
|
|
29
|
+
{ name: 'Home', url: baseUrl || '/' },
|
|
30
|
+
{ name: 'Blog', url: `${baseUrl}/blog` },
|
|
31
|
+
{ name: post.title, url },
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<script
|
|
37
|
+
type="application/ld+json"
|
|
38
|
+
nonce={nonce}
|
|
39
|
+
suppressHydrationWarning
|
|
40
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(articleJsonLd) }}
|
|
41
|
+
/>
|
|
42
|
+
<script
|
|
43
|
+
type="application/ld+json"
|
|
44
|
+
nonce={nonce}
|
|
45
|
+
suppressHydrationWarning
|
|
46
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
47
|
+
/>
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Escape `<`, `>`, `&` so AI-generated article copy can't break out of the
|
|
53
|
+
// <script> element or inject HTML.
|
|
54
|
+
function serializeJsonLd(value: unknown): string {
|
|
55
|
+
return JSON.stringify(value)
|
|
56
|
+
.replace(/</g, '\\u003c')
|
|
57
|
+
.replace(/>/g, '\\u003e')
|
|
58
|
+
.replace(/&/g, '\\u0026');
|
|
59
|
+
}
|
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
import Link from 'next/link';
|
|
2
|
-
|
|
3
|
-
interface Crumb {
|
|
4
|
-
name: string;
|
|
5
|
-
/** Site-relative path, e.g. "/category/shoes". Omit on the current (last) crumb. */
|
|
6
|
-
href?: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Visible breadcrumb trail. Renders the exact same path structured data
|
|
11
|
-
* already declares (see category-json-ld.tsx / product-json-ld.tsx) as real
|
|
12
|
-
* `<Link>`s — search engines weight on-page navigation links much more than
|
|
13
|
-
* sitemap-only or script-only (JSON-LD) mentions of a URL.
|
|
14
|
-
*/
|
|
15
|
-
export function Breadcrumbs({ items }: { items: Crumb[] }) {
|
|
16
|
-
if (items.length < 2) return null;
|
|
17
|
-
return (
|
|
18
|
-
<nav aria-label="Breadcrumb" className="text-muted-foreground mb-4 text-sm">
|
|
19
|
-
<ol className="flex flex-wrap items-center gap-1.5">
|
|
20
|
-
{items.map((item, idx) => (
|
|
21
|
-
<li key={`${item.name}-${idx}`} className="flex items-center gap-1.5">
|
|
22
|
-
{idx > 0 && <span aria-hidden="true">/</span>}
|
|
23
|
-
{item.href ? (
|
|
24
|
-
<Link href={item.href} className="hover:text-foreground transition-colors">
|
|
25
|
-
{item.name}
|
|
26
|
-
</Link>
|
|
27
|
-
) : (
|
|
28
|
-
<span aria-current="page" className="text-foreground">
|
|
29
|
-
{item.name}
|
|
30
|
-
</span>
|
|
31
|
-
)}
|
|
32
|
-
</li>
|
|
33
|
-
))}
|
|
34
|
-
</ol>
|
|
35
|
-
</nav>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
|
|
3
|
+
interface Crumb {
|
|
4
|
+
name: string;
|
|
5
|
+
/** Site-relative path, e.g. "/category/shoes". Omit on the current (last) crumb. */
|
|
6
|
+
href?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Visible breadcrumb trail. Renders the exact same path structured data
|
|
11
|
+
* already declares (see category-json-ld.tsx / product-json-ld.tsx) as real
|
|
12
|
+
* `<Link>`s — search engines weight on-page navigation links much more than
|
|
13
|
+
* sitemap-only or script-only (JSON-LD) mentions of a URL.
|
|
14
|
+
*/
|
|
15
|
+
export function Breadcrumbs({ items }: { items: Crumb[] }) {
|
|
16
|
+
if (items.length < 2) return null;
|
|
17
|
+
return (
|
|
18
|
+
<nav aria-label="Breadcrumb" className="text-muted-foreground mb-4 text-sm">
|
|
19
|
+
<ol className="flex flex-wrap items-center gap-1.5">
|
|
20
|
+
{items.map((item, idx) => (
|
|
21
|
+
<li key={`${item.name}-${idx}`} className="flex items-center gap-1.5">
|
|
22
|
+
{idx > 0 && <span aria-hidden="true">/</span>}
|
|
23
|
+
{item.href ? (
|
|
24
|
+
<Link href={item.href} className="hover:text-foreground transition-colors">
|
|
25
|
+
{item.name}
|
|
26
|
+
</Link>
|
|
27
|
+
) : (
|
|
28
|
+
<span aria-current="page" className="text-foreground">
|
|
29
|
+
{item.name}
|
|
30
|
+
</span>
|
|
31
|
+
)}
|
|
32
|
+
</li>
|
|
33
|
+
))}
|
|
34
|
+
</ol>
|
|
35
|
+
</nav>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
import type { CategoryDetail } from 'brainerce';
|
|
2
|
-
import { buildCollectionPageJsonLd, buildBreadcrumbJsonLd } from 'brainerce';
|
|
3
|
-
import { getNonce } from '@/core/lib/nonce';
|
|
4
|
-
|
|
5
|
-
interface CategoryJsonLdProps {
|
|
6
|
-
category: CategoryDetail;
|
|
7
|
-
url: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* CollectionPage + BreadcrumbList structured data for a category (collection)
|
|
12
|
-
* landing page. NEVER emit Product markup on a listing page — Google's Product
|
|
13
|
-
* rich results are single-product only (see ProductJsonLd for PDPs).
|
|
14
|
-
*
|
|
15
|
-
* Uses the SDK builders for the schema shape, but renders the <script> here so
|
|
16
|
-
* we can attach the CSP nonce (the SDK's jsonLdScriptProps can't).
|
|
17
|
-
*/
|
|
18
|
-
export async function CategoryJsonLd({ category, url }: CategoryJsonLdProps) {
|
|
19
|
-
const nonce = await getNonce();
|
|
20
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
21
|
-
|
|
22
|
-
const collectionJsonLd = buildCollectionPageJsonLd(category, { siteUrl: baseUrl, path: url });
|
|
23
|
-
|
|
24
|
-
// Breadcrumb: Home → ancestor categories → this category.
|
|
25
|
-
const crumbs: Array<{ name: string; url: string }> = [
|
|
26
|
-
{ name: 'Home', url: baseUrl || '/' },
|
|
27
|
-
];
|
|
28
|
-
for (const ancestor of category.breadcrumb) {
|
|
29
|
-
if (ancestor.slug) {
|
|
30
|
-
crumbs.push({ name: ancestor.name, url: `${baseUrl}/category/${ancestor.slug}` });
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
crumbs.push({ name: category.name, url });
|
|
34
|
-
const breadcrumbJsonLd = buildBreadcrumbJsonLd(crumbs);
|
|
35
|
-
|
|
36
|
-
return (
|
|
37
|
-
<>
|
|
38
|
-
<script
|
|
39
|
-
type="application/ld+json"
|
|
40
|
-
nonce={nonce}
|
|
41
|
-
suppressHydrationWarning
|
|
42
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(collectionJsonLd) }}
|
|
43
|
-
/>
|
|
44
|
-
<script
|
|
45
|
-
type="application/ld+json"
|
|
46
|
-
nonce={nonce}
|
|
47
|
-
suppressHydrationWarning
|
|
48
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
49
|
-
/>
|
|
50
|
-
</>
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Escape `<`, `>`, `&` so merchant-authored category copy can't break out of
|
|
55
|
-
// the <script> element or inject HTML.
|
|
56
|
-
function serializeJsonLd(value: unknown): string {
|
|
57
|
-
return JSON.stringify(value)
|
|
58
|
-
.replace(/</g, '\\u003c')
|
|
59
|
-
.replace(/>/g, '\\u003e')
|
|
60
|
-
.replace(/&/g, '\\u0026');
|
|
61
|
-
}
|
|
1
|
+
import type { CategoryDetail } from 'brainerce';
|
|
2
|
+
import { buildCollectionPageJsonLd, buildBreadcrumbJsonLd } from 'brainerce';
|
|
3
|
+
import { getNonce } from '@/core/lib/nonce';
|
|
4
|
+
|
|
5
|
+
interface CategoryJsonLdProps {
|
|
6
|
+
category: CategoryDetail;
|
|
7
|
+
url: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CollectionPage + BreadcrumbList structured data for a category (collection)
|
|
12
|
+
* landing page. NEVER emit Product markup on a listing page — Google's Product
|
|
13
|
+
* rich results are single-product only (see ProductJsonLd for PDPs).
|
|
14
|
+
*
|
|
15
|
+
* Uses the SDK builders for the schema shape, but renders the <script> here so
|
|
16
|
+
* we can attach the CSP nonce (the SDK's jsonLdScriptProps can't).
|
|
17
|
+
*/
|
|
18
|
+
export async function CategoryJsonLd({ category, url }: CategoryJsonLdProps) {
|
|
19
|
+
const nonce = await getNonce();
|
|
20
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
21
|
+
|
|
22
|
+
const collectionJsonLd = buildCollectionPageJsonLd(category, { siteUrl: baseUrl, path: url });
|
|
23
|
+
|
|
24
|
+
// Breadcrumb: Home → ancestor categories → this category.
|
|
25
|
+
const crumbs: Array<{ name: string; url: string }> = [
|
|
26
|
+
{ name: 'Home', url: baseUrl || '/' },
|
|
27
|
+
];
|
|
28
|
+
for (const ancestor of category.breadcrumb) {
|
|
29
|
+
if (ancestor.slug) {
|
|
30
|
+
crumbs.push({ name: ancestor.name, url: `${baseUrl}/category/${ancestor.slug}` });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
crumbs.push({ name: category.name, url });
|
|
34
|
+
const breadcrumbJsonLd = buildBreadcrumbJsonLd(crumbs);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<script
|
|
39
|
+
type="application/ld+json"
|
|
40
|
+
nonce={nonce}
|
|
41
|
+
suppressHydrationWarning
|
|
42
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(collectionJsonLd) }}
|
|
43
|
+
/>
|
|
44
|
+
<script
|
|
45
|
+
type="application/ld+json"
|
|
46
|
+
nonce={nonce}
|
|
47
|
+
suppressHydrationWarning
|
|
48
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
49
|
+
/>
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Escape `<`, `>`, `&` so merchant-authored category copy can't break out of
|
|
55
|
+
// the <script> element or inject HTML.
|
|
56
|
+
function serializeJsonLd(value: unknown): string {
|
|
57
|
+
return JSON.stringify(value)
|
|
58
|
+
.replace(/</g, '\\u003c')
|
|
59
|
+
.replace(/>/g, '\\u003e')
|
|
60
|
+
.replace(/&/g, '\\u0026');
|
|
61
|
+
}
|
|
@@ -1,97 +1,97 @@
|
|
|
1
|
-
import type { StoreInfo } from 'brainerce';
|
|
2
|
-
import { getNonce } from '@/core/lib/nonce';
|
|
3
|
-
import { stripHtmlForSeo } from '@/core/lib/seo';
|
|
4
|
-
|
|
5
|
-
interface OrganizationJsonLdProps {
|
|
6
|
-
storeInfo: StoreInfo;
|
|
7
|
-
baseUrl: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// Emits Organization + WebSite JSON-LD on the storefront homepage.
|
|
11
|
-
//
|
|
12
|
-
// Why these two specifically:
|
|
13
|
-
// - Organization → feeds the Google Knowledge Panel (store name, logo,
|
|
14
|
-
// description) and is the primary signal AI Overviews / ChatGPT /
|
|
15
|
-
// Perplexity ingest when citing the store.
|
|
16
|
-
// - WebSite → enables Sitelinks Searchbox (Google) and tells crawlers
|
|
17
|
-
// the canonical site URL. The `potentialAction` block is what makes
|
|
18
|
-
// the search box appear in branded SERP results.
|
|
19
|
-
export async function OrganizationJsonLd({ storeInfo, baseUrl }: OrganizationJsonLdProps) {
|
|
20
|
-
const nonce = await getNonce();
|
|
21
|
-
|
|
22
|
-
const cleanDescription = stripHtmlForSeo(storeInfo.metaDescription ?? undefined);
|
|
23
|
-
const logo =
|
|
24
|
-
typeof storeInfo.logo === 'string' && storeInfo.logo.trim() ? storeInfo.logo : undefined;
|
|
25
|
-
const contactEmail =
|
|
26
|
-
typeof storeInfo.contactEmail === 'string' && storeInfo.contactEmail.trim()
|
|
27
|
-
? storeInfo.contactEmail.trim()
|
|
28
|
-
: undefined;
|
|
29
|
-
const contactPhone =
|
|
30
|
-
typeof storeInfo.contactPhone === 'string' && storeInfo.contactPhone.trim()
|
|
31
|
-
? storeInfo.contactPhone.trim()
|
|
32
|
-
: undefined;
|
|
33
|
-
// sameAs is the JSON-LD property Google uses to associate a brand with its
|
|
34
|
-
// social profiles. Order doesn't matter; we just filter out empties.
|
|
35
|
-
const sameAs = storeInfo.socialLinks
|
|
36
|
-
? Object.values(storeInfo.socialLinks).filter(
|
|
37
|
-
(url): url is string => typeof url === 'string' && url.trim().length > 0
|
|
38
|
-
)
|
|
39
|
-
: [];
|
|
40
|
-
|
|
41
|
-
const organizationJsonLd: Record<string, unknown> = {
|
|
42
|
-
'@context': 'https://schema.org',
|
|
43
|
-
'@type': 'Organization',
|
|
44
|
-
name: storeInfo.name,
|
|
45
|
-
url: baseUrl,
|
|
46
|
-
...(logo ? { logo } : {}),
|
|
47
|
-
...(cleanDescription ? { description: cleanDescription } : {}),
|
|
48
|
-
...(contactEmail ? { email: contactEmail } : {}),
|
|
49
|
-
...(contactPhone ? { telephone: contactPhone } : {}),
|
|
50
|
-
...(sameAs.length > 0 ? { sameAs } : {}),
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const websiteJsonLd: Record<string, unknown> = {
|
|
54
|
-
'@context': 'https://schema.org',
|
|
55
|
-
'@type': 'WebSite',
|
|
56
|
-
name: storeInfo.name,
|
|
57
|
-
url: baseUrl,
|
|
58
|
-
potentialAction: {
|
|
59
|
-
'@type': 'SearchAction',
|
|
60
|
-
target: {
|
|
61
|
-
// Must match the storefront's real search param — the product listing
|
|
62
|
-
// reads `?search=` (see use-product-listing.ts), not `?q=`. A wrong
|
|
63
|
-
// param makes the sitelinks search box land on an empty listing.
|
|
64
|
-
'@type': 'EntryPoint',
|
|
65
|
-
urlTemplate: `${baseUrl}/products?search={search_term_string}`,
|
|
66
|
-
},
|
|
67
|
-
'query-input': 'required name=search_term_string',
|
|
68
|
-
},
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
return (
|
|
72
|
-
<>
|
|
73
|
-
<script
|
|
74
|
-
type="application/ld+json"
|
|
75
|
-
nonce={nonce}
|
|
76
|
-
suppressHydrationWarning
|
|
77
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(organizationJsonLd) }}
|
|
78
|
-
/>
|
|
79
|
-
<script
|
|
80
|
-
type="application/ld+json"
|
|
81
|
-
nonce={nonce}
|
|
82
|
-
suppressHydrationWarning
|
|
83
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(websiteJsonLd) }}
|
|
84
|
-
/>
|
|
85
|
-
</>
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Escape `<`, `>`, `&` to \uXXXX so seller-controlled fields can't break out
|
|
90
|
-
// of the <script> with `</script>` or inject HTML. Same approach as
|
|
91
|
-
// product-json-ld.tsx — keep the two in sync.
|
|
92
|
-
function serializeJsonLd(value: unknown): string {
|
|
93
|
-
return JSON.stringify(value)
|
|
94
|
-
.replace(/</g, '\\u003c')
|
|
95
|
-
.replace(/>/g, '\\u003e')
|
|
96
|
-
.replace(/&/g, '\\u0026');
|
|
97
|
-
}
|
|
1
|
+
import type { StoreInfo } from 'brainerce';
|
|
2
|
+
import { getNonce } from '@/core/lib/nonce';
|
|
3
|
+
import { stripHtmlForSeo } from '@/core/lib/seo';
|
|
4
|
+
|
|
5
|
+
interface OrganizationJsonLdProps {
|
|
6
|
+
storeInfo: StoreInfo;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Emits Organization + WebSite JSON-LD on the storefront homepage.
|
|
11
|
+
//
|
|
12
|
+
// Why these two specifically:
|
|
13
|
+
// - Organization → feeds the Google Knowledge Panel (store name, logo,
|
|
14
|
+
// description) and is the primary signal AI Overviews / ChatGPT /
|
|
15
|
+
// Perplexity ingest when citing the store.
|
|
16
|
+
// - WebSite → enables Sitelinks Searchbox (Google) and tells crawlers
|
|
17
|
+
// the canonical site URL. The `potentialAction` block is what makes
|
|
18
|
+
// the search box appear in branded SERP results.
|
|
19
|
+
export async function OrganizationJsonLd({ storeInfo, baseUrl }: OrganizationJsonLdProps) {
|
|
20
|
+
const nonce = await getNonce();
|
|
21
|
+
|
|
22
|
+
const cleanDescription = stripHtmlForSeo(storeInfo.metaDescription ?? undefined);
|
|
23
|
+
const logo =
|
|
24
|
+
typeof storeInfo.logo === 'string' && storeInfo.logo.trim() ? storeInfo.logo : undefined;
|
|
25
|
+
const contactEmail =
|
|
26
|
+
typeof storeInfo.contactEmail === 'string' && storeInfo.contactEmail.trim()
|
|
27
|
+
? storeInfo.contactEmail.trim()
|
|
28
|
+
: undefined;
|
|
29
|
+
const contactPhone =
|
|
30
|
+
typeof storeInfo.contactPhone === 'string' && storeInfo.contactPhone.trim()
|
|
31
|
+
? storeInfo.contactPhone.trim()
|
|
32
|
+
: undefined;
|
|
33
|
+
// sameAs is the JSON-LD property Google uses to associate a brand with its
|
|
34
|
+
// social profiles. Order doesn't matter; we just filter out empties.
|
|
35
|
+
const sameAs = storeInfo.socialLinks
|
|
36
|
+
? Object.values(storeInfo.socialLinks).filter(
|
|
37
|
+
(url): url is string => typeof url === 'string' && url.trim().length > 0
|
|
38
|
+
)
|
|
39
|
+
: [];
|
|
40
|
+
|
|
41
|
+
const organizationJsonLd: Record<string, unknown> = {
|
|
42
|
+
'@context': 'https://schema.org',
|
|
43
|
+
'@type': 'Organization',
|
|
44
|
+
name: storeInfo.name,
|
|
45
|
+
url: baseUrl,
|
|
46
|
+
...(logo ? { logo } : {}),
|
|
47
|
+
...(cleanDescription ? { description: cleanDescription } : {}),
|
|
48
|
+
...(contactEmail ? { email: contactEmail } : {}),
|
|
49
|
+
...(contactPhone ? { telephone: contactPhone } : {}),
|
|
50
|
+
...(sameAs.length > 0 ? { sameAs } : {}),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const websiteJsonLd: Record<string, unknown> = {
|
|
54
|
+
'@context': 'https://schema.org',
|
|
55
|
+
'@type': 'WebSite',
|
|
56
|
+
name: storeInfo.name,
|
|
57
|
+
url: baseUrl,
|
|
58
|
+
potentialAction: {
|
|
59
|
+
'@type': 'SearchAction',
|
|
60
|
+
target: {
|
|
61
|
+
// Must match the storefront's real search param — the product listing
|
|
62
|
+
// reads `?search=` (see use-product-listing.ts), not `?q=`. A wrong
|
|
63
|
+
// param makes the sitelinks search box land on an empty listing.
|
|
64
|
+
'@type': 'EntryPoint',
|
|
65
|
+
urlTemplate: `${baseUrl}/products?search={search_term_string}`,
|
|
66
|
+
},
|
|
67
|
+
'query-input': 'required name=search_term_string',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
<script
|
|
74
|
+
type="application/ld+json"
|
|
75
|
+
nonce={nonce}
|
|
76
|
+
suppressHydrationWarning
|
|
77
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(organizationJsonLd) }}
|
|
78
|
+
/>
|
|
79
|
+
<script
|
|
80
|
+
type="application/ld+json"
|
|
81
|
+
nonce={nonce}
|
|
82
|
+
suppressHydrationWarning
|
|
83
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(websiteJsonLd) }}
|
|
84
|
+
/>
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Escape `<`, `>`, `&` to \uXXXX so seller-controlled fields can't break out
|
|
90
|
+
// of the <script> with `</script>` or inject HTML. Same approach as
|
|
91
|
+
// product-json-ld.tsx — keep the two in sync.
|
|
92
|
+
function serializeJsonLd(value: unknown): string {
|
|
93
|
+
return JSON.stringify(value)
|
|
94
|
+
.replace(/</g, '\\u003c')
|
|
95
|
+
.replace(/>/g, '\\u003e')
|
|
96
|
+
.replace(/&/g, '\\u0026');
|
|
97
|
+
}
|