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,179 +1,179 @@
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 { 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
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import { getClient } from '@/core/lib/brainerce';
6
+ import { useStoreInfo } from '@/core/providers/store-provider';
7
+
8
+ /**
9
+ * Boots the merchant's marketing tags — GA4, Google Tag Manager, the Meta
10
+ * pixel, the TikTok pixel — from whatever they connected in the Brainerce
11
+ * dashboard.
12
+ *
13
+ * There is nothing to configure here and no environment variable to set. The
14
+ * tag ids arrive inside `storeInfo.tracking`, which the backend resolves from
15
+ * the merchant's connected marketplace apps: connecting the Google & YouTube
16
+ * app runs GA4 discovery and the measurement id shows up on its own, likewise
17
+ * the Meta and TikTok pixels. Connect an app in the dashboard and this file
18
+ * starts loading its tag within a minute — no redeploy, no code change.
19
+ *
20
+ * Loading GA4 also switches on Brainerce's server-side purchase conversion:
21
+ * the SDK captures gtag's `client_id`/`session_id` and attaches them to cart
22
+ * and checkout calls, which is what lets the backend send a purchase event
23
+ * that lands in the right GA4 session instead of orphaning.
24
+ */
25
+ export function TrackingBootstrap() {
26
+ const { storeInfo } = useStoreInfo();
27
+ const pathname = usePathname();
28
+ const tracking = storeInfo?.tracking;
29
+ const bootedRef = useRef(false);
30
+
31
+ useEffect(() => {
32
+ if (!tracking) return;
33
+ getClient().initTracking(tracking);
34
+ bootedRef.current = true;
35
+ }, [tracking]);
36
+
37
+ // App Router client-side navigation never reloads the document, so the
38
+ // pixels' one-shot boot pageview is the only one they would ever record —
39
+ // every page after the landing page would be invisible. Re-fire on each
40
+ // path change, skipping the first run so the landing page isn't counted
41
+ // twice.
42
+ const lastPathRef = useRef<string | null>(null);
43
+ useEffect(() => {
44
+ if (!bootedRef.current) return;
45
+ if (lastPathRef.current === null) {
46
+ lastPathRef.current = pathname;
47
+ return;
48
+ }
49
+ if (lastPathRef.current === pathname) return;
50
+ lastPathRef.current = pathname;
51
+
52
+ if (typeof window === 'undefined') return;
53
+ try {
54
+ window.fbq?.('track', 'PageView');
55
+ (window.ttq as { page?: () => void } | undefined)?.page?.();
56
+ } catch {
57
+ // Analytics must never break navigation.
58
+ }
59
+ }, [pathname]);
60
+
61
+ return null;
62
+ }
@@ -1,53 +1,53 @@
1
- 'use client';
2
-
3
- import * as React from 'react';
4
- import * as AccordionPrimitive from '@radix-ui/react-accordion';
5
- import { ChevronDown } from 'lucide-react';
6
-
7
- import { cn } from '@/core/lib/utils';
8
-
9
- const Accordion = AccordionPrimitive.Root;
10
-
11
- const AccordionItem = React.forwardRef<
12
- React.ElementRef<typeof AccordionPrimitive.Item>,
13
- React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
14
- >(({ className, ...props }, ref) => (
15
- <AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
16
- ));
17
- AccordionItem.displayName = 'AccordionItem';
18
-
19
- const AccordionTrigger = React.forwardRef<
20
- React.ElementRef<typeof AccordionPrimitive.Trigger>,
21
- React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
22
- >(({ className, children, ...props }, ref) => (
23
- <AccordionPrimitive.Header className="flex">
24
- <AccordionPrimitive.Trigger
25
- ref={ref}
26
- className={cn(
27
- 'flex flex-1 items-center justify-between py-4 text-start font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
28
- className
29
- )}
30
- {...props}
31
- >
32
- {children}
33
- <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
34
- </AccordionPrimitive.Trigger>
35
- </AccordionPrimitive.Header>
36
- ));
37
- AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
38
-
39
- const AccordionContent = React.forwardRef<
40
- React.ElementRef<typeof AccordionPrimitive.Content>,
41
- React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
42
- >(({ className, children, ...props }, ref) => (
43
- <AccordionPrimitive.Content
44
- ref={ref}
45
- className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
46
- {...props}
47
- >
48
- <div className={cn('pb-4 pt-0', className)}>{children}</div>
49
- </AccordionPrimitive.Content>
50
- ));
51
- AccordionContent.displayName = AccordionPrimitive.Content.displayName;
52
-
53
- export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import * as AccordionPrimitive from '@radix-ui/react-accordion';
5
+ import { ChevronDown } from 'lucide-react';
6
+
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ const Accordion = AccordionPrimitive.Root;
10
+
11
+ const AccordionItem = React.forwardRef<
12
+ React.ElementRef<typeof AccordionPrimitive.Item>,
13
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
14
+ >(({ className, ...props }, ref) => (
15
+ <AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
16
+ ));
17
+ AccordionItem.displayName = 'AccordionItem';
18
+
19
+ const AccordionTrigger = React.forwardRef<
20
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
21
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
22
+ >(({ className, children, ...props }, ref) => (
23
+ <AccordionPrimitive.Header className="flex">
24
+ <AccordionPrimitive.Trigger
25
+ ref={ref}
26
+ className={cn(
27
+ 'flex flex-1 items-center justify-between py-4 text-start font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
28
+ className
29
+ )}
30
+ {...props}
31
+ >
32
+ {children}
33
+ <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
34
+ </AccordionPrimitive.Trigger>
35
+ </AccordionPrimitive.Header>
36
+ ));
37
+ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
38
+
39
+ const AccordionContent = React.forwardRef<
40
+ React.ElementRef<typeof AccordionPrimitive.Content>,
41
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
42
+ >(({ className, children, ...props }, ref) => (
43
+ <AccordionPrimitive.Content
44
+ ref={ref}
45
+ className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
46
+ {...props}
47
+ >
48
+ <div className={cn('pb-4 pt-0', className)}>{children}</div>
49
+ </AccordionPrimitive.Content>
50
+ ));
51
+ AccordionContent.displayName = AccordionPrimitive.Content.displayName;
52
+
53
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -1,32 +1,32 @@
1
- import * as React from 'react';
2
- import { cva, type VariantProps } from 'class-variance-authority';
3
-
4
- import { cn } from '@/core/lib/utils';
5
-
6
- const badgeVariants = cva(
7
- 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8
- {
9
- variants: {
10
- variant: {
11
- default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
12
- secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
13
- destructive:
14
- 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
15
- outline: 'text-foreground',
16
- },
17
- },
18
- defaultVariants: {
19
- variant: 'default',
20
- },
21
- }
22
- );
23
-
24
- export interface BadgeProps
25
- extends React.HTMLAttributes<HTMLDivElement>,
26
- VariantProps<typeof badgeVariants> {}
27
-
28
- function Badge({ className, variant, ...props }: BadgeProps) {
29
- return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
30
- }
31
-
32
- export { Badge, badgeVariants };
1
+ import * as React from 'react';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+
4
+ import { cn } from '@/core/lib/utils';
5
+
6
+ const badgeVariants = cva(
7
+ 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
12
+ secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
13
+ destructive:
14
+ 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
15
+ outline: 'text-foreground',
16
+ },
17
+ },
18
+ defaultVariants: {
19
+ variant: 'default',
20
+ },
21
+ }
22
+ );
23
+
24
+ export interface BadgeProps
25
+ extends React.HTMLAttributes<HTMLDivElement>,
26
+ VariantProps<typeof badgeVariants> {}
27
+
28
+ function Badge({ className, variant, ...props }: BadgeProps) {
29
+ return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
30
+ }
31
+
32
+ export { Badge, badgeVariants };