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.
- package/LICENSE +21 -21
- package/README.md +71 -71
- package/dist/index.js +10 -2
- package/messages/en.json +502 -502
- package/messages/he.json +502 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -60
- package/templates/nextjs/base/.env.local.ejs +24 -24
- package/templates/nextjs/base/.eslintrc.json +57 -57
- package/templates/nextjs/base/.mcp.json +8 -8
- package/templates/nextjs/base/AI-GUIDE.md +174 -174
- package/templates/nextjs/base/TRANSLATIONS.md +200 -200
- package/templates/nextjs/base/package.json.ejs +52 -52
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +262 -262
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
- package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +106 -106
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
- package/templates/nextjs/base/src/app/globals.css +36 -36
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +10 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +44 -44
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
- package/templates/nextjs/base/src/app/page.tsx +64 -64
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +175 -175
- package/templates/nextjs/base/src/app/products/page.tsx +19 -19
- package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -564
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +179 -179
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
- package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
- package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
- package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
- package/templates/nextjs/base/src/core/lib/store-info.ts +9 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
- package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
- package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
- package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -20
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
- package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
- package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
- package/templates/nextjs/base/tailwind.config.ts +69 -69
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +10 -0
- package/templates/nextjs/designs/atelier/globals.css +402 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
- package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -27
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -19
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
- package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
- 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
|
+
}
|