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.
Files changed (196) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +71 -71
  3. package/dist/index.js +5 -2
  4. package/messages/en.json +502 -502
  5. package/messages/he.json +502 -502
  6. package/package.json +1 -1
  7. package/templates/nextjs/base/.claude/commands/design.md +60 -60
  8. package/templates/nextjs/base/.env.local.ejs +24 -24
  9. package/templates/nextjs/base/.eslintrc.json +57 -57
  10. package/templates/nextjs/base/.mcp.json +8 -8
  11. package/templates/nextjs/base/AI-GUIDE.md +174 -174
  12. package/templates/nextjs/base/TRANSLATIONS.md +200 -200
  13. package/templates/nextjs/base/package.json.ejs +52 -52
  14. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +262 -262
  15. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
  16. package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
  17. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +106 -106
  18. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
  19. package/templates/nextjs/base/src/app/globals.css +36 -36
  20. package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
  21. package/templates/nextjs/base/src/app/layout.tsx.ejs +10 -0
  22. package/templates/nextjs/base/src/app/llms.txt/route.ts +44 -44
  23. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
  24. package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
  25. package/templates/nextjs/base/src/app/page.tsx +64 -64
  26. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +175 -175
  27. package/templates/nextjs/base/src/app/products/page.tsx +19 -19
  28. package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
  29. package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
  30. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
  31. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
  32. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
  33. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
  34. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
  35. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
  36. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
  37. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
  38. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
  39. package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
  40. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
  41. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
  42. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +179 -179
  43. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
  44. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
  45. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
  46. package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
  47. package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
  48. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
  49. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
  50. package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
  51. package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
  52. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
  53. package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
  54. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
  55. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
  56. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
  57. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
  58. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
  59. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
  60. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
  61. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
  62. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
  63. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
  64. package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
  65. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
  66. package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
  67. package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
  68. package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
  69. package/templates/nextjs/base/src/core/lib/store-info.ts +9 -0
  70. package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
  71. package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
  72. package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
  73. package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
  74. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
  75. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
  76. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
  77. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
  78. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
  79. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
  80. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
  81. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
  82. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
  83. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -20
  84. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
  85. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  86. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
  87. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
  88. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
  89. package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
  90. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
  91. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
  92. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
  93. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
  94. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
  95. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
  96. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
  97. package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
  98. package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
  99. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
  100. package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
  101. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
  102. package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
  103. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
  104. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
  105. package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
  106. package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
  107. package/templates/nextjs/base/tailwind.config.ts +69 -69
  108. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +10 -0
  109. package/templates/nextjs/designs/atelier/globals.css +402 -402
  110. package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
  111. package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
  112. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
  113. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
  114. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
  115. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
  116. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
  117. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
  118. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
  119. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
  120. package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
  121. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
  122. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
  123. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
  124. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
  125. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
  126. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
  127. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
  128. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
  129. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
  130. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
  131. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
  132. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
  133. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
  134. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
  135. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
  136. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
  137. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
  138. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
  139. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
  140. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
  141. package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
  142. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
  143. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
  144. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
  145. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
  146. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
  147. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
  148. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
  149. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
  150. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
  151. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
  152. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
  153. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
  154. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
  155. package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
  156. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
  157. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
  158. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
  159. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
  160. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
  161. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
  162. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -27
  163. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
  164. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
  165. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
  166. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
  167. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
  168. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
  169. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -19
  170. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
  171. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
  172. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
  173. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
  174. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
  175. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
  176. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
  177. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
  178. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
  179. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
  180. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
  181. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
  182. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
  183. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
  184. package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
  185. package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
  186. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
  187. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
  188. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
  189. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
  190. package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
  191. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
  192. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
  193. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
  194. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
  195. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
  196. 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
+ }