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.
Files changed (23) hide show
  1. package/dist/index.js +32 -6
  2. package/package.json +1 -1
  3. package/templates/nextjs/base/src/app/agents.md/route.ts +87 -0
  4. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +51 -5
  5. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +70 -0
  6. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +132 -106
  7. package/templates/nextjs/base/src/app/contact/layout.tsx.ejs +14 -0
  8. package/templates/nextjs/base/src/app/layout.tsx.ejs +20 -0
  9. package/templates/nextjs/base/src/app/llms.txt/route.ts +67 -44
  10. package/templates/nextjs/base/src/app/not-found.tsx.ejs +35 -0
  11. package/templates/nextjs/base/src/app/opengraph-image.tsx +109 -0
  12. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +187 -175
  13. package/templates/nextjs/base/src/app/robots.ts +73 -14
  14. package/templates/nextjs/base/src/app/sitemap.ts +89 -66
  15. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +71 -179
  16. package/templates/nextjs/base/src/core/lib/store-info.ts +8 -0
  17. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +1 -1
  18. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +3 -1
  19. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +20 -0
  20. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +1 -1
  21. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +1 -1
  22. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +3 -1
  23. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +3 -1
@@ -1,14 +1,73 @@
1
- import type { MetadataRoute } from 'next';
2
-
3
- export default function robots(): MetadataRoute.Robots {
4
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
5
-
6
- return {
7
- rules: {
8
- userAgent: '*',
9
- allow: '/',
10
- disallow: ['/api/', '/auth/', '/checkout/', '/account/'],
11
- },
12
- sitemap: `${baseUrl}/sitemap.xml`,
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 { getBlogSitemapEntries, getCategorySitemapEntries } from 'brainerce';
3
- import { getServerClient } from '@/core/lib/brainerce';
4
-
5
- export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
6
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
7
-
8
- const client = getServerClient();
9
- const storeInfo = await client.getStoreInfo().catch(() => null);
10
- const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
11
- const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
12
- const isMultiLocale = supportedLocales.length > 1;
13
-
14
- const staticPages: MetadataRoute.Sitemap = isMultiLocale
15
- ? supportedLocales.flatMap((loc) => {
16
- const prefix = loc === defaultLoc ? '' : `/${loc}`;
17
- return [
18
- { url: `${baseUrl}${prefix || '/'}`, lastModified: new Date(), priority: 1 },
19
- { url: `${baseUrl}${prefix}/products`, lastModified: new Date(), priority: 0.9 },
20
- ];
21
- })
22
- : [
23
- { url: baseUrl, lastModified: new Date(), priority: 1 },
24
- { url: `${baseUrl}/products`, lastModified: new Date(), priority: 0.9 },
25
- ];
26
-
27
- try {
28
- const { data: products } = await client.getProducts({ limit: 1000 });
29
-
30
- const productPages: MetadataRoute.Sitemap = products.flatMap((product) => {
31
- const baseSlug = product.slug || product.id;
32
- const localeSlugs = product.localeSlugs ?? {};
33
- const lastMod = product.updatedAt ? new Date(product.updatedAt) : new Date();
34
-
35
- if (!isMultiLocale) {
36
- return [{ url: `${baseUrl}/products/${baseSlug}`, lastModified: lastMod, priority: 0.8 }];
37
- }
38
-
39
- return supportedLocales.map((loc) => {
40
- const locSlug = localeSlugs[loc] || baseSlug;
41
- const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
42
- return { url: `${baseUrl}${path}`, lastModified: lastMod, priority: 0.8 };
43
- });
44
- });
45
-
46
- // Category (collection) pages — the highest-value organic-SEO surface;
47
- // must be crawlable so they can rank for broad research-intent queries.
48
- const categoryPages: MetadataRoute.Sitemap = await getCategorySitemapEntries(client, {
49
- siteUrl: baseUrl,
50
- locales: isMultiLocale ? supportedLocales : undefined,
51
- defaultLocale: defaultLoc,
52
- }).catch(() => []);
53
-
54
- // Blog posts — the SEO Autopilot (and manual publishes) must be
55
- // discoverable; missing blog entries = articles never get crawled.
56
- const blogPages: MetadataRoute.Sitemap = await getBlogSitemapEntries(client, {
57
- siteUrl: baseUrl,
58
- locales: isMultiLocale ? supportedLocales : undefined,
59
- defaultLocale: defaultLoc,
60
- }).catch(() => []);
61
-
62
- return [...staticPages, ...productPages, ...categoryPages, ...blogPages];
63
- } catch {
64
- return staticPages;
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 { getProductPriceInfo } from 'brainerce';
3
- import { getNonce } from '@/core/lib/nonce';
4
- import { resolveCurrency } from '@/core/lib/resolve-currency';
5
- import { stripHtmlForSeo } from '@/core/lib/seo';
6
-
7
- interface ProductJsonLdProps {
8
- product: Product;
9
- url: string;
10
- currency?: string;
11
- /** From `storeInfo.shipping` — real flat-rate/free zones only. Omitted entirely when unset, never fabricated. */
12
- shipping?: ShippingSummaryEntry[];
13
- }
14
-
15
- export async function ProductJsonLd({ product, url, currency, shipping }: ProductJsonLdProps) {
16
- // Defer to the shared server helper so the env-var + USD fallback chain
17
- // lives in exactly one place across all server-side currency reads.
18
- const resolvedCurrency = resolveCurrency(null, currency);
19
- const nonce = await getNonce();
20
- const priceInfo = getProductPriceInfo(product);
21
- const imageUrl = product.images?.[0]?.url;
22
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
23
-
24
- const availability =
25
- product.inventory?.canPurchase !== false
26
- ? 'https://schema.org/InStock'
27
- : 'https://schema.org/OutOfStock';
28
-
29
- // All Brainerce catalog listings are first-party new-goods sales — no
30
- // used/refurbished condition data exists anywhere in the product model, so
31
- // this is a deliberate always-true assumption, not a per-product field.
32
- const itemCondition = 'https://schema.org/NewCondition';
33
-
34
- const shippingDetails = (shipping ?? [])
35
- .filter((z) => z.amount !== null)
36
- .map((z) => ({
37
- '@type': 'OfferShippingDetails',
38
- shippingRate: { '@type': 'MonetaryAmount', value: z.amount, currency: resolvedCurrency },
39
- shippingDestination: { '@type': 'DefinedRegion', addressCountry: z.countries },
40
- ...(z.handlingTime != null || z.minDeliveryDays != null || z.maxDeliveryDays != null
41
- ? {
42
- deliveryTime: {
43
- '@type': 'ShippingDeliveryTime',
44
- ...(z.handlingTime != null
45
- ? {
46
- handlingTime: {
47
- '@type': 'QuantitativeValue',
48
- minValue: 0,
49
- maxValue: z.handlingTime,
50
- },
51
- }
52
- : {}),
53
- ...(z.minDeliveryDays != null || z.maxDeliveryDays != null
54
- ? {
55
- transitTime: {
56
- '@type': 'QuantitativeValue',
57
- minValue: z.minDeliveryDays ?? z.maxDeliveryDays,
58
- maxValue: z.maxDeliveryDays ?? z.minDeliveryDays,
59
- },
60
- }
61
- : {}),
62
- },
63
- }
64
- : {}),
65
- }));
66
-
67
- const isVariable = product.type === 'VARIABLE';
68
- const offers =
69
- isVariable && product.priceMin
70
- ? {
71
- '@type': 'AggregateOffer',
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}>{banner.text}</span>
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}>{nudge.text}</li>
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}>{banner.text}</li>
14
+ <li key={banner.ruleId} className="whitespace-pre-line">
15
+ {banner.text}
16
+ </li>
15
17
  ))}
16
18
  </ul>
17
19
  </div>