create-brainerce-store 1.63.0 → 1.65.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 +32 -6
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/agents.md/route.ts +87 -0
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +51 -5
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +70 -0
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +132 -106
- package/templates/nextjs/base/src/app/contact/layout.tsx.ejs +14 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +20 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +67 -44
- package/templates/nextjs/base/src/app/not-found.tsx.ejs +35 -0
- package/templates/nextjs/base/src/app/opengraph-image.tsx +109 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +187 -175
- package/templates/nextjs/base/src/app/robots.ts +73 -14
- package/templates/nextjs/base/src/app/sitemap.ts +89 -66
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +71 -179
- package/templates/nextjs/base/src/core/lib/store-info.ts +8 -0
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +1 -1
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +3 -1
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +20 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +1 -1
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +1 -1
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +3 -1
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +3 -1
|
@@ -1,14 +1,73 @@
|
|
|
1
|
-
import type { MetadataRoute } from 'next';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* robots.txt with an explicit AI-crawler policy.
|
|
5
|
+
*
|
|
6
|
+
* Merchants WANT to be recommended by AI assistants, so the bots that power
|
|
7
|
+
* AI search/answers are allowed by name — an explicit allow survives any
|
|
8
|
+
* future tightening of the `*` rule and documents intent to auditors:
|
|
9
|
+
*
|
|
10
|
+
* - OAI-SearchBot / ChatGPT-User → ChatGPT search + shopping visibility
|
|
11
|
+
* - Claude-SearchBot / Claude-User → Claude search + user-requested reads
|
|
12
|
+
* - PerplexityBot / Perplexity-User → Perplexity shopping visibility
|
|
13
|
+
* - Bingbot → also feeds Microsoft Copilot answers
|
|
14
|
+
* - Applebot → Siri / Apple search surfaces
|
|
15
|
+
* - Amazonbot → Alexa / Amazon shopping surfaces
|
|
16
|
+
*
|
|
17
|
+
* TRAINING bots (GPTBot, ClaudeBot, CCBot, Google-Extended, Meta-
|
|
18
|
+
* ExternalAgent, Applebot-Extended) are also allowed by default: model-weight
|
|
19
|
+
* familiarity with the store feeds zero-shot recommendations, which is what a
|
|
20
|
+
* store wants. A merchant who objects to AI training on their content can
|
|
21
|
+
* move those user-agents to a `disallow: '/'` rule — that does NOT affect
|
|
22
|
+
* search visibility (Google-Extended, for example, has no effect on Google
|
|
23
|
+
* Search or AI Overviews; it only gates Gemini training).
|
|
24
|
+
*
|
|
25
|
+
* Checkout/account/API paths stay disallowed for every agent.
|
|
26
|
+
*/
|
|
27
|
+
const DISALLOWED_PATHS = ['/api/', '/auth/', '/checkout/', '/account/'];
|
|
28
|
+
|
|
29
|
+
const AI_SEARCH_BOTS = [
|
|
30
|
+
'OAI-SearchBot',
|
|
31
|
+
'ChatGPT-User',
|
|
32
|
+
'Claude-SearchBot',
|
|
33
|
+
'Claude-User',
|
|
34
|
+
'PerplexityBot',
|
|
35
|
+
'Perplexity-User',
|
|
36
|
+
'Bingbot',
|
|
37
|
+
'Applebot',
|
|
38
|
+
'Amazonbot',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const AI_TRAINING_BOTS = [
|
|
42
|
+
'GPTBot',
|
|
43
|
+
'ClaudeBot',
|
|
44
|
+
'CCBot',
|
|
45
|
+
'Google-Extended',
|
|
46
|
+
'Meta-ExternalAgent',
|
|
47
|
+
'Applebot-Extended',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export default function robots(): MetadataRoute.Robots {
|
|
51
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
rules: [
|
|
55
|
+
{
|
|
56
|
+
userAgent: '*',
|
|
57
|
+
allow: '/',
|
|
58
|
+
disallow: DISALLOWED_PATHS,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
userAgent: AI_SEARCH_BOTS,
|
|
62
|
+
allow: '/',
|
|
63
|
+
disallow: DISALLOWED_PATHS,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
userAgent: AI_TRAINING_BOTS,
|
|
67
|
+
allow: '/',
|
|
68
|
+
disallow: DISALLOWED_PATHS,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
sitemap: `${baseUrl}/sitemap.xml`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -1,66 +1,89 @@
|
|
|
1
|
-
import type { MetadataRoute } from 'next';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
import {
|
|
3
|
+
getBlogSitemapEntries,
|
|
4
|
+
getCategorySitemapEntries,
|
|
5
|
+
getProductSitemapEntries,
|
|
6
|
+
} from 'brainerce';
|
|
7
|
+
import { getServerClient } from '@/core/lib/brainerce';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Storefront sitemap: static pages + every published product, category,
|
|
11
|
+
* blog post, and merchant-authored content page.
|
|
12
|
+
*
|
|
13
|
+
* Products MUST go through `getProductSitemapEntries` — the public listing
|
|
14
|
+
* API clamps `limit` to 100, so a naive `getProducts({ limit: 1000 })` call
|
|
15
|
+
* silently truncates the sitemap for any store with more than 100 products.
|
|
16
|
+
* The helper uses a dedicated lightweight endpoint with a 5000-product cap.
|
|
17
|
+
*/
|
|
18
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
19
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
20
|
+
|
|
21
|
+
const client = getServerClient();
|
|
22
|
+
const storeInfo = await client.getStoreInfo().catch(() => null);
|
|
23
|
+
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
24
|
+
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
25
|
+
const isMultiLocale = supportedLocales.length > 1;
|
|
26
|
+
const locales = isMultiLocale ? supportedLocales : undefined;
|
|
27
|
+
|
|
28
|
+
// Indexable static routes. Cart/checkout/account/login are noindex — never
|
|
29
|
+
// list them here.
|
|
30
|
+
const staticPaths: Array<{ path: string; priority: number }> = [
|
|
31
|
+
{ path: '/', priority: 1 },
|
|
32
|
+
{ path: '/products', priority: 0.9 },
|
|
33
|
+
{ path: '/faq', priority: 0.5 },
|
|
34
|
+
{ path: '/contact', priority: 0.5 },
|
|
35
|
+
];
|
|
36
|
+
const staticPages: MetadataRoute.Sitemap = (isMultiLocale ? supportedLocales : ['']).flatMap(
|
|
37
|
+
(loc) => {
|
|
38
|
+
const prefix = !isMultiLocale || loc === defaultLoc ? '' : `/${loc}`;
|
|
39
|
+
return staticPaths.map(({ path, priority }) => ({
|
|
40
|
+
url: `${baseUrl}${prefix}${path === '/' ? (prefix ? '' : '/') : path}`,
|
|
41
|
+
lastModified: new Date(),
|
|
42
|
+
priority,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Each section fails independently to an empty list — a transient API error
|
|
48
|
+
// on one content type must not blank the whole sitemap.
|
|
49
|
+
const [productPages, categoryPages, blogPages, contentPages] = await Promise.all([
|
|
50
|
+
getProductSitemapEntries(client, {
|
|
51
|
+
siteUrl: baseUrl,
|
|
52
|
+
locales,
|
|
53
|
+
defaultLocale: defaultLoc,
|
|
54
|
+
}).catch(() => []),
|
|
55
|
+
// Category (collection) pages — the highest-value organic-SEO surface;
|
|
56
|
+
// must be crawlable so they can rank for broad research-intent queries.
|
|
57
|
+
getCategorySitemapEntries(client, {
|
|
58
|
+
siteUrl: baseUrl,
|
|
59
|
+
locales,
|
|
60
|
+
defaultLocale: defaultLoc,
|
|
61
|
+
}).catch(() => []),
|
|
62
|
+
// Blog posts — the SEO Autopilot (and manual publishes) must be
|
|
63
|
+
// discoverable; missing blog entries = articles never get crawled.
|
|
64
|
+
getBlogSitemapEntries(client, {
|
|
65
|
+
siteUrl: baseUrl,
|
|
66
|
+
locales,
|
|
67
|
+
defaultLocale: defaultLoc,
|
|
68
|
+
}).catch(() => []),
|
|
69
|
+
// Merchant-authored content pages (About, policies, …) at /pages/{slug}.
|
|
70
|
+
client.content.page
|
|
71
|
+
.list()
|
|
72
|
+
.then((pages) =>
|
|
73
|
+
pages.flatMap((page) => {
|
|
74
|
+
const slug = (page.data as { slug?: string }).slug;
|
|
75
|
+
if (!slug) return [];
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
url: `${baseUrl}/pages/${slug}`,
|
|
79
|
+
lastModified: page.updatedAt ? new Date(page.updatedAt) : undefined,
|
|
80
|
+
priority: 0.5,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
.catch(() => []),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
return [...staticPages, ...productPages, ...categoryPages, ...blogPages, ...contentPages];
|
|
89
|
+
}
|
|
@@ -1,179 +1,71 @@
|
|
|
1
|
-
import type { Product, ShippingSummaryEntry } from 'brainerce';
|
|
2
|
-
import {
|
|
3
|
-
import { getNonce } from '@/core/lib/nonce';
|
|
4
|
-
import { resolveCurrency } from '@/core/lib/resolve-currency';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
lowPrice: product.priceMin,
|
|
73
|
-
highPrice: product.priceMax ?? product.priceMin,
|
|
74
|
-
offerCount: product.variants?.length ?? 1,
|
|
75
|
-
priceCurrency: resolvedCurrency,
|
|
76
|
-
availability,
|
|
77
|
-
itemCondition,
|
|
78
|
-
...(shippingDetails.length > 0 ? { shippingDetails } : {}),
|
|
79
|
-
url,
|
|
80
|
-
}
|
|
81
|
-
: {
|
|
82
|
-
'@type': 'Offer',
|
|
83
|
-
price: priceInfo.price,
|
|
84
|
-
priceCurrency: resolvedCurrency,
|
|
85
|
-
availability,
|
|
86
|
-
itemCondition,
|
|
87
|
-
...(product.salePrice && product.salePriceEndsAt
|
|
88
|
-
? { priceValidUntil: product.salePriceEndsAt }
|
|
89
|
-
: {}),
|
|
90
|
-
...(shippingDetails.length > 0 ? { shippingDetails } : {}),
|
|
91
|
-
url,
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
// schema.org/Product.description must be plain text — Google's Rich Results
|
|
95
|
-
// and AI Overviews ingest this field directly and reject markup. Strip HTML
|
|
96
|
-
// first; if the description is empty after stripping, fall back to the name.
|
|
97
|
-
const cleanDescription = stripHtmlForSeo(product.description) || product.name;
|
|
98
|
-
const brandName = product.brands?.[0]?.name;
|
|
99
|
-
const productJsonLd: Record<string, unknown> = {
|
|
100
|
-
'@context': 'https://schema.org',
|
|
101
|
-
'@type': 'Product',
|
|
102
|
-
name: product.name,
|
|
103
|
-
description: cleanDescription,
|
|
104
|
-
image: imageUrl,
|
|
105
|
-
url,
|
|
106
|
-
sku: product.sku || product.id,
|
|
107
|
-
// Brand + identifiers qualify the PDP for Google's free merchant listings
|
|
108
|
-
// (no Merchant Center feed needed). Emitted only when present — Google
|
|
109
|
-
// penalizes fabricated identifiers.
|
|
110
|
-
...(brandName ? { brand: { '@type': 'Brand', name: brandName } } : {}),
|
|
111
|
-
...(product.gtin ? { gtin: product.gtin } : {}),
|
|
112
|
-
...(product.mpn ? { mpn: product.mpn } : {}),
|
|
113
|
-
offers,
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
// Emit aggregateRating only when there is real review data — Google rejects
|
|
117
|
-
// self-serving / faked ratings and stores with 0 reviews shouldn't claim any.
|
|
118
|
-
if (product.reviewCount && product.reviewCount > 0) {
|
|
119
|
-
productJsonLd.aggregateRating = {
|
|
120
|
-
'@type': 'AggregateRating',
|
|
121
|
-
ratingValue: product.avgRating,
|
|
122
|
-
reviewCount: product.reviewCount,
|
|
123
|
-
bestRating: 5,
|
|
124
|
-
worstRating: 1,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const breadcrumbJsonLd = {
|
|
129
|
-
'@context': 'https://schema.org',
|
|
130
|
-
'@type': 'BreadcrumbList',
|
|
131
|
-
itemListElement: [
|
|
132
|
-
{
|
|
133
|
-
'@type': 'ListItem',
|
|
134
|
-
position: 1,
|
|
135
|
-
name: 'Home',
|
|
136
|
-
item: baseUrl || '/',
|
|
137
|
-
},
|
|
138
|
-
{
|
|
139
|
-
'@type': 'ListItem',
|
|
140
|
-
position: 2,
|
|
141
|
-
name: 'Products',
|
|
142
|
-
item: `${baseUrl}/products`,
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
'@type': 'ListItem',
|
|
146
|
-
position: 3,
|
|
147
|
-
name: product.name,
|
|
148
|
-
item: url,
|
|
149
|
-
},
|
|
150
|
-
],
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
return (
|
|
154
|
-
<>
|
|
155
|
-
<script
|
|
156
|
-
type="application/ld+json"
|
|
157
|
-
nonce={nonce}
|
|
158
|
-
suppressHydrationWarning
|
|
159
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(productJsonLd) }}
|
|
160
|
-
/>
|
|
161
|
-
<script
|
|
162
|
-
type="application/ld+json"
|
|
163
|
-
nonce={nonce}
|
|
164
|
-
suppressHydrationWarning
|
|
165
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
166
|
-
/>
|
|
167
|
-
</>
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Serialize a JSON-LD object for embedding in a <script> tag. Escapes
|
|
172
|
-
// `<`, `>`, and `&` to \uXXXX so seller-controlled product fields cannot
|
|
173
|
-
// break out of the script element with `</script>` or inject HTML.
|
|
174
|
-
function serializeJsonLd(value: unknown): string {
|
|
175
|
-
return JSON.stringify(value)
|
|
176
|
-
.replace(/</g, '\\u003c')
|
|
177
|
-
.replace(/>/g, '\\u003e')
|
|
178
|
-
.replace(/&/g, '\\u0026');
|
|
179
|
-
}
|
|
1
|
+
import type { Product, ShippingSummaryEntry } from 'brainerce';
|
|
2
|
+
import { buildProductJsonLd, buildBreadcrumbJsonLd } from 'brainerce';
|
|
3
|
+
import { getNonce } from '@/core/lib/nonce';
|
|
4
|
+
import { resolveCurrency } from '@/core/lib/resolve-currency';
|
|
5
|
+
|
|
6
|
+
interface ProductJsonLdProps {
|
|
7
|
+
product: Product;
|
|
8
|
+
url: string;
|
|
9
|
+
currency?: string;
|
|
10
|
+
/** From `storeInfo.shipping` — real flat-rate/free zones only. Omitted entirely when unset, never fabricated. */
|
|
11
|
+
shipping?: ShippingSummaryEntry[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Product + BreadcrumbList structured data for a product detail page (PDP).
|
|
16
|
+
*
|
|
17
|
+
* Uses the SDK's `buildProductJsonLd` — NEVER hand-roll the schema object
|
|
18
|
+
* here. The builder owns the availability mapping (InStock / BackOrder /
|
|
19
|
+
* OutOfStock from the backend's pre-computed inventory flags), the
|
|
20
|
+
* Offer/AggregateOffer split, identifier emission (gtin/mpn/sku), shipping
|
|
21
|
+
* details, and the aggregateRating gating; a local copy WILL drift from it.
|
|
22
|
+
* The <script> is rendered locally only to attach the CSP nonce (the SDK's
|
|
23
|
+
* jsonLdScriptProps can't).
|
|
24
|
+
*/
|
|
25
|
+
export async function ProductJsonLd({ product, url, currency, shipping }: ProductJsonLdProps) {
|
|
26
|
+
// Defer to the shared server helper so the env-var + USD fallback chain
|
|
27
|
+
// lives in exactly one place across all server-side currency reads.
|
|
28
|
+
const resolvedCurrency = resolveCurrency(null, currency);
|
|
29
|
+
const nonce = await getNonce();
|
|
30
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
31
|
+
|
|
32
|
+
const productJsonLd = buildProductJsonLd(product, {
|
|
33
|
+
siteUrl: baseUrl,
|
|
34
|
+
path: url,
|
|
35
|
+
currency: resolvedCurrency,
|
|
36
|
+
shipping,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const breadcrumbJsonLd = buildBreadcrumbJsonLd([
|
|
40
|
+
{ name: 'Home', url: baseUrl || '/' },
|
|
41
|
+
{ name: 'Products', url: `${baseUrl}/products` },
|
|
42
|
+
{ name: product.name, url },
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
<script
|
|
48
|
+
type="application/ld+json"
|
|
49
|
+
nonce={nonce}
|
|
50
|
+
suppressHydrationWarning
|
|
51
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(productJsonLd) }}
|
|
52
|
+
/>
|
|
53
|
+
<script
|
|
54
|
+
type="application/ld+json"
|
|
55
|
+
nonce={nonce}
|
|
56
|
+
suppressHydrationWarning
|
|
57
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
58
|
+
/>
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Serialize a JSON-LD object for embedding in a <script> tag. Escapes
|
|
64
|
+
// `<`, `>`, and `&` to \uXXXX so seller-controlled product fields cannot
|
|
65
|
+
// break out of the script element with `</script>` or inject HTML.
|
|
66
|
+
function serializeJsonLd(value: unknown): string {
|
|
67
|
+
return JSON.stringify(value)
|
|
68
|
+
.replace(/</g, '\\u003c')
|
|
69
|
+
.replace(/>/g, '\\u003e')
|
|
70
|
+
.replace(/&/g, '\\u0026');
|
|
71
|
+
}
|
|
@@ -33,6 +33,13 @@ export interface PublicStoreInfo {
|
|
|
33
33
|
* `<TrackingBootstrap>`; nothing else should read them.
|
|
34
34
|
*/
|
|
35
35
|
tracking?: StoreInfo['tracking'];
|
|
36
|
+
/**
|
|
37
|
+
* SEO surface: IndexNow key (served at /indexnow-key.txt) and the Google
|
|
38
|
+
* site-verification token (rendered as a meta tag in the root layout so the
|
|
39
|
+
* merchant can verify the domain in Search Console / claim it in Merchant
|
|
40
|
+
* Center). Both public by protocol design.
|
|
41
|
+
*/
|
|
42
|
+
seo?: StoreInfo['seo'];
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/**
|
|
@@ -56,5 +63,6 @@ export function pickPublicStoreInfo(raw: StoreInfo): PublicStoreInfo {
|
|
|
56
63
|
i18n: raw.i18n,
|
|
57
64
|
shipping: raw.shipping,
|
|
58
65
|
tracking: raw.tracking,
|
|
66
|
+
seo: raw.seo,
|
|
59
67
|
};
|
|
60
68
|
}
|
|
@@ -20,7 +20,7 @@ export function CartNudges({ nudges, className }: CartNudgesProps) {
|
|
|
20
20
|
className="bg-primary/5 border-primary/20 flex items-start gap-3 rounded-lg border px-4 py-3"
|
|
21
21
|
>
|
|
22
22
|
<Info className="text-primary mt-0.5 h-5 w-5 flex-shrink-0" aria-hidden="true" />
|
|
23
|
-
<p className="text-foreground flex-1 text-sm">{nudge.text}</p>
|
|
23
|
+
<p className="text-foreground flex-1 text-sm whitespace-pre-line">{nudge.text}</p>
|
|
24
24
|
</div>
|
|
25
25
|
))}
|
|
26
26
|
</div>
|
|
@@ -11,7 +11,9 @@ export function DiscountBannerStrip({ banners }: { banners: DiscountBanner[] })
|
|
|
11
11
|
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-8">
|
|
12
12
|
<div className="flex items-center justify-center gap-4 overflow-x-auto text-sm font-medium">
|
|
13
13
|
{banners.map((banner) => (
|
|
14
|
-
<span key={banner.ruleId}
|
|
14
|
+
<span key={banner.ruleId} className="whitespace-pre-line text-center">
|
|
15
|
+
{banner.text}
|
|
16
|
+
</span>
|
|
15
17
|
))}
|
|
16
18
|
</div>
|
|
17
19
|
</div>
|
|
@@ -104,6 +104,16 @@ export default async function RootLayout({
|
|
|
104
104
|
.replace(/&/g, '\\u0026'),
|
|
105
105
|
}}
|
|
106
106
|
/>
|
|
107
|
+
{/* Google Search Console verification — token set by the merchant in
|
|
108
|
+
the dashboard (channel settings → Google site verification). Also
|
|
109
|
+
what the Merchant Center website claim checks for. Renders nothing
|
|
110
|
+
until a token is configured. */}
|
|
111
|
+
{storeInfo?.seo?.googleSiteVerification ? (
|
|
112
|
+
<meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
|
|
113
|
+
) : null}
|
|
114
|
+
{/* RSS autodiscovery — feed readers and aggregators find the blog feed
|
|
115
|
+
from any page. The route itself lives at /blog/rss.xml. */}
|
|
116
|
+
<link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
|
|
107
117
|
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
108
118
|
SPA route changes. Cookieless (no consent banner needed); the
|
|
109
119
|
merchant can toggle it per sales channel in the dashboard. */}
|
|
@@ -230,6 +240,16 @@ export default async function RootLayout({
|
|
|
230
240
|
.replace(/&/g, '\\u0026'),
|
|
231
241
|
}}
|
|
232
242
|
/>
|
|
243
|
+
{/* Google Search Console verification — token set by the merchant in
|
|
244
|
+
the dashboard (channel settings → Google site verification). Also
|
|
245
|
+
what the Merchant Center website claim checks for. Renders nothing
|
|
246
|
+
until a token is configured. */}
|
|
247
|
+
{storeInfo?.seo?.googleSiteVerification ? (
|
|
248
|
+
<meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
|
|
249
|
+
) : null}
|
|
250
|
+
{/* RSS autodiscovery — feed readers and aggregators find the blog feed
|
|
251
|
+
from any page. The route itself lives at /blog/rss.xml. */}
|
|
252
|
+
<link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
|
|
233
253
|
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
234
254
|
SPA route changes. Cookieless (no consent banner needed); the
|
|
235
255
|
merchant can toggle it per sales channel in the dashboard. */}
|
|
@@ -21,7 +21,7 @@ export function CartNudges({ nudges, className }: CartNudgesProps) {
|
|
|
21
21
|
{nudges.map((nudge) => (
|
|
22
22
|
<li
|
|
23
23
|
key={nudge.ruleId}
|
|
24
|
-
className="flex items-center gap-2.5 rounded-lg bg-secondary px-3.5 py-2.5 text-sm font-medium text-foreground"
|
|
24
|
+
className="flex items-center gap-2.5 rounded-lg bg-secondary px-3.5 py-2.5 text-sm font-medium whitespace-pre-line text-foreground"
|
|
25
25
|
>
|
|
26
26
|
<span aria-hidden="true" className="shrink-0 text-primary">
|
|
27
27
|
<IconSparkle size={18} />
|
|
@@ -13,7 +13,7 @@ export function DiscountBannerStrip({ banners }: { banners: DiscountBanner[] })
|
|
|
13
13
|
{banners.map((banner) => (
|
|
14
14
|
<li
|
|
15
15
|
key={banner.ruleId}
|
|
16
|
-
className="inline-flex items-center gap-2 text-[13px] font-semibold text-foreground"
|
|
16
|
+
className="inline-flex items-center gap-2 text-[13px] font-semibold whitespace-pre-line text-foreground"
|
|
17
17
|
>
|
|
18
18
|
<span aria-hidden="true" className="text-primary">
|
|
19
19
|
<IconTag size={16} />
|
|
@@ -20,7 +20,9 @@ export function CartNudges({ nudges, className }: CartNudgesProps) {
|
|
|
20
20
|
return (
|
|
21
21
|
<ul className={cn(className)} aria-label="Cart suggestions">
|
|
22
22
|
{nudges.map((nudge) => (
|
|
23
|
-
<li key={nudge.ruleId}>
|
|
23
|
+
<li key={nudge.ruleId} className="whitespace-pre-line">
|
|
24
|
+
{nudge.text}
|
|
25
|
+
</li>
|
|
24
26
|
))}
|
|
25
27
|
</ul>
|
|
26
28
|
);
|
|
@@ -11,7 +11,9 @@ export function DiscountBannerStrip({ banners }: { banners: DiscountBanner[] })
|
|
|
11
11
|
{/* DESIGN ME — discount banner strip: active discount-rule texts; banners come from useHomeData() in the parent. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
12
12
|
<ul className="flex items-center gap-4">
|
|
13
13
|
{banners.map((banner) => (
|
|
14
|
-
<li key={banner.ruleId}>
|
|
14
|
+
<li key={banner.ruleId} className="whitespace-pre-line">
|
|
15
|
+
{banner.text}
|
|
16
|
+
</li>
|
|
15
17
|
))}
|
|
16
18
|
</ul>
|
|
17
19
|
</div>
|