create-brainerce-store 1.61.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 +10 -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 -564
  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,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,14 @@ 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'];
28
36
  }
29
37
 
30
38
  /**
@@ -47,5 +55,6 @@ export function pickPublicStoreInfo(raw: StoreInfo): PublicStoreInfo {
47
55
  upsell: raw.upsell,
48
56
  i18n: raw.i18n,
49
57
  shipping: raw.shipping,
58
+ tracking: raw.tracking,
50
59
  };
51
60
  }
@@ -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
+ }