create-brainerce-store 1.62.0 → 1.64.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 (203) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +71 -71
  3. package/dist/index.js +26 -3
  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/agents.md/route.ts +87 -0
  15. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +308 -262
  16. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
  17. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +70 -0
  18. package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
  19. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +29 -3
  20. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
  21. package/templates/nextjs/base/src/app/contact/layout.tsx.ejs +14 -0
  22. package/templates/nextjs/base/src/app/globals.css +36 -36
  23. package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
  24. package/templates/nextjs/base/src/app/layout.tsx.ejs +30 -0
  25. package/templates/nextjs/base/src/app/llms.txt/route.ts +28 -5
  26. package/templates/nextjs/base/src/app/not-found.tsx.ejs +35 -0
  27. package/templates/nextjs/base/src/app/opengraph-image.tsx +109 -0
  28. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
  29. package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
  30. package/templates/nextjs/base/src/app/page.tsx +64 -64
  31. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  32. package/templates/nextjs/base/src/app/products/page.tsx +19 -19
  33. package/templates/nextjs/base/src/app/robots.ts +73 -14
  34. package/templates/nextjs/base/src/app/sitemap.ts +89 -66
  35. package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
  36. package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
  37. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
  38. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
  39. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
  40. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
  41. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
  42. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
  43. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
  44. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
  45. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
  46. package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
  47. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
  48. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
  49. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +23 -131
  50. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
  51. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
  52. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
  53. package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
  54. package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
  55. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
  56. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
  57. package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
  58. package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
  59. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
  60. package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
  61. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
  62. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
  63. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
  64. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
  65. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
  66. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
  67. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
  68. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
  69. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
  70. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
  71. package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
  72. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
  73. package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
  74. package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
  75. package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
  76. package/templates/nextjs/base/src/core/lib/store-info.ts +17 -0
  77. package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
  78. package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
  79. package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
  80. package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
  81. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
  82. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
  83. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
  84. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
  85. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
  86. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
  87. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
  88. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
  89. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
  90. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +22 -20
  91. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
  92. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  93. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
  94. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
  95. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
  96. package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
  97. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
  98. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
  99. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
  100. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
  101. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
  102. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
  103. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
  104. package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
  105. package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
  106. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
  107. package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
  108. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
  109. package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
  110. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
  111. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
  112. package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
  113. package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
  114. package/templates/nextjs/base/tailwind.config.ts +69 -69
  115. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +30 -0
  116. package/templates/nextjs/designs/atelier/globals.css +402 -402
  117. package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
  118. package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
  119. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
  120. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
  121. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
  122. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
  123. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
  124. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
  125. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
  126. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
  127. package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
  128. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
  129. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
  130. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
  131. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
  132. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
  133. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
  134. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
  135. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
  136. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
  137. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
  138. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
  139. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
  140. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
  141. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
  142. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
  143. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
  144. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
  145. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
  146. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
  147. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
  148. package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
  149. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
  150. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
  151. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
  152. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
  153. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
  154. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
  155. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
  156. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
  157. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
  158. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
  159. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
  160. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
  161. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
  162. package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
  163. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
  164. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
  165. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
  166. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
  167. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
  168. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
  169. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +29 -27
  170. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
  171. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
  172. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
  173. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
  174. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
  175. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
  176. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +21 -19
  177. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
  178. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
  179. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
  180. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
  181. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
  182. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
  183. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
  184. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
  185. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
  186. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
  187. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
  188. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
  189. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
  190. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
  191. package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
  192. package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
  193. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
  194. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
  195. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
  196. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
  197. package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
  198. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
  199. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
  200. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
  201. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
  202. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
  203. package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
@@ -1,121 +1,121 @@
1
- /**
2
- * Pure product-option logic (zero markup) shared by `core/hooks/use-product-page`
3
- * and the `ui/product` option components. Moved verbatim from
4
- * `customization-fields.tsx` / `modifier-group-selector.tsx` so that core
5
- * never depends on `ui/` — the UI components re-export these for
6
- * backward compatibility.
7
- */
8
-
9
- import type { ModifierGroup, ModifierSelection, ProductCustomizationField } from 'brainerce';
10
-
11
- export type CustomizationValues = Record<string, unknown>;
12
-
13
- export function validateCustomization(
14
- fields: ProductCustomizationField[],
15
- values: CustomizationValues
16
- ): Record<string, string> {
17
- const errors: Record<string, string> = {};
18
- for (const field of fields) {
19
- const value = values[field.key];
20
- const empty =
21
- value === undefined ||
22
- value === null ||
23
- value === '' ||
24
- (Array.isArray(value) && value.length === 0);
25
-
26
- if (field.required && empty) {
27
- errors[field.key] = 'required';
28
- continue;
29
- }
30
- if (empty) continue;
31
-
32
- if (field.type === 'SELECT') {
33
- const allowed = (field.enumValues ?? []).map((o) => o.value);
34
- if (typeof value !== 'string' || !allowed.includes(value)) {
35
- errors[field.key] = 'invalidOption';
36
- }
37
- }
38
- if (field.type === 'MULTI_SELECT') {
39
- const allowed = (field.enumValues ?? []).map((o) => o.value);
40
- if (
41
- !Array.isArray(value) ||
42
- !(value as unknown[]).every((v) => typeof v === 'string' && allowed.includes(v as string))
43
- ) {
44
- errors[field.key] = 'invalidOption';
45
- }
46
- }
47
- }
48
- return errors;
49
- }
50
-
51
- /**
52
- * Lightweight client-side validation that mirrors the server's checks. Use
53
- * this to gate the add-to-cart button — but always treat the server's 400
54
- * (`MODIFIER_VALIDATION_FAILED`) as the authoritative answer.
55
- */
56
- export function validateSelections(
57
- groups: ModifierGroup[],
58
- selections: Record<string, string[]>
59
- ): string | null {
60
- for (const group of groups) {
61
- if (group.max === 0) continue; // disabled-for-variant — skip
62
- const picks = selections[group.id] ?? [];
63
- if (group.required && picks.length === 0) {
64
- return `${group.name} is required`;
65
- }
66
- if (picks.length < group.min) {
67
- return `Pick at least ${group.min} from ${group.name}`;
68
- }
69
- if (group.max != null && picks.length > group.max) {
70
- return `Pick at most ${group.max} from ${group.name}`;
71
- }
72
- if (group.selectionType === 'SINGLE' && picks.length > 1) {
73
- return `Only one option allowed in ${group.name}`;
74
- }
75
- }
76
- return null;
77
- }
78
-
79
- /**
80
- * Adapter: convert the local Record<groupId, modifierIds> shape used by the
81
- * selector component into the wire-format `ModifierSelection[]` array the SDK
82
- * expects on `addToCart` / `updateCartItem`.
83
- */
84
- export function toModifierSelections(
85
- groups: ModifierGroup[],
86
- selections: Record<string, string[]>
87
- ): ModifierSelection[] {
88
- const out: ModifierSelection[] = [];
89
- for (const group of groups) {
90
- if (group.max === 0) continue;
91
- const picks = selections[group.id] ?? [];
92
- if (picks.length === 0) continue;
93
- out.push({ modifierGroupId: group.id, modifierIds: picks });
94
- }
95
- return out;
96
- }
97
-
98
- /**
99
- * Build the initial selection map from the group's defaults — call once on
100
- * first render for each group. Honors `defaultModifierIds` first (per-attach
101
- * defaults), falling back to per-modifier `isDefault` flags. Sold-out
102
- * modifiers are filtered out so the user doesn't start with an invalid pick.
103
- */
104
- export function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
105
- const out: Record<string, string[]> = {};
106
- for (const group of groups) {
107
- if (group.max === 0) continue;
108
- const fromDefaults = group.defaultModifierIds ?? [];
109
- const fromIsDefault = group.modifiers
110
- .filter((m) => m.isDefault && m.available)
111
- .map((m) => m.id);
112
- const merged =
113
- fromDefaults.length > 0
114
- ? fromDefaults.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
115
- : fromIsDefault;
116
- // Cap by max for SINGLE groups — defaults can't violate selection rules.
117
- const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
118
- if (capped.length > 0) out[group.id] = capped;
119
- }
120
- return out;
121
- }
1
+ /**
2
+ * Pure product-option logic (zero markup) shared by `core/hooks/use-product-page`
3
+ * and the `ui/product` option components. Moved verbatim from
4
+ * `customization-fields.tsx` / `modifier-group-selector.tsx` so that core
5
+ * never depends on `ui/` — the UI components re-export these for
6
+ * backward compatibility.
7
+ */
8
+
9
+ import type { ModifierGroup, ModifierSelection, ProductCustomizationField } from 'brainerce';
10
+
11
+ export type CustomizationValues = Record<string, unknown>;
12
+
13
+ export function validateCustomization(
14
+ fields: ProductCustomizationField[],
15
+ values: CustomizationValues
16
+ ): Record<string, string> {
17
+ const errors: Record<string, string> = {};
18
+ for (const field of fields) {
19
+ const value = values[field.key];
20
+ const empty =
21
+ value === undefined ||
22
+ value === null ||
23
+ value === '' ||
24
+ (Array.isArray(value) && value.length === 0);
25
+
26
+ if (field.required && empty) {
27
+ errors[field.key] = 'required';
28
+ continue;
29
+ }
30
+ if (empty) continue;
31
+
32
+ if (field.type === 'SELECT') {
33
+ const allowed = (field.enumValues ?? []).map((o) => o.value);
34
+ if (typeof value !== 'string' || !allowed.includes(value)) {
35
+ errors[field.key] = 'invalidOption';
36
+ }
37
+ }
38
+ if (field.type === 'MULTI_SELECT') {
39
+ const allowed = (field.enumValues ?? []).map((o) => o.value);
40
+ if (
41
+ !Array.isArray(value) ||
42
+ !(value as unknown[]).every((v) => typeof v === 'string' && allowed.includes(v as string))
43
+ ) {
44
+ errors[field.key] = 'invalidOption';
45
+ }
46
+ }
47
+ }
48
+ return errors;
49
+ }
50
+
51
+ /**
52
+ * Lightweight client-side validation that mirrors the server's checks. Use
53
+ * this to gate the add-to-cart button — but always treat the server's 400
54
+ * (`MODIFIER_VALIDATION_FAILED`) as the authoritative answer.
55
+ */
56
+ export function validateSelections(
57
+ groups: ModifierGroup[],
58
+ selections: Record<string, string[]>
59
+ ): string | null {
60
+ for (const group of groups) {
61
+ if (group.max === 0) continue; // disabled-for-variant — skip
62
+ const picks = selections[group.id] ?? [];
63
+ if (group.required && picks.length === 0) {
64
+ return `${group.name} is required`;
65
+ }
66
+ if (picks.length < group.min) {
67
+ return `Pick at least ${group.min} from ${group.name}`;
68
+ }
69
+ if (group.max != null && picks.length > group.max) {
70
+ return `Pick at most ${group.max} from ${group.name}`;
71
+ }
72
+ if (group.selectionType === 'SINGLE' && picks.length > 1) {
73
+ return `Only one option allowed in ${group.name}`;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Adapter: convert the local Record<groupId, modifierIds> shape used by the
81
+ * selector component into the wire-format `ModifierSelection[]` array the SDK
82
+ * expects on `addToCart` / `updateCartItem`.
83
+ */
84
+ export function toModifierSelections(
85
+ groups: ModifierGroup[],
86
+ selections: Record<string, string[]>
87
+ ): ModifierSelection[] {
88
+ const out: ModifierSelection[] = [];
89
+ for (const group of groups) {
90
+ if (group.max === 0) continue;
91
+ const picks = selections[group.id] ?? [];
92
+ if (picks.length === 0) continue;
93
+ out.push({ modifierGroupId: group.id, modifierIds: picks });
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * Build the initial selection map from the group's defaults — call once on
100
+ * first render for each group. Honors `defaultModifierIds` first (per-attach
101
+ * defaults), falling back to per-modifier `isDefault` flags. Sold-out
102
+ * modifiers are filtered out so the user doesn't start with an invalid pick.
103
+ */
104
+ export function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
105
+ const out: Record<string, string[]> = {};
106
+ for (const group of groups) {
107
+ if (group.max === 0) continue;
108
+ const fromDefaults = group.defaultModifierIds ?? [];
109
+ const fromIsDefault = group.modifiers
110
+ .filter((m) => m.isDefault && m.available)
111
+ .map((m) => m.id);
112
+ const merged =
113
+ fromDefaults.length > 0
114
+ ? fromDefaults.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
115
+ : fromIsDefault;
116
+ // Cap by max for SINGLE groups — defaults can't violate selection rules.
117
+ const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
118
+ if (capped.length > 0) out[group.id] = capped;
119
+ }
120
+ return out;
121
+ }
@@ -1,20 +1,20 @@
1
- import type { PublicStoreInfo } from '@/core/lib/store-info';
2
-
3
- /**
4
- * Server-side twin of `useCurrency()` — call from Server Components,
5
- * `generateMetadata`, route handlers, and anywhere outside React render
6
- * (where the client `useStoreInfo()` hook isn't available).
7
- *
8
- * Keeps the fallback chain in one place across both client and server.
9
- *
10
- * @param storeInfo result of `fetchStoreInfo()` — may be null if the SSR
11
- * fetch failed transiently
12
- * @param override pass when the caller already has a specific currency
13
- * (e.g. order-level currency that should win over store)
14
- */
15
- export function resolveCurrency(
16
- storeInfo: PublicStoreInfo | null | undefined,
17
- override?: string | null
18
- ): string {
19
- return override || storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
20
- }
1
+ import type { PublicStoreInfo } from '@/core/lib/store-info';
2
+
3
+ /**
4
+ * Server-side twin of `useCurrency()` — call from Server Components,
5
+ * `generateMetadata`, route handlers, and anywhere outside React render
6
+ * (where the client `useStoreInfo()` hook isn't available).
7
+ *
8
+ * Keeps the fallback chain in one place across both client and server.
9
+ *
10
+ * @param storeInfo result of `fetchStoreInfo()` — may be null if the SSR
11
+ * fetch failed transiently
12
+ * @param override pass when the caller already has a specific currency
13
+ * (e.g. order-level currency that should win over store)
14
+ */
15
+ export function resolveCurrency(
16
+ storeInfo: PublicStoreInfo | null | undefined,
17
+ override?: string | null
18
+ ): string {
19
+ return override || storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
20
+ }
@@ -1,38 +1,38 @@
1
- // Re-export the platform-maintained payment URL allowlist from the SDK.
2
- //
3
- // Why a re-export and not a direct import everywhere: keeping a stable
4
- // `@/core/lib/safe-redirect` import path means future SDK API changes (e.g. an
5
- // `extraHosts` rename) only need editing here, and merchants don't have to
6
- // touch every component that does payment redirects. It also leaves a clear
7
- // place to plug in store-specific extras (custom self-hosted PSP) without
8
- // scattering option objects across components.
9
- //
10
- // The allowlist itself now lives in the `brainerce` package — running
11
- // `npm update brainerce` picks up new payment providers and platform
12
- // embed domains automatically.
13
-
14
- import {
15
- isAllowedPaymentUrl as sdkIsAllowedPaymentUrl,
16
- safePaymentRedirect as sdkSafePaymentRedirect,
17
- } from 'brainerce';
18
-
19
- // Dev-only: allow http://localhost|127.0.0.1 so the local storefront can
20
- // iframe the local backend's embed proxy. Inlined to a literal at build time
21
- // by Next.js, so the allow path is stripped from production bundles.
22
- const allowLocalhost = process.env.NODE_ENV !== 'production';
23
-
24
- export function isAllowedPaymentUrl(url: string): boolean {
25
- return sdkIsAllowedPaymentUrl(url, { allowLocalhost });
26
- }
27
-
28
- export function safePaymentRedirect(url: string): void {
29
- sdkSafePaymentRedirect(url, { allowLocalhost });
30
- }
31
-
32
- // CUID format used by Prisma for Checkout.id — c + 24 lowercase alphanumeric chars.
33
- // Allow a small range to tolerate cuid2 (slightly different length).
34
- const CHECKOUT_ID_RE = /^c[a-z0-9]{20,30}$/;
35
-
36
- export function isValidCheckoutId(id: unknown): id is string {
37
- return typeof id === 'string' && CHECKOUT_ID_RE.test(id);
38
- }
1
+ // Re-export the platform-maintained payment URL allowlist from the SDK.
2
+ //
3
+ // Why a re-export and not a direct import everywhere: keeping a stable
4
+ // `@/core/lib/safe-redirect` import path means future SDK API changes (e.g. an
5
+ // `extraHosts` rename) only need editing here, and merchants don't have to
6
+ // touch every component that does payment redirects. It also leaves a clear
7
+ // place to plug in store-specific extras (custom self-hosted PSP) without
8
+ // scattering option objects across components.
9
+ //
10
+ // The allowlist itself now lives in the `brainerce` package — running
11
+ // `npm update brainerce` picks up new payment providers and platform
12
+ // embed domains automatically.
13
+
14
+ import {
15
+ isAllowedPaymentUrl as sdkIsAllowedPaymentUrl,
16
+ safePaymentRedirect as sdkSafePaymentRedirect,
17
+ } from 'brainerce';
18
+
19
+ // Dev-only: allow http://localhost|127.0.0.1 so the local storefront can
20
+ // iframe the local backend's embed proxy. Inlined to a literal at build time
21
+ // by Next.js, so the allow path is stripped from production bundles.
22
+ const allowLocalhost = process.env.NODE_ENV !== 'production';
23
+
24
+ export function isAllowedPaymentUrl(url: string): boolean {
25
+ return sdkIsAllowedPaymentUrl(url, { allowLocalhost });
26
+ }
27
+
28
+ export function safePaymentRedirect(url: string): void {
29
+ sdkSafePaymentRedirect(url, { allowLocalhost });
30
+ }
31
+
32
+ // CUID format used by Prisma for Checkout.id — c + 24 lowercase alphanumeric chars.
33
+ // Allow a small range to tolerate cuid2 (slightly different length).
34
+ const CHECKOUT_ID_RE = /^c[a-z0-9]{20,30}$/;
35
+
36
+ export function isValidCheckoutId(id: unknown): id is string {
37
+ return typeof id === 'string' && CHECKOUT_ID_RE.test(id);
38
+ }
@@ -1,46 +1,46 @@
1
- // SEO helpers for meta tags and JSON-LD. Strips HTML and decodes common
2
- // entities so seller-authored rich-text descriptions don't leak markup into
3
- // <meta name="description">, og:description, twitter:description, or
4
- // schema.org Product.description.
5
-
6
- const NAMED_ENTITIES: Record<string, string> = {
7
- nbsp: ' ',
8
- amp: '&',
9
- lt: '<',
10
- gt: '>',
11
- quot: '"',
12
- apos: "'",
13
- };
14
-
15
- function decodeEntities(text: string): string {
16
- return text
17
- .replace(/&(nbsp|amp|lt|gt|quot|apos);/g, (_, name: string) => NAMED_ENTITIES[name] ?? '')
18
- .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
19
- .replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(parseInt(code, 16)));
20
- }
21
-
22
- // Strip HTML tags, decode entities, collapse whitespace. Idempotent on plain text.
23
- export function stripHtmlForSeo(input: string | null | undefined): string {
24
- if (!input) return '';
25
- return decodeEntities(input.replace(/<[^>]*>/g, ' '))
26
- .replace(/\s+/g, ' ')
27
- .trim();
28
- }
29
-
30
- // Build a meta description from raw HTML/text.
31
- // Truncates at the nearest word boundary so we never cut mid-word, and
32
- // appends a Unicode ellipsis. Returns '' for null/empty input so callers
33
- // can fall back cleanly.
34
- export function buildMetaDescription(input: string | null | undefined, maxLength = 160): string {
35
- const stripped = stripHtmlForSeo(input);
36
- if (!stripped) return '';
37
- if (stripped.length <= maxLength) return stripped;
38
-
39
- const room = maxLength - 1; // leave room for ellipsis
40
- const cut = stripped.slice(0, room);
41
- const lastSpace = cut.lastIndexOf(' ');
42
- // Only break on word boundary when it's reasonably close to the cap — otherwise
43
- // a single very long word would shrink the description to almost nothing.
44
- const safeCut = lastSpace > room * 0.7 ? cut.slice(0, lastSpace) : cut;
45
- return `${safeCut.trim()}…`;
46
- }
1
+ // SEO helpers for meta tags and JSON-LD. Strips HTML and decodes common
2
+ // entities so seller-authored rich-text descriptions don't leak markup into
3
+ // <meta name="description">, og:description, twitter:description, or
4
+ // schema.org Product.description.
5
+
6
+ const NAMED_ENTITIES: Record<string, string> = {
7
+ nbsp: ' ',
8
+ amp: '&',
9
+ lt: '<',
10
+ gt: '>',
11
+ quot: '"',
12
+ apos: "'",
13
+ };
14
+
15
+ function decodeEntities(text: string): string {
16
+ return text
17
+ .replace(/&(nbsp|amp|lt|gt|quot|apos);/g, (_, name: string) => NAMED_ENTITIES[name] ?? '')
18
+ .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
19
+ .replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(parseInt(code, 16)));
20
+ }
21
+
22
+ // Strip HTML tags, decode entities, collapse whitespace. Idempotent on plain text.
23
+ export function stripHtmlForSeo(input: string | null | undefined): string {
24
+ if (!input) return '';
25
+ return decodeEntities(input.replace(/<[^>]*>/g, ' '))
26
+ .replace(/\s+/g, ' ')
27
+ .trim();
28
+ }
29
+
30
+ // Build a meta description from raw HTML/text.
31
+ // Truncates at the nearest word boundary so we never cut mid-word, and
32
+ // appends a Unicode ellipsis. Returns '' for null/empty input so callers
33
+ // can fall back cleanly.
34
+ export function buildMetaDescription(input: string | null | undefined, maxLength = 160): string {
35
+ const stripped = stripHtmlForSeo(input);
36
+ if (!stripped) return '';
37
+ if (stripped.length <= maxLength) return stripped;
38
+
39
+ const room = maxLength - 1; // leave room for ellipsis
40
+ const cut = stripped.slice(0, room);
41
+ const lastSpace = cut.lastIndexOf(' ');
42
+ // Only break on word boundary when it's reasonably close to the cap — otherwise
43
+ // a single very long word would shrink the description to almost nothing.
44
+ const safeCut = lastSpace > room * 0.7 ? cut.slice(0, lastSpace) : cut;
45
+ return `${safeCut.trim()}…`;
46
+ }
@@ -25,6 +25,21 @@ export interface PublicStoreInfo {
25
25
  i18n?: StoreInfo['i18n'];
26
26
  /** Real flat-rate/free shipping zones — feeds Product JSON-LD `shippingDetails`. Public by design (merchants display shipping rates openly). */
27
27
  shipping?: StoreInfo['shipping'];
28
+ /**
29
+ * Marketing tag ids (GA4 / GTM / Meta / TikTok) resolved from the apps the
30
+ * merchant connected in the Brainerce dashboard. Public by design — these
31
+ * ids appear in the page source of every storefront that runs a tag, and the
32
+ * backend format-validates them before serving. Consumed by
33
+ * `<TrackingBootstrap>`; nothing else should read them.
34
+ */
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'];
28
43
  }
29
44
 
30
45
  /**
@@ -47,5 +62,7 @@ export function pickPublicStoreInfo(raw: StoreInfo): PublicStoreInfo {
47
62
  upsell: raw.upsell,
48
63
  i18n: raw.i18n,
49
64
  shipping: raw.shipping,
65
+ tracking: raw.tracking,
66
+ seo: raw.seo,
50
67
  };
51
68
  }
@@ -0,0 +1,171 @@
1
+ import type { Cart, Order, Product, ProductVariant, TrackingEventItem } from 'brainerce';
2
+ import { getCartTotals } from 'brainerce';
3
+
4
+ import { getClient } from './brainerce';
5
+
6
+ /**
7
+ * E-commerce event reporting for the merchant's marketing tags.
8
+ *
9
+ * `<TrackingBootstrap>` loads whichever tags the merchant connected; these
10
+ * helpers describe what the shopper did. Both halves are needed: a Google Tag
11
+ * Manager container with no events is an empty container, and Meta cannot
12
+ * optimize a campaign it never sees a `Purchase` for.
13
+ *
14
+ * Every function is a no-op when no tag is loaded, so calling them is always
15
+ * safe and never needs a feature check at the call site.
16
+ *
17
+ * ## Item ids
18
+ *
19
+ * `itemId` is always the **SKU**. That is the id Brainerce publishes to the
20
+ * Google Merchant Center and Meta catalog feeds, so it is the only id the ad
21
+ * platforms can match a pixel event against. Sending a product or variant id
22
+ * instead breaks attribution and dynamic remarketing silently — the events
23
+ * arrive, the platform reports an id its catalog has never seen, and the
24
+ * remarketing audience never builds.
25
+ */
26
+
27
+ /** Parse a Brainerce decimal string (`"29.99"`) into a number. */
28
+ function toNumber(value: string | number | null | undefined): number | undefined {
29
+ if (value === null || value === undefined) return undefined;
30
+ const parsed = typeof value === 'number' ? value : parseFloat(value);
31
+ return Number.isFinite(parsed) ? parsed : undefined;
32
+ }
33
+
34
+ /**
35
+ * Resolve the SKU for a product/variant pair, falling back to the variant then
36
+ * product id only when the catalog has no SKU at all — a feed-less product
37
+ * can't be attributed either way, and dropping the event entirely would hide
38
+ * real shopper behaviour from GA4 too.
39
+ */
40
+ function resolveItemId(product: Product, variant?: ProductVariant | null): string {
41
+ return variant?.sku || product.sku || variant?.id || product.id;
42
+ }
43
+
44
+ /** The shopper opened a product detail page. */
45
+ export function trackProductView(
46
+ product: Product,
47
+ variant: ProductVariant | null | undefined,
48
+ price: number | undefined,
49
+ currency: string | undefined
50
+ ): void {
51
+ getClient().trackMarketingEvent('view_item', {
52
+ currency,
53
+ value: price,
54
+ items: [
55
+ {
56
+ itemId: resolveItemId(product, variant),
57
+ itemName: product.name,
58
+ price,
59
+ quantity: 1,
60
+ ...(variant?.name ? { itemVariant: variant.name } : {}),
61
+ },
62
+ ],
63
+ });
64
+ }
65
+
66
+ /** The shopper added a line to the cart. */
67
+ export function trackAddToCart(
68
+ product: Product,
69
+ variant: ProductVariant | null | undefined,
70
+ quantity: number,
71
+ price: number | undefined,
72
+ currency: string | undefined
73
+ ): void {
74
+ getClient().trackMarketingEvent('add_to_cart', {
75
+ currency,
76
+ value: price !== undefined ? price * quantity : undefined,
77
+ items: [
78
+ {
79
+ itemId: resolveItemId(product, variant),
80
+ itemName: product.name,
81
+ price,
82
+ quantity,
83
+ ...(variant?.name ? { itemVariant: variant.name } : {}),
84
+ },
85
+ ],
86
+ });
87
+ }
88
+
89
+ /** Map cart lines onto tracking items. */
90
+ function cartItems(cart: Cart): TrackingEventItem[] {
91
+ return (cart.items ?? []).map((item) => ({
92
+ // The cart line carries product + variant snapshots; prefer the variant's
93
+ // SKU for the same catalog-parity reason as `resolveItemId`.
94
+ itemId: item.variant?.sku || item.product?.sku || item.variantId || item.productId,
95
+ itemName: item.product?.name,
96
+ price: toNumber(item.currentUnitPrice ?? item.unitPrice),
97
+ quantity: item.quantity,
98
+ ...(item.variant?.name ? { itemVariant: item.variant.name } : {}),
99
+ }));
100
+ }
101
+
102
+ /**
103
+ * The shopper landed on the checkout page.
104
+ *
105
+ * Shipping is not known at this point (no address yet), so the value is the
106
+ * cart total without it — which is what GA4's `begin_checkout` expects.
107
+ */
108
+ export function trackBeginCheckout(cart: Cart, currency: string | undefined): void {
109
+ getClient().trackMarketingEvent('begin_checkout', {
110
+ currency,
111
+ value: getCartTotals(cart).total,
112
+ items: cartItems(cart),
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Key under which a reported order id is remembered for this tab.
118
+ *
119
+ * Prefixed per order rather than a single "last order" slot so a shopper who
120
+ * places two orders in one session gets both reported exactly once.
121
+ */
122
+ const PURCHASE_KEY_PREFIX = 'brainerce_purchase_tracked:';
123
+
124
+ /**
125
+ * The order is paid — report the conversion, at most once per order.
126
+ *
127
+ * Two independent guards, because double-counted revenue is the failure that
128
+ * quietly ruins a merchant's ROAS reporting:
129
+ *
130
+ * 1. `sessionStorage` stops a page refresh or a back-button return from
131
+ * re-firing the event at all.
132
+ * 2. The order id travels as GA4's `transaction_id`, Meta's `eventID` and
133
+ * TikTok's `event_id`, so even a send that slips past guard 1 (new tab,
134
+ * cleared storage) de-duplicates on the vendor's side.
135
+ *
136
+ * Note the event fires on the confirmation page, which a shopper can always
137
+ * fail to reach — closing the tab after paying, or an iframe provider that
138
+ * lands them on `/payment-complete`. Browser-side conversion tracking is
139
+ * lossy by nature; Brainerce's server-side GA4 purchase event (sent from the
140
+ * backend when the order is paid) is what covers that gap for Google.
141
+ */
142
+ export function trackPurchase(order: Order): void {
143
+ if (typeof window === 'undefined' || !order?.id) return;
144
+
145
+ const key = `${PURCHASE_KEY_PREFIX}${order.id}`;
146
+ try {
147
+ if (window.sessionStorage.getItem(key)) return;
148
+ window.sessionStorage.setItem(key, '1');
149
+ } catch {
150
+ // Private mode / storage disabled — fall through and rely on the
151
+ // vendor-side transaction id de-dupe rather than skipping the conversion.
152
+ }
153
+
154
+ getClient().trackMarketingEvent('purchase', {
155
+ transactionId: order.id,
156
+ currency: order.currency,
157
+ value: toNumber(order.total ?? order.totalAmount),
158
+ shipping: toNumber(order.shippingAmount),
159
+ // In inclusive (VAT) pricing `taxAmount` is "0" and the real VAT lives in
160
+ // the frozen breakdown — reporting the 0 would understate tax on every
161
+ // order for every VAT-region merchant.
162
+ tax: toNumber(order.taxBreakdown?.totalTax) ?? toNumber(order.taxAmount),
163
+ coupon: order.couponCode ?? undefined,
164
+ items: (order.items ?? []).map((item) => ({
165
+ itemId: item.sku || item.variantId || item.productId,
166
+ itemName: item.name,
167
+ price: toNumber(item.price),
168
+ quantity: item.quantity,
169
+ })),
170
+ });
171
+ }