@osimart/create-storefront 0.0.0-stage → 1.0.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/README.md +80 -2
- package/THEME_DEV_API.md +80 -0
- package/app/app.vue +154 -0
- package/app/assets/css/osi-controls.css +824 -0
- package/app/components/SectionRenderer.vue +46 -0
- package/app/components/builder/BuilderOverlay.vue +582 -0
- package/app/components/checkout/ExtraFees.vue +329 -0
- package/app/components/checkout/PaymentHandoff.vue +388 -0
- package/app/components/common/Banner.vue +35 -0
- package/app/components/common/ConfirmDeleteModal.vue +117 -0
- package/app/components/common/DropdownSelect.vue +432 -0
- package/app/components/common/Loader.vue +32 -0
- package/app/components/common/OsiField.vue +218 -0
- package/app/components/common/PhoneNumInput.vue +434 -0
- package/app/composables/UseBodyScrollLock.js +122 -0
- package/app/composables/UseThemeGroup.js +575 -0
- package/app/composables/useAddToCart.js +87 -0
- package/app/composables/useAnalytics.js +15 -0
- package/app/composables/useAnnouncements.js +29 -0
- package/app/composables/useApiErrorToast.js +104 -0
- package/app/composables/useAuth.js +99 -0
- package/app/composables/useBanners.js +25 -0
- package/app/composables/useBuilderPreview.js +320 -0
- package/app/composables/useButtonStyle.js +107 -0
- package/app/composables/useCarousel.js +160 -0
- package/app/composables/useCart.js +109 -0
- package/app/composables/useCheckout.js +76 -0
- package/app/composables/useClassificationItems.js +269 -0
- package/app/composables/useClassificationPage.js +545 -0
- package/app/composables/useClassificationUrl.js +63 -0
- package/app/composables/useColorScheme.js +44 -0
- package/app/composables/useContactForm.js +53 -0
- package/app/composables/useContentTree.js +770 -0
- package/app/composables/useDragScroll.js +73 -0
- package/app/composables/useFooterSocials.js +107 -0
- package/app/composables/useInputStyle.js +45 -0
- package/app/composables/useLayoutShell.js +114 -0
- package/app/composables/useLazyVideo.js +36 -0
- package/app/composables/useMediaTypes.js +66 -0
- package/app/composables/useMediaUrl.js +71 -0
- package/app/composables/useMenu.js +100 -0
- package/app/composables/useNavStyle.js +219 -0
- package/app/composables/usePlaceholders.js +294 -0
- package/app/composables/usePolicies.js +26 -0
- package/app/composables/useProductCardMedia.js +135 -0
- package/app/composables/useProductCardStyle.js +132 -0
- package/app/composables/useProductDetail.js +982 -0
- package/app/composables/useProductGallery.js +166 -0
- package/app/composables/useProductItems.js +270 -0
- package/app/composables/useProductUrl.js +60 -0
- package/app/composables/useProductVariants.js +210 -0
- package/app/composables/useQuickShop.js +115 -0
- package/app/composables/useRelatedProducts.js +186 -0
- package/app/composables/useRenderContext.js +45 -0
- package/app/composables/useScopedCss.js +106 -0
- package/app/composables/useSearch.js +35 -0
- package/app/composables/useSectionData.js +462 -0
- package/app/composables/useSectionShell.js +113 -0
- package/app/composables/useSidebar.js +69 -0
- package/app/composables/useStoreData.js +57 -0
- package/app/composables/useStoreInit.js +52 -0
- package/app/composables/useStorefront.js +239 -0
- package/app/composables/useT.js +21 -0
- package/app/composables/useTextStyles.js +84 -0
- package/app/composables/useThemeControls.js +34 -0
- package/app/composables/useThemeSeo.js +91 -0
- package/app/composables/useThemeTokens.js +171 -0
- package/app/composables/useWishlist.js +66 -0
- package/app/logic/components/AnnouncementBar.js +118 -0
- package/app/logic/components/CardGrid.js +125 -0
- package/app/logic/components/CardList.js +98 -0
- package/app/logic/components/CartDrawer.js +273 -0
- package/app/logic/components/CartItem.js +31 -0
- package/app/logic/components/ClassificationBento.js +6 -0
- package/app/logic/components/ClassificationCard.js +6 -0
- package/app/logic/components/ClassificationCarousel.js +115 -0
- package/app/logic/components/ClassificationEditorial.js +41 -0
- package/app/logic/components/ClassificationFeatured.js +57 -0
- package/app/logic/components/ClassificationGrid.js +6 -0
- package/app/logic/components/ClassificationNav.js +61 -0
- package/app/logic/components/ClassificationPart.js +72 -0
- package/app/logic/components/FooterBlock.js +91 -0
- package/app/logic/components/FooterSocials.js +60 -0
- package/app/logic/components/HeaderDropdownList.js +29 -0
- package/app/logic/components/HeaderIcon.js +68 -0
- package/app/logic/components/HeaderLogo.js +39 -0
- package/app/logic/components/HeaderNav.js +268 -0
- package/app/logic/components/MobileMenu.js +280 -0
- package/app/logic/components/ProductBuyButtons.js +66 -0
- package/app/logic/components/ProductCard.js +160 -0
- package/app/logic/components/ProductFilters.js +37 -0
- package/app/logic/components/ProductGallery.js +19 -0
- package/app/logic/components/ProductInfoBlock.js +411 -0
- package/app/logic/components/ProductLightbox.js +12 -0
- package/app/logic/components/ProductQuantity.js +31 -0
- package/app/logic/components/ProductVariantPicker.js +125 -0
- package/app/logic/components/QuickBuy.js +194 -0
- package/app/logic/components/SearchOverlay.js +41 -0
- package/app/logic/components/SidebarMenuItem.js +39 -0
- package/app/logic/components/SiteFooter.js +476 -0
- package/app/logic/components/SiteHeader.js +266 -0
- package/app/logic/components/WhatsAppButton.js +75 -0
- package/app/logic/layouts/default.js +66 -0
- package/app/logic/sections/AnnouncementBarSection.js +6 -0
- package/app/logic/sections/BlockCarousel.js +103 -0
- package/app/logic/sections/ClassificationListSection.js +40 -0
- package/app/logic/sections/ContactFormSection.js +294 -0
- package/app/logic/sections/ContactInfoSection.js +419 -0
- package/app/logic/sections/ContentNode.js +189 -0
- package/app/logic/sections/CountdownSection.js +254 -0
- package/app/logic/sections/CustomHtmlSection.js +51 -0
- package/app/logic/sections/DividerSection.js +31 -0
- package/app/logic/sections/FaqSection.js +53 -0
- package/app/logic/sections/FeatureListSection.js +6 -0
- package/app/logic/sections/FeaturedProductSection.js +40 -0
- package/app/logic/sections/FooterSection.js +16 -0
- package/app/logic/sections/FooterUtilitiesSection.js +24 -0
- package/app/logic/sections/GallerySection.js +232 -0
- package/app/logic/sections/HeaderSection.js +24 -0
- package/app/logic/sections/HeroSection.js +24 -0
- package/app/logic/sections/LogoListSection.js +6 -0
- package/app/logic/sections/MainClassificationSection.js +473 -0
- package/app/logic/sections/MainProductSection.js +211 -0
- package/app/logic/sections/MainSearchSection.js +6 -0
- package/app/logic/sections/MapSection.js +92 -0
- package/app/logic/sections/MediaContentSection.js +38 -0
- package/app/logic/sections/MediaWithTextSection.js +238 -0
- package/app/logic/sections/NewsletterSection.js +126 -0
- package/app/logic/sections/ProductHighlightSection.js +201 -0
- package/app/logic/sections/ProductListSection.js +45 -0
- package/app/logic/sections/ProductRecommendationsSection.js +113 -0
- package/app/logic/sections/RichTextSection.js +6 -0
- package/app/logic/sections/ScrollingTextSection.js +69 -0
- package/app/logic/sections/SearchHeaderSection.js +22 -0
- package/app/logic/sections/SlideshowSection.js +118 -0
- package/app/logic/sections/SpacerSection.js +37 -0
- package/app/logic/sections/SplitShowcaseSection.js +6 -0
- package/app/logic/sections/TestimonialListSection.js +6 -0
- package/app/logic/sections/VideoSection.js +235 -0
- package/app/pages/[category]/[product].vue +196 -0
- package/app/pages/__builder-preview.vue +38 -0
- package/app/pages/categories/[slug].vue +208 -0
- package/app/pages/checkout.vue +1972 -0
- package/app/pages/forgot-password.vue +115 -0
- package/app/pages/index.vue +64 -0
- package/app/pages/login.vue +585 -0
- package/app/pages/order-detail.vue +442 -0
- package/app/pages/pages/[handle].vue +58 -0
- package/app/pages/privacy-policy.vue +46 -0
- package/app/pages/resetpassword.vue +138 -0
- package/app/pages/resetverificationcode.vue +163 -0
- package/app/pages/result.vue +203 -0
- package/app/pages/return-policy.vue +50 -0
- package/app/pages/search.vue +100 -0
- package/app/pages/shipping-policy.vue +46 -0
- package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
- package/app/pages/user-profile/components/Whishlist.vue +137 -0
- package/app/pages/user-profile/components/left-bar.vue +233 -0
- package/app/pages/user-profile/components/orders.vue +162 -0
- package/app/pages/user-profile/components/profile-details.vue +566 -0
- package/app/pages/user-profile/components/settings.vue +194 -0
- package/app/pages/user-profile/index.vue +252 -0
- package/app/pages/verificationcode.vue +188 -0
- package/app/plugins/analytics.client.js +99 -0
- package/app/plugins/geo.ts +24 -0
- package/app/plugins/reveal.client.js +53 -0
- package/app/plugins/toast.js +10 -0
- package/app/plugins/vue-query.ts +5 -0
- package/app/services/ApiServices.js +74 -0
- package/app/services/AuthBus.js +162 -0
- package/app/services/Authentication/AuthenticationService.js +133 -0
- package/app/services/Authentication/TokenService.js +62 -0
- package/app/services/CartServices.js +91 -0
- package/app/services/CartToken.js +26 -0
- package/app/services/CategoryServices.js +54 -0
- package/app/services/CheckoutServices.js +113 -0
- package/app/services/HomeServices.js +133 -0
- package/app/services/NewsletterServices.js +29 -0
- package/app/services/PaymentServices.js +71 -0
- package/app/services/PoliciesServices.js +16 -0
- package/app/services/ProductServices.js +79 -0
- package/app/services/SearchServices.js +18 -0
- package/app/services/UserServices.js +105 -0
- package/app/services/UtilsServices.js +46 -0
- package/app/services/WishlistServices.js +51 -0
- package/app/services/storefront.service.js +57 -0
- package/app/stores/userStore.js +13 -0
- package/app/theme/categoryTree.js +68 -0
- package/app/theme/controls.js +88 -0
- package/app/theme/registry.js +56 -0
- package/app/utils/analytics.js +864 -0
- package/app/utils/device.js +50 -0
- package/app/utils/eventBus.js +24 -0
- package/app/utils/icon-presets.js +24 -0
- package/app/utils/productOffers.js +118 -0
- package/app/utils/requestCache.js +36 -0
- package/app/utils/scrollToError.js +59 -0
- package/app/utils/toPlainError.js +30 -0
- package/bin/osimart-theme.mjs +259 -0
- package/nuxt/osi-sections.ts +56 -0
- package/nuxt.config.ts +60 -0
- package/package.json +43 -3
- package/public/css/input.css +29 -0
- package/public/css/output.css +2716 -0
- package/public/favicon-32x32.png +0 -0
- package/public/robots.txt +2 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics — Google Analytics 4 (gtag.js) and the Meta Pixel (fbq).
|
|
3
|
+
*
|
|
4
|
+
* One module, one vocabulary. Every storefront event goes through the methods
|
|
5
|
+
* on `analytics` below, and each method maps the SAME business fact to BOTH
|
|
6
|
+
* vendors, so the two never drift apart:
|
|
7
|
+
*
|
|
8
|
+
* method Google Analytics 4 Meta Pixel
|
|
9
|
+
* ─────────────── ───────────────────── ──────────────────────
|
|
10
|
+
* pageView page_view PageView
|
|
11
|
+
* viewItem view_item ViewContent
|
|
12
|
+
* viewItemList view_item_list ViewCategory (custom, category pages)
|
|
13
|
+
* selectItem select_item —
|
|
14
|
+
* addToCart add_to_cart AddToCart
|
|
15
|
+
* removeFromCart remove_from_cart —
|
|
16
|
+
* viewCart view_cart —
|
|
17
|
+
* beginCheckout begin_checkout InitiateCheckout
|
|
18
|
+
* addShippingInfo add_shipping_info —
|
|
19
|
+
* addPaymentInfo add_payment_info AddPaymentInfo
|
|
20
|
+
* purchase purchase Purchase
|
|
21
|
+
* search search Search
|
|
22
|
+
* signUp sign_up CompleteRegistration
|
|
23
|
+
* login login —
|
|
24
|
+
* addToWishlist add_to_wishlist AddToWishlist
|
|
25
|
+
* lead generate_lead Lead
|
|
26
|
+
* contact generate_lead Contact
|
|
27
|
+
*
|
|
28
|
+
* Where the IDs come from: the bootstrap response's `store` object —
|
|
29
|
+
* `google_analytics_id` and `meta_pixel_id`. plugins/analytics.client.js reads
|
|
30
|
+
* them and calls start(). A store that has neither gets no scripts and every
|
|
31
|
+
* method below is a silent no-op, so call sites never have to guard.
|
|
32
|
+
*
|
|
33
|
+
* Rules this file keeps:
|
|
34
|
+
*
|
|
35
|
+
* * Client only. Nothing here runs during SSR, and the module state below is
|
|
36
|
+
* therefore per-browser (one shopper, one tenant) — never shared between
|
|
37
|
+
* tenants the way a server-side module variable would be.
|
|
38
|
+
* * IDs are validated before they reach a <script src> or an inline snippet.
|
|
39
|
+
* They come from a merchant-editable field; a malformed one must not become
|
|
40
|
+
* injected markup.
|
|
41
|
+
* * Tracking can never break the storefront. Every vendor call is wrapped, and
|
|
42
|
+
* nothing here throws into the caller.
|
|
43
|
+
* * Purchases are reported once per order, however often the confirmation page
|
|
44
|
+
* is reloaded.
|
|
45
|
+
* * Meta events carry an `eventID`, so a server-side (Conversions API)
|
|
46
|
+
* integration added later can de-duplicate against the browser events.
|
|
47
|
+
*
|
|
48
|
+
* Items use the product id as `item_id` / Meta `content_ids` everywhere. If the
|
|
49
|
+
* store's Meta catalog is keyed by SKU instead, change contentId() — it is the
|
|
50
|
+
* only place that decides.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/* ------------------------------------------------------------------ config */
|
|
54
|
+
|
|
55
|
+
const GA_ID_RE = /^(G|GT|AW)-[A-Z0-9]{4,20}$/;
|
|
56
|
+
const PIXEL_ID_RE = /^\d{8,20}$/;
|
|
57
|
+
|
|
58
|
+
/** Query params that must never be sent to a vendor as part of a page URL. */
|
|
59
|
+
const SENSITIVE_PARAMS = ["reference", "token", "code", "email", "password", "access_token", "refresh_token"];
|
|
60
|
+
|
|
61
|
+
/** GA4 caps an event's payload; a long grid is cut to its first screenful. */
|
|
62
|
+
const MAX_LIST_ITEMS = 25;
|
|
63
|
+
|
|
64
|
+
const GTAG_SRC = "https://www.googletagmanager.com/gtag/js";
|
|
65
|
+
const FBEVENTS_SRC = "https://connect.facebook.net/en_US/fbevents.js";
|
|
66
|
+
|
|
67
|
+
/* ------------------------------------------------------------------- state */
|
|
68
|
+
|
|
69
|
+
const state = {
|
|
70
|
+
started: false,
|
|
71
|
+
gaIds: [],
|
|
72
|
+
pixelIds: [],
|
|
73
|
+
currency: "",
|
|
74
|
+
consent: true,
|
|
75
|
+
/** Last cart the server sent, keyed by line (variant) id. */
|
|
76
|
+
cart: {},
|
|
77
|
+
/** What we know about a line beyond the cart payload: brand, category… */
|
|
78
|
+
lineContext: new Map(),
|
|
79
|
+
/** Once-per-page guards (view_item, view_item_list). Cleared on navigation. */
|
|
80
|
+
seen: new Set(),
|
|
81
|
+
/** product id -> the list it was shown in, so a click knows where it came from. */
|
|
82
|
+
listOf: new Map(),
|
|
83
|
+
seenPath: "",
|
|
84
|
+
/** Purchases already reported, for when storage is unavailable. */
|
|
85
|
+
purchased: new Set(),
|
|
86
|
+
/** When the shopper last added something (the drawer then opens itself). */
|
|
87
|
+
lastAddAt: 0,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const isClient = () => typeof window !== "undefined";
|
|
91
|
+
const enabled = () => state.started && (state.gaIds.length > 0 || state.pixelIds.length > 0);
|
|
92
|
+
|
|
93
|
+
/* ----------------------------------------------------------------- helpers */
|
|
94
|
+
|
|
95
|
+
/** "G-AB12, G-CD34" / ["…"] -> validated, de-duplicated, upper-cased list. */
|
|
96
|
+
export function parseIds(raw, kind) {
|
|
97
|
+
const list = Array.isArray(raw) ? raw : String(raw ?? "").split(/[\s,;]+/);
|
|
98
|
+
const re = kind === "pixel" ? PIXEL_ID_RE : GA_ID_RE;
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const value of list) {
|
|
101
|
+
const id = String(value ?? "").trim();
|
|
102
|
+
const normal = kind === "pixel" ? id : id.toUpperCase();
|
|
103
|
+
if (re.test(normal) && !out.includes(normal)) out.push(normal);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A number out of a number or a price string ("12.50", "$12", 12). */
|
|
109
|
+
function num(value) {
|
|
110
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
111
|
+
const n = parseFloat(String(value ?? "").replace(/[^0-9.\-]/g, ""));
|
|
112
|
+
return Number.isFinite(n) ? n : 0;
|
|
113
|
+
}
|
|
114
|
+
const money = (value) => Math.round(num(value) * 100) / 100;
|
|
115
|
+
|
|
116
|
+
/** Lowest positive price in "10", 10 or "10 - 20". */
|
|
117
|
+
function lowestPrice(value) {
|
|
118
|
+
if (value === null || value === undefined || value === "") return 0;
|
|
119
|
+
const parts = String(value)
|
|
120
|
+
.split(/\s+-\s+|-(?=\d)/)
|
|
121
|
+
.map((part) => num(part))
|
|
122
|
+
.filter((n) => n > 0);
|
|
123
|
+
return parts.length ? Math.min(...parts) : 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function newEventId() {
|
|
127
|
+
try {
|
|
128
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
|
|
129
|
+
} catch {
|
|
130
|
+
/* fall through */
|
|
131
|
+
}
|
|
132
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Drops undefined / null / "" so vendors never see empty parameters. */
|
|
136
|
+
function clean(value) {
|
|
137
|
+
if (Array.isArray(value)) return value.map(clean);
|
|
138
|
+
if (value && typeof value === "object") {
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const [key, v] of Object.entries(value)) {
|
|
141
|
+
if (v === undefined || v === null || v === "") continue;
|
|
142
|
+
out[key] = clean(v);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function safely(label, fn) {
|
|
150
|
+
try {
|
|
151
|
+
return fn();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (import.meta.dev) console.warn(`[analytics] ${label} failed:`, error);
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The page URL with anything sensitive taken out of the query string. */
|
|
159
|
+
function safeLocation() {
|
|
160
|
+
try {
|
|
161
|
+
const url = new URL(window.location.href);
|
|
162
|
+
for (const key of SENSITIVE_PARAMS) url.searchParams.delete(key);
|
|
163
|
+
url.hash = "";
|
|
164
|
+
return url.toString();
|
|
165
|
+
} catch {
|
|
166
|
+
return window.location.href;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The id every event uses for a product — see the note at the top. */
|
|
171
|
+
function contentId(product, variant, fallback) {
|
|
172
|
+
const id = product?.id ?? variant?.product_id ?? fallback;
|
|
173
|
+
return id === undefined || id === null ? "" : String(id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ------------------------------------------------------------------ mapping */
|
|
177
|
+
|
|
178
|
+
const variantLabel = (variant) => {
|
|
179
|
+
const values = variant?.values;
|
|
180
|
+
if (typeof values === "string") return values;
|
|
181
|
+
return (values ?? [])
|
|
182
|
+
.map((v) => (typeof v === "string" ? v : v?.name ?? v?.value))
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join(" / ");
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const brandOf = (product) =>
|
|
188
|
+
typeof product?.brand === "object" ? product?.brand?.name || "" : product?.brand || "";
|
|
189
|
+
|
|
190
|
+
/** [top-level, sub-category] names for GA's item_category / item_category2. */
|
|
191
|
+
function categoriesOf(product) {
|
|
192
|
+
const first = product?.categories?.[0];
|
|
193
|
+
const category = first?.category ?? first;
|
|
194
|
+
if (!category) return [];
|
|
195
|
+
const names = [category.name];
|
|
196
|
+
const parent = category.parent_category;
|
|
197
|
+
if (parent && typeof parent === "object" && parent.name) names.unshift(parent.name);
|
|
198
|
+
return names.filter(Boolean);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A catalog product -> a GA4 item.
|
|
203
|
+
* @param {object} product
|
|
204
|
+
* @param {{ variant?: object, quantity?: number, index?: number,
|
|
205
|
+
* listId?: string, listName?: string }} [opts]
|
|
206
|
+
*/
|
|
207
|
+
export function productToItem(product, opts = {}) {
|
|
208
|
+
if (!product) return null;
|
|
209
|
+
const { variant, quantity = 1, index, listId, listName } = opts;
|
|
210
|
+
// [top-level, sub-category] — Shoes > Sneakers — as GA's item_category / item_category2.
|
|
211
|
+
const [category, category2] = categoriesOf(product);
|
|
212
|
+
|
|
213
|
+
let price = variant ? num(variant.price) : 0;
|
|
214
|
+
if (!(price > 0)) {
|
|
215
|
+
price =
|
|
216
|
+
lowestPrice(product.discounted_price_range) ||
|
|
217
|
+
lowestPrice(product.price_range) ||
|
|
218
|
+
Math.min(
|
|
219
|
+
...(product.product_variants ?? []).map((v) => num(v.price)).filter((n) => n > 0),
|
|
220
|
+
Number.MAX_SAFE_INTEGER
|
|
221
|
+
);
|
|
222
|
+
if (!(price > 0) || price === Number.MAX_SAFE_INTEGER) price = num(product.price);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return clean({
|
|
226
|
+
item_id: contentId(product, variant),
|
|
227
|
+
item_name: product.name || product.page_title,
|
|
228
|
+
item_brand: brandOf(product),
|
|
229
|
+
item_category: category,
|
|
230
|
+
item_category2: category2,
|
|
231
|
+
item_variant: variantLabel(variant),
|
|
232
|
+
variant_id: variant?.id !== undefined ? String(variant.id) : undefined,
|
|
233
|
+
sku: variant?.sku || product.sku,
|
|
234
|
+
price: money(price),
|
|
235
|
+
quantity,
|
|
236
|
+
index,
|
|
237
|
+
item_list_id: listId,
|
|
238
|
+
item_list_name: listName,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A cart line (the server's `cart[id]` entry, keyed by variant id) -> item.
|
|
244
|
+
* The payload doesn't repeat brand / category, so what the add-to-cart call
|
|
245
|
+
* site knew is remembered per line and laid back over it here.
|
|
246
|
+
*/
|
|
247
|
+
function cartLineToItem(id, line, quantity) {
|
|
248
|
+
const known = state.lineContext.get(String(id)) ?? {};
|
|
249
|
+
const productId =
|
|
250
|
+
known.item_id ?? line?.product_id ?? line?.product?.id ?? line?.product?.product?.id ?? id;
|
|
251
|
+
return clean({
|
|
252
|
+
item_id: String(productId),
|
|
253
|
+
item_name: line?.name ?? known.item_name,
|
|
254
|
+
item_brand: known.item_brand,
|
|
255
|
+
item_category: known.item_category,
|
|
256
|
+
item_category2: known.item_category2,
|
|
257
|
+
item_variant: variantLabel(line) || known.item_variant,
|
|
258
|
+
variant_id: String(id),
|
|
259
|
+
sku: line?.sku ?? known.sku,
|
|
260
|
+
price: money(line?.price ?? known.price),
|
|
261
|
+
quantity: quantity ?? Math.max(1, num(line?.quantity)),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The cart as a list of items — [{ id, ...line }] as useCart().cartItems holds it. */
|
|
266
|
+
function cartToItems(lines) {
|
|
267
|
+
return (lines ?? []).map((line) => cartLineToItem(line.id, line, Math.max(1, num(line.quantity))));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const sumItems = (items) => money(items.reduce((total, i) => total + num(i.price) * num(i.quantity || 1), 0));
|
|
271
|
+
const countItems = (items) => items.reduce((total, i) => total + num(i.quantity || 1), 0);
|
|
272
|
+
|
|
273
|
+
/** The Meta product parameters for a list of items. */
|
|
274
|
+
function metaProducts(items) {
|
|
275
|
+
return {
|
|
276
|
+
content_type: "product",
|
|
277
|
+
content_ids: items.map((i) => i.item_id),
|
|
278
|
+
contents: items.map((i) => ({ id: i.item_id, quantity: i.quantity || 1, item_price: i.price })),
|
|
279
|
+
num_items: countItems(items),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* ----------------------------------------------------------------- vendors */
|
|
284
|
+
|
|
285
|
+
function sendGA(name, params) {
|
|
286
|
+
if (!state.gaIds.length) return;
|
|
287
|
+
safely(`gtag ${name}`, () => window.gtag("event", name, clean(params)));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @param {string} name
|
|
292
|
+
* @param {object} params
|
|
293
|
+
* @param {{ custom?: boolean, eventID?: string }} [options]
|
|
294
|
+
*/
|
|
295
|
+
function sendMeta(name, params, { custom = false, eventID } = {}) {
|
|
296
|
+
if (!state.pixelIds.length) return;
|
|
297
|
+
safely(`fbq ${name}`, () =>
|
|
298
|
+
window.fbq(custom ? "trackCustom" : "track", name, clean(params), { eventID: eventID || newEventId() })
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function log(name, ga, meta) {
|
|
303
|
+
if (import.meta.dev) console.debug(`[analytics] ${name}`, { ga, meta });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Installs the gtag.js stub (events queue until the library arrives) and loads it. */
|
|
307
|
+
function loadGA(ids) {
|
|
308
|
+
window.dataLayer = window.dataLayer || [];
|
|
309
|
+
if (typeof window.gtag !== "function") {
|
|
310
|
+
// Must push `arguments`, not an array: gtag.js reads the Arguments object.
|
|
311
|
+
window.gtag = function gtag() {
|
|
312
|
+
window.dataLayer.push(arguments); // eslint-disable-line prefer-rest-params
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
// Consent Mode v2. Granted by default because the storefront has no consent
|
|
316
|
+
// banner; when one is added, flip these to "denied" and call setConsent().
|
|
317
|
+
window.gtag("consent", "default", {
|
|
318
|
+
ad_storage: "granted",
|
|
319
|
+
analytics_storage: "granted",
|
|
320
|
+
ad_user_data: "granted",
|
|
321
|
+
ad_personalization: "granted",
|
|
322
|
+
});
|
|
323
|
+
window.gtag("js", new Date());
|
|
324
|
+
for (const id of ids) {
|
|
325
|
+
// page_view is sent by hand: the shop is a SPA, and a config-time hit would
|
|
326
|
+
// fire before the page title is set and miss every client-side navigation.
|
|
327
|
+
window.gtag("config", id, { send_page_view: false });
|
|
328
|
+
}
|
|
329
|
+
const script = document.createElement("script");
|
|
330
|
+
script.async = true;
|
|
331
|
+
script.src = `${GTAG_SRC}?id=${encodeURIComponent(ids[0])}`;
|
|
332
|
+
document.head.appendChild(script);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Meta's base code, un-minified: queueing stub, loader, then init per pixel. */
|
|
336
|
+
function loadPixel(ids) {
|
|
337
|
+
if (!window.fbq) {
|
|
338
|
+
const fbq = function fbq() {
|
|
339
|
+
if (fbq.callMethod) fbq.callMethod.apply(fbq, arguments); // eslint-disable-line prefer-rest-params
|
|
340
|
+
else fbq.queue.push(arguments); // eslint-disable-line prefer-rest-params
|
|
341
|
+
};
|
|
342
|
+
if (!window._fbq) window._fbq = fbq;
|
|
343
|
+
fbq.push = fbq;
|
|
344
|
+
fbq.loaded = true;
|
|
345
|
+
fbq.version = "2.0";
|
|
346
|
+
fbq.queue = [];
|
|
347
|
+
window.fbq = fbq;
|
|
348
|
+
|
|
349
|
+
const script = document.createElement("script");
|
|
350
|
+
script.async = true;
|
|
351
|
+
script.src = FBEVENTS_SRC;
|
|
352
|
+
const first = document.getElementsByTagName("script")[0];
|
|
353
|
+
if (first?.parentNode) first.parentNode.insertBefore(script, first);
|
|
354
|
+
else document.head.appendChild(script);
|
|
355
|
+
}
|
|
356
|
+
// PageView is sent by hand on every route change; the pixel's own
|
|
357
|
+
// history-change PageView would double-count each navigation.
|
|
358
|
+
window.fbq.disablePushState = true;
|
|
359
|
+
for (const id of ids) window.fbq("init", id);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* --------------------------------------------------------------- lifecycle */
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Loads the vendor scripts. Idempotent.
|
|
366
|
+
* @param {{ gaIds?: string[], pixelIds?: string[], currency?: string }} config
|
|
367
|
+
*/
|
|
368
|
+
function start({ gaIds = [], pixelIds = [], currency = "" } = {}) {
|
|
369
|
+
if (!isClient() || state.started) return false;
|
|
370
|
+
const ga = parseIds(gaIds, "ga");
|
|
371
|
+
const pixel = parseIds(pixelIds, "pixel");
|
|
372
|
+
state.currency = String(currency || "").toUpperCase();
|
|
373
|
+
if (!ga.length && !pixel.length) return false;
|
|
374
|
+
|
|
375
|
+
state.gaIds = ga;
|
|
376
|
+
state.pixelIds = pixel;
|
|
377
|
+
state.started = true;
|
|
378
|
+
|
|
379
|
+
safely("load gtag", () => ga.length && loadGA(ga));
|
|
380
|
+
safely("load pixel", () => pixel.length && loadPixel(pixel));
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function setCurrency(code) {
|
|
385
|
+
if (code) state.currency = String(code).toUpperCase();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Cookie / consent banner hook. `true` grants, `false` revokes.
|
|
390
|
+
* GA honours it through Consent Mode, the pixel through fbq('consent').
|
|
391
|
+
*/
|
|
392
|
+
function setConsent(granted) {
|
|
393
|
+
state.consent = !!granted;
|
|
394
|
+
if (!enabled()) return;
|
|
395
|
+
const value = granted ? "granted" : "denied";
|
|
396
|
+
if (state.gaIds.length) {
|
|
397
|
+
safely("consent gtag", () =>
|
|
398
|
+
window.gtag("consent", "update", {
|
|
399
|
+
ad_storage: value,
|
|
400
|
+
analytics_storage: value,
|
|
401
|
+
ad_user_data: value,
|
|
402
|
+
ad_personalization: value,
|
|
403
|
+
})
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (state.pixelIds.length) {
|
|
407
|
+
safely("consent fbq", () => window.fbq("consent", granted ? "grant" : "revoke"));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** A new path: events that fire once per page may fire again. */
|
|
412
|
+
function routeChanged(path) {
|
|
413
|
+
if (path !== state.seenPath) {
|
|
414
|
+
state.seenPath = path;
|
|
415
|
+
state.seen.clear();
|
|
416
|
+
state.listOf.clear();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** True the first time `key` is seen on this page. */
|
|
421
|
+
function firstTime(key) {
|
|
422
|
+
if (state.seen.has(key)) return false;
|
|
423
|
+
state.seen.add(key);
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const currencyParams = (value) => ({ currency: state.currency || undefined, value: money(value) });
|
|
428
|
+
|
|
429
|
+
/* ------------------------------------------------------------------ events */
|
|
430
|
+
|
|
431
|
+
function pageView() {
|
|
432
|
+
if (!enabled()) return;
|
|
433
|
+
const params = {
|
|
434
|
+
page_title: document.title,
|
|
435
|
+
page_location: safeLocation(),
|
|
436
|
+
page_path: window.location.pathname,
|
|
437
|
+
};
|
|
438
|
+
sendGA("page_view", params);
|
|
439
|
+
sendMeta("PageView", {});
|
|
440
|
+
log("page_view", params);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** A product page was viewed. */
|
|
444
|
+
function viewItem(product, variant) {
|
|
445
|
+
if (!enabled() || !product?.id) return;
|
|
446
|
+
if (!firstTime(`view_item:${product.id}`)) return;
|
|
447
|
+
const item = productToItem(product, { variant });
|
|
448
|
+
const ga = { ...currencyParams(item.price), items: [item] };
|
|
449
|
+
const meta = {
|
|
450
|
+
...metaProducts([item]),
|
|
451
|
+
content_name: item.item_name,
|
|
452
|
+
content_category: item.item_category2 ? `${item.item_category} > ${item.item_category2}` : item.item_category,
|
|
453
|
+
...currencyParams(item.price),
|
|
454
|
+
};
|
|
455
|
+
sendGA("view_item", ga);
|
|
456
|
+
sendMeta("ViewContent", meta);
|
|
457
|
+
log("view_item", ga, meta);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A list of products came into view: a category, search results, a home-page
|
|
462
|
+
* product section.
|
|
463
|
+
* @param {{ id: string, name: string, products: object[], category?: boolean }} list
|
|
464
|
+
*/
|
|
465
|
+
function viewItemList({ id, name, products, category = false } = {}) {
|
|
466
|
+
if (!enabled() || !products?.length) return;
|
|
467
|
+
const rows = products.filter((p) => p && !p.is_placeholder).slice(0, MAX_LIST_ITEMS);
|
|
468
|
+
if (!rows.length) return;
|
|
469
|
+
// Remembered even when the event itself is a repeat: a click still needs it.
|
|
470
|
+
rows.forEach((p, i) => state.listOf.set(String(p.id), { listId: id, listName: name, index: i }));
|
|
471
|
+
if (!firstTime(`view_item_list:${id}:${rows.map((p) => p.id).join(",")}`)) return;
|
|
472
|
+
|
|
473
|
+
const items = rows.map((p, index) =>
|
|
474
|
+
productToItem(p, { index: index + 1, listId: id, listName: name })
|
|
475
|
+
);
|
|
476
|
+
const ga = { item_list_id: id, item_list_name: name, items };
|
|
477
|
+
sendGA("view_item_list", ga);
|
|
478
|
+
if (category) {
|
|
479
|
+
sendMeta(
|
|
480
|
+
"ViewCategory",
|
|
481
|
+
{ content_type: "product", content_name: name, content_category: name, content_ids: items.map((i) => i.item_id) },
|
|
482
|
+
{ custom: true }
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
log("view_item_list", ga);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** A product card was clicked. */
|
|
489
|
+
function selectItem(product, override = {}) {
|
|
490
|
+
if (!enabled() || !product?.id || product.is_placeholder) return;
|
|
491
|
+
const from = state.listOf.get(String(product.id)) ?? {};
|
|
492
|
+
const { listId = from.listId, listName = from.listName, index = from.index } = override;
|
|
493
|
+
const ga = {
|
|
494
|
+
item_list_id: listId,
|
|
495
|
+
item_list_name: listName,
|
|
496
|
+
items: [productToItem(product, { index: index !== undefined ? index + 1 : undefined, listId, listName })],
|
|
497
|
+
};
|
|
498
|
+
sendGA("select_item", ga);
|
|
499
|
+
log("select_item", ga);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** What we last heard the cart contained for this line, before an edit. */
|
|
503
|
+
function peekCartLine(id) {
|
|
504
|
+
return state.cart[String(id)] ?? null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Remembers the server's cart. Called with every cart response. */
|
|
508
|
+
function rememberCart(response) {
|
|
509
|
+
if (!isClient() || !response || typeof response !== "object") return;
|
|
510
|
+
const lines = response.cart && typeof response.cart === "object" ? response.cart : {};
|
|
511
|
+
state.cart = Object.fromEntries(Object.entries(lines).map(([id, line]) => [String(id), { ...line }]));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The cart was emptied (order placed / paid). */
|
|
515
|
+
function resetCart() {
|
|
516
|
+
state.cart = {};
|
|
517
|
+
state.lineContext.clear();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* A cart edit the server accepted.
|
|
522
|
+
*
|
|
523
|
+
* Called from CartServices.EditCart — the one function every add / remove goes
|
|
524
|
+
* through, from product pages, cards, quick buy, the drawer and checkout — so
|
|
525
|
+
* nothing is missed and nothing is counted twice.
|
|
526
|
+
*
|
|
527
|
+
* @param {{ itemId: string|number, quantity: number, action: "add"|"remove",
|
|
528
|
+
* before: object|null, after: object|null,
|
|
529
|
+
* context?: { product?: object, variant?: object } }} edit
|
|
530
|
+
*/
|
|
531
|
+
function cartEdited({ itemId, quantity, action, before, after, context }) {
|
|
532
|
+
if (!isClient()) return;
|
|
533
|
+
const id = String(itemId);
|
|
534
|
+
|
|
535
|
+
// Remember what the call site knew, for later edits made from the drawer.
|
|
536
|
+
if (context?.product) {
|
|
537
|
+
const item = productToItem(context.product, { variant: context.variant });
|
|
538
|
+
if (item) state.lineContext.set(id, item);
|
|
539
|
+
}
|
|
540
|
+
if (!enabled()) return;
|
|
541
|
+
|
|
542
|
+
const adding = action === "add";
|
|
543
|
+
const line = (adding ? after ?? before : before ?? after) ?? null;
|
|
544
|
+
const known = state.lineContext.get(id);
|
|
545
|
+
if (!line && !known) return;
|
|
546
|
+
|
|
547
|
+
// Prefer the server's own before / after: it clamps to stock and cart limits.
|
|
548
|
+
let delta = num(quantity) || 1;
|
|
549
|
+
if (before && after) {
|
|
550
|
+
const diff = adding ? num(after.quantity) - num(before.quantity) : num(before.quantity) - num(after.quantity);
|
|
551
|
+
if (diff > 0) delta = diff;
|
|
552
|
+
} else if (!adding && before) {
|
|
553
|
+
delta = Math.min(delta, num(before.quantity) || delta);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const item = line ? cartLineToItem(id, line, delta) : { ...known, quantity: delta };
|
|
557
|
+
const total = money(num(item.price) * delta);
|
|
558
|
+
const ga = { ...currencyParams(total), items: [item] };
|
|
559
|
+
|
|
560
|
+
if (adding) {
|
|
561
|
+
state.lastAddAt = Date.now();
|
|
562
|
+
const meta = { ...metaProducts([item]), content_name: item.item_name, ...currencyParams(total) };
|
|
563
|
+
sendGA("add_to_cart", ga);
|
|
564
|
+
sendMeta("AddToCart", meta);
|
|
565
|
+
log("add_to_cart", ga, meta);
|
|
566
|
+
} else {
|
|
567
|
+
sendGA("remove_from_cart", ga);
|
|
568
|
+
log("remove_from_cart", ga);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** The cart drawer / page was opened. */
|
|
573
|
+
function viewCart(lines) {
|
|
574
|
+
if (!enabled() || !lines?.length) return;
|
|
575
|
+
// The drawer opens itself after an add; that isn't the shopper looking at
|
|
576
|
+
// their bag, and counting it would bury the real view_cart signal.
|
|
577
|
+
if (Date.now() - state.lastAddAt < 3000) return;
|
|
578
|
+
const items = cartToItems(lines);
|
|
579
|
+
const ga = { ...currencyParams(sumItems(items)), items };
|
|
580
|
+
sendGA("view_cart", ga);
|
|
581
|
+
log("view_cart", ga);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The checkout page opened with something in the bag. */
|
|
585
|
+
function beginCheckout(lines, { coupon } = {}) {
|
|
586
|
+
if (!enabled() || !lines?.length) return;
|
|
587
|
+
const items = cartToItems(lines);
|
|
588
|
+
const value = sumItems(items);
|
|
589
|
+
const ga = { ...currencyParams(value), coupon, items };
|
|
590
|
+
const meta = { ...metaProducts(items), ...currencyParams(value) };
|
|
591
|
+
sendGA("begin_checkout", ga);
|
|
592
|
+
sendMeta("InitiateCheckout", meta);
|
|
593
|
+
log("begin_checkout", ga, meta);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** A delivery destination was chosen. */
|
|
597
|
+
function addShippingInfo(lines, { tier, coupon } = {}) {
|
|
598
|
+
if (!enabled() || !lines?.length) return;
|
|
599
|
+
const items = cartToItems(lines);
|
|
600
|
+
const ga = { ...currencyParams(sumItems(items)), coupon, shipping_tier: tier, items };
|
|
601
|
+
sendGA("add_shipping_info", ga);
|
|
602
|
+
log("add_shipping_info", ga);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** A payment method was chosen. */
|
|
606
|
+
function addPaymentInfo(lines, { paymentType, coupon } = {}) {
|
|
607
|
+
if (!enabled() || !lines?.length) return;
|
|
608
|
+
const items = cartToItems(lines);
|
|
609
|
+
const value = sumItems(items);
|
|
610
|
+
const ga = { ...currencyParams(value), coupon, payment_type: paymentType, items };
|
|
611
|
+
const meta = { ...metaProducts(items), ...currencyParams(value) };
|
|
612
|
+
sendGA("add_payment_info", ga);
|
|
613
|
+
sendMeta("AddPaymentInfo", meta);
|
|
614
|
+
log("add_payment_info", ga, meta);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* --------------------------------------------------------------- purchases */
|
|
618
|
+
|
|
619
|
+
const PURCHASE_KEY = (id) => `osi:purchase:${id}`;
|
|
620
|
+
/** A confirmation page opened more than this long after the order isn't a sale. */
|
|
621
|
+
const PURCHASE_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
|
622
|
+
|
|
623
|
+
function alreadyPurchased(id) {
|
|
624
|
+
if (state.purchased.has(id)) return true;
|
|
625
|
+
try {
|
|
626
|
+
return window.localStorage.getItem(PURCHASE_KEY(id)) === "1";
|
|
627
|
+
} catch {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function markPurchased(id) {
|
|
633
|
+
state.purchased.add(id);
|
|
634
|
+
try {
|
|
635
|
+
window.localStorage.setItem(PURCHASE_KEY(id), "1");
|
|
636
|
+
} catch {
|
|
637
|
+
/* private mode: the in-memory set still guards this tab */
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Order lines -> items.
|
|
643
|
+
*
|
|
644
|
+
* `checkout_price` is read as the line total. If the lines don't add up to the
|
|
645
|
+
* order's cart price that way, it is read as a unit price instead — whichever
|
|
646
|
+
* reading reconciles with `checkout_cart_price` wins, so a unit / line-total
|
|
647
|
+
* mix-up in the payload can't inflate or shrink reported revenue.
|
|
648
|
+
*/
|
|
649
|
+
function orderToItems(order) {
|
|
650
|
+
const lines = order?.items ?? [];
|
|
651
|
+
const cartPrice = num(order?.checkout_cart_price);
|
|
652
|
+
const asLineTotals = lines.reduce((t, l) => t + num(l.checkout_price), 0);
|
|
653
|
+
const asUnitPrices = lines.reduce((t, l) => t + num(l.checkout_price) * Math.max(1, num(l.quantity)), 0);
|
|
654
|
+
const unitPriced =
|
|
655
|
+
cartPrice > 0 &&
|
|
656
|
+
Math.abs(asLineTotals - cartPrice) > 0.01 &&
|
|
657
|
+
Math.abs(asUnitPrices - cartPrice) <= 0.01;
|
|
658
|
+
|
|
659
|
+
return lines.map((line) => {
|
|
660
|
+
const quantity = Math.max(1, num(line.quantity));
|
|
661
|
+
const variant = line.product && line.product.product ? line.product : null;
|
|
662
|
+
const product = variant?.product ?? line.product?.product ?? null;
|
|
663
|
+
const base = product ? productToItem(product, { variant, quantity }) : {};
|
|
664
|
+
const unit = unitPriced ? num(line.checkout_price) : num(line.checkout_price) / quantity;
|
|
665
|
+
return clean({
|
|
666
|
+
...base,
|
|
667
|
+
item_id: base.item_id || String(line.product_id ?? variant?.id ?? line.id ?? line.name),
|
|
668
|
+
item_name: line.name || base.item_name,
|
|
669
|
+
// The order payload writes "Red | 42"; everywhere else it's "Red / 42".
|
|
670
|
+
item_variant: String(line.variant_values ?? "").replace(/\s*\|\s*/g, " / ") || base.item_variant,
|
|
671
|
+
price: money(unit),
|
|
672
|
+
quantity,
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The order was placed (and, for card orders, paid).
|
|
679
|
+
*
|
|
680
|
+
* Reported once per order: the confirmation page can be reloaded, bookmarked or
|
|
681
|
+
* re-opened from an email. GA's `value` leaves shipping out, as its docs ask
|
|
682
|
+
* (shipping goes in its own field); the pixel's `value` is the full amount the
|
|
683
|
+
* customer paid, which is what its ROAS reporting expects.
|
|
684
|
+
*
|
|
685
|
+
* @returns {boolean} whether an event was sent
|
|
686
|
+
*/
|
|
687
|
+
function purchase(order) {
|
|
688
|
+
if (!enabled() || !order?.id) return false;
|
|
689
|
+
const orderRef = String(order.order_number ?? order.id);
|
|
690
|
+
if (alreadyPurchased(orderRef)) return false;
|
|
691
|
+
// An old order opened from a bookmark or an email isn't a new sale. Only
|
|
692
|
+
// applied when the payload says when the order began.
|
|
693
|
+
const placedAt = Date.parse(
|
|
694
|
+
order.created_at ?? order.created ?? order.date_created ?? order.date ?? ""
|
|
695
|
+
);
|
|
696
|
+
if (Number.isFinite(placedAt) && Date.now() - placedAt > PURCHASE_MAX_AGE_MS) return false;
|
|
697
|
+
|
|
698
|
+
const items = orderToItems(order);
|
|
699
|
+
if (!items.length) return false;
|
|
700
|
+
|
|
701
|
+
const total = money(order.checkout_order_total ?? sumItems(items));
|
|
702
|
+
const shipping = order.freedelivery ? 0 : money(order.checkout_delivery_fees);
|
|
703
|
+
const coupon = order.promocode?.code;
|
|
704
|
+
|
|
705
|
+
const ga = {
|
|
706
|
+
transaction_id: orderRef,
|
|
707
|
+
...currencyParams(Math.max(0, total - shipping)),
|
|
708
|
+
shipping,
|
|
709
|
+
coupon,
|
|
710
|
+
payment_type: order.order_payments?.[0]?.method_name,
|
|
711
|
+
items,
|
|
712
|
+
};
|
|
713
|
+
const meta = { ...metaProducts(items), ...currencyParams(total) };
|
|
714
|
+
|
|
715
|
+
sendGA("purchase", ga);
|
|
716
|
+
// Same event id for the browser and a future server-side event: it is the order.
|
|
717
|
+
sendMeta("Purchase", meta, { eventID: `purchase-${orderRef}` });
|
|
718
|
+
markPurchased(orderRef);
|
|
719
|
+
resetCart();
|
|
720
|
+
log("purchase", ga, meta);
|
|
721
|
+
|
|
722
|
+
identify({
|
|
723
|
+
email: order.customer?.email,
|
|
724
|
+
phone: order.customer?.mobile_number,
|
|
725
|
+
firstName: order.customer?.first_name,
|
|
726
|
+
lastName: order.customer?.last_name,
|
|
727
|
+
});
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/* ----------------------------------------------------------- everything else */
|
|
732
|
+
|
|
733
|
+
/** A storefront search ran. */
|
|
734
|
+
function search(term, products = []) {
|
|
735
|
+
if (!enabled() || !String(term ?? "").trim()) return;
|
|
736
|
+
const query = String(term).trim();
|
|
737
|
+
if (!firstTime(`search:${query.toLowerCase()}`)) return;
|
|
738
|
+
const items = products.filter((p) => p?.id).slice(0, MAX_LIST_ITEMS).map((p) => productToItem(p));
|
|
739
|
+
sendGA("search", { search_term: query });
|
|
740
|
+
sendMeta("Search", {
|
|
741
|
+
search_string: query,
|
|
742
|
+
content_type: "product",
|
|
743
|
+
content_ids: items.map((i) => i.item_id),
|
|
744
|
+
});
|
|
745
|
+
log("search", { search_term: query });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function signUp(method = "email") {
|
|
749
|
+
if (!enabled()) return;
|
|
750
|
+
sendGA("sign_up", { method });
|
|
751
|
+
sendMeta("CompleteRegistration", { content_name: "Registration", status: true });
|
|
752
|
+
log("sign_up", { method });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function login(method = "email") {
|
|
756
|
+
if (!enabled()) return;
|
|
757
|
+
sendGA("login", { method });
|
|
758
|
+
log("login", { method });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function addToWishlist(product) {
|
|
762
|
+
if (!enabled() || !product?.id) return;
|
|
763
|
+
const item = productToItem(product);
|
|
764
|
+
const ga = { ...currencyParams(item.price), items: [item] };
|
|
765
|
+
const meta = { ...metaProducts([item]), content_name: item.item_name, ...currencyParams(item.price) };
|
|
766
|
+
sendGA("add_to_wishlist", ga);
|
|
767
|
+
sendMeta("AddToWishlist", meta);
|
|
768
|
+
log("add_to_wishlist", ga, meta);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** A newsletter sign-up. */
|
|
772
|
+
function lead(source = "newsletter") {
|
|
773
|
+
if (!enabled()) return;
|
|
774
|
+
sendGA("generate_lead", { lead_source: source });
|
|
775
|
+
sendMeta("Lead", { content_name: source });
|
|
776
|
+
log("generate_lead", { lead_source: source });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** The contact form was sent. */
|
|
780
|
+
function contact() {
|
|
781
|
+
if (!enabled()) return;
|
|
782
|
+
sendGA("generate_lead", { lead_source: "contact_form" });
|
|
783
|
+
sendMeta("Contact", { content_name: "contact_form" });
|
|
784
|
+
log("contact", {});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/* ---------------------------------------------------------- advanced matching */
|
|
788
|
+
|
|
789
|
+
async function sha256(value) {
|
|
790
|
+
const bytes = new TextEncoder().encode(value);
|
|
791
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
792
|
+
return Array.from(new Uint8Array(digest))
|
|
793
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
794
|
+
.join("");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Meta advanced matching: hashed customer details attached to the pixel so it
|
|
799
|
+
* can match events to people. Hashed in the browser (SHA-256, normalised the
|
|
800
|
+
* way Meta specifies); the raw values never leave. Called once a person is
|
|
801
|
+
* known — at purchase and at login.
|
|
802
|
+
*/
|
|
803
|
+
async function identify({ email, phone, firstName, lastName } = {}) {
|
|
804
|
+
if (!enabled() || !state.pixelIds.length || !state.consent) return;
|
|
805
|
+
if (typeof crypto === "undefined" || !crypto.subtle) return;
|
|
806
|
+
await safely("identify", async () => {
|
|
807
|
+
const fields = {
|
|
808
|
+
em: String(email ?? "").trim().toLowerCase(),
|
|
809
|
+
ph: String(phone ?? "").replace(/\D/g, ""),
|
|
810
|
+
fn: String(firstName ?? "").trim().toLowerCase(),
|
|
811
|
+
ln: String(lastName ?? "").trim().toLowerCase(),
|
|
812
|
+
};
|
|
813
|
+
const hashed = {};
|
|
814
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
815
|
+
if (value) hashed[key] = await sha256(value);
|
|
816
|
+
}
|
|
817
|
+
if (!Object.keys(hashed).length) return;
|
|
818
|
+
for (const id of state.pixelIds) window.fbq("init", id, hashed);
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/* ------------------------------------------------------------------- export */
|
|
823
|
+
|
|
824
|
+
const api = {
|
|
825
|
+
start,
|
|
826
|
+
setCurrency,
|
|
827
|
+
setConsent,
|
|
828
|
+
routeChanged,
|
|
829
|
+
isEnabled: enabled,
|
|
830
|
+
pageView,
|
|
831
|
+
viewItem,
|
|
832
|
+
viewItemList,
|
|
833
|
+
selectItem,
|
|
834
|
+
peekCartLine,
|
|
835
|
+
rememberCart,
|
|
836
|
+
resetCart,
|
|
837
|
+
cartEdited,
|
|
838
|
+
viewCart,
|
|
839
|
+
beginCheckout,
|
|
840
|
+
addShippingInfo,
|
|
841
|
+
addPaymentInfo,
|
|
842
|
+
purchase,
|
|
843
|
+
search,
|
|
844
|
+
signUp,
|
|
845
|
+
login,
|
|
846
|
+
addToWishlist,
|
|
847
|
+
lead,
|
|
848
|
+
contact,
|
|
849
|
+
identify,
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
export const analytics = Object.fromEntries(
|
|
853
|
+
Object.entries(api).map(([name, fn]) => [
|
|
854
|
+
name,
|
|
855
|
+
(...args) => {
|
|
856
|
+
try {
|
|
857
|
+
return fn(...args);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
if (import.meta.dev) console.warn(`[analytics] ${name} failed:`, error);
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
])
|
|
864
|
+
);
|