@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.
Files changed (206) hide show
  1. package/README.md +80 -2
  2. package/THEME_DEV_API.md +80 -0
  3. package/app/app.vue +154 -0
  4. package/app/assets/css/osi-controls.css +824 -0
  5. package/app/components/SectionRenderer.vue +46 -0
  6. package/app/components/builder/BuilderOverlay.vue +582 -0
  7. package/app/components/checkout/ExtraFees.vue +329 -0
  8. package/app/components/checkout/PaymentHandoff.vue +388 -0
  9. package/app/components/common/Banner.vue +35 -0
  10. package/app/components/common/ConfirmDeleteModal.vue +117 -0
  11. package/app/components/common/DropdownSelect.vue +432 -0
  12. package/app/components/common/Loader.vue +32 -0
  13. package/app/components/common/OsiField.vue +218 -0
  14. package/app/components/common/PhoneNumInput.vue +434 -0
  15. package/app/composables/UseBodyScrollLock.js +122 -0
  16. package/app/composables/UseThemeGroup.js +575 -0
  17. package/app/composables/useAddToCart.js +87 -0
  18. package/app/composables/useAnalytics.js +15 -0
  19. package/app/composables/useAnnouncements.js +29 -0
  20. package/app/composables/useApiErrorToast.js +104 -0
  21. package/app/composables/useAuth.js +99 -0
  22. package/app/composables/useBanners.js +25 -0
  23. package/app/composables/useBuilderPreview.js +320 -0
  24. package/app/composables/useButtonStyle.js +107 -0
  25. package/app/composables/useCarousel.js +160 -0
  26. package/app/composables/useCart.js +109 -0
  27. package/app/composables/useCheckout.js +76 -0
  28. package/app/composables/useClassificationItems.js +269 -0
  29. package/app/composables/useClassificationPage.js +545 -0
  30. package/app/composables/useClassificationUrl.js +63 -0
  31. package/app/composables/useColorScheme.js +44 -0
  32. package/app/composables/useContactForm.js +53 -0
  33. package/app/composables/useContentTree.js +770 -0
  34. package/app/composables/useDragScroll.js +73 -0
  35. package/app/composables/useFooterSocials.js +107 -0
  36. package/app/composables/useInputStyle.js +45 -0
  37. package/app/composables/useLayoutShell.js +114 -0
  38. package/app/composables/useLazyVideo.js +36 -0
  39. package/app/composables/useMediaTypes.js +66 -0
  40. package/app/composables/useMediaUrl.js +71 -0
  41. package/app/composables/useMenu.js +100 -0
  42. package/app/composables/useNavStyle.js +219 -0
  43. package/app/composables/usePlaceholders.js +294 -0
  44. package/app/composables/usePolicies.js +26 -0
  45. package/app/composables/useProductCardMedia.js +135 -0
  46. package/app/composables/useProductCardStyle.js +132 -0
  47. package/app/composables/useProductDetail.js +982 -0
  48. package/app/composables/useProductGallery.js +166 -0
  49. package/app/composables/useProductItems.js +270 -0
  50. package/app/composables/useProductUrl.js +60 -0
  51. package/app/composables/useProductVariants.js +210 -0
  52. package/app/composables/useQuickShop.js +115 -0
  53. package/app/composables/useRelatedProducts.js +186 -0
  54. package/app/composables/useRenderContext.js +45 -0
  55. package/app/composables/useScopedCss.js +106 -0
  56. package/app/composables/useSearch.js +35 -0
  57. package/app/composables/useSectionData.js +462 -0
  58. package/app/composables/useSectionShell.js +113 -0
  59. package/app/composables/useSidebar.js +69 -0
  60. package/app/composables/useStoreData.js +57 -0
  61. package/app/composables/useStoreInit.js +52 -0
  62. package/app/composables/useStorefront.js +239 -0
  63. package/app/composables/useT.js +21 -0
  64. package/app/composables/useTextStyles.js +84 -0
  65. package/app/composables/useThemeControls.js +34 -0
  66. package/app/composables/useThemeSeo.js +91 -0
  67. package/app/composables/useThemeTokens.js +171 -0
  68. package/app/composables/useWishlist.js +66 -0
  69. package/app/logic/components/AnnouncementBar.js +118 -0
  70. package/app/logic/components/CardGrid.js +125 -0
  71. package/app/logic/components/CardList.js +98 -0
  72. package/app/logic/components/CartDrawer.js +273 -0
  73. package/app/logic/components/CartItem.js +31 -0
  74. package/app/logic/components/ClassificationBento.js +6 -0
  75. package/app/logic/components/ClassificationCard.js +6 -0
  76. package/app/logic/components/ClassificationCarousel.js +115 -0
  77. package/app/logic/components/ClassificationEditorial.js +41 -0
  78. package/app/logic/components/ClassificationFeatured.js +57 -0
  79. package/app/logic/components/ClassificationGrid.js +6 -0
  80. package/app/logic/components/ClassificationNav.js +61 -0
  81. package/app/logic/components/ClassificationPart.js +72 -0
  82. package/app/logic/components/FooterBlock.js +91 -0
  83. package/app/logic/components/FooterSocials.js +60 -0
  84. package/app/logic/components/HeaderDropdownList.js +29 -0
  85. package/app/logic/components/HeaderIcon.js +68 -0
  86. package/app/logic/components/HeaderLogo.js +39 -0
  87. package/app/logic/components/HeaderNav.js +268 -0
  88. package/app/logic/components/MobileMenu.js +280 -0
  89. package/app/logic/components/ProductBuyButtons.js +66 -0
  90. package/app/logic/components/ProductCard.js +160 -0
  91. package/app/logic/components/ProductFilters.js +37 -0
  92. package/app/logic/components/ProductGallery.js +19 -0
  93. package/app/logic/components/ProductInfoBlock.js +411 -0
  94. package/app/logic/components/ProductLightbox.js +12 -0
  95. package/app/logic/components/ProductQuantity.js +31 -0
  96. package/app/logic/components/ProductVariantPicker.js +125 -0
  97. package/app/logic/components/QuickBuy.js +194 -0
  98. package/app/logic/components/SearchOverlay.js +41 -0
  99. package/app/logic/components/SidebarMenuItem.js +39 -0
  100. package/app/logic/components/SiteFooter.js +476 -0
  101. package/app/logic/components/SiteHeader.js +266 -0
  102. package/app/logic/components/WhatsAppButton.js +75 -0
  103. package/app/logic/layouts/default.js +66 -0
  104. package/app/logic/sections/AnnouncementBarSection.js +6 -0
  105. package/app/logic/sections/BlockCarousel.js +103 -0
  106. package/app/logic/sections/ClassificationListSection.js +40 -0
  107. package/app/logic/sections/ContactFormSection.js +294 -0
  108. package/app/logic/sections/ContactInfoSection.js +419 -0
  109. package/app/logic/sections/ContentNode.js +189 -0
  110. package/app/logic/sections/CountdownSection.js +254 -0
  111. package/app/logic/sections/CustomHtmlSection.js +51 -0
  112. package/app/logic/sections/DividerSection.js +31 -0
  113. package/app/logic/sections/FaqSection.js +53 -0
  114. package/app/logic/sections/FeatureListSection.js +6 -0
  115. package/app/logic/sections/FeaturedProductSection.js +40 -0
  116. package/app/logic/sections/FooterSection.js +16 -0
  117. package/app/logic/sections/FooterUtilitiesSection.js +24 -0
  118. package/app/logic/sections/GallerySection.js +232 -0
  119. package/app/logic/sections/HeaderSection.js +24 -0
  120. package/app/logic/sections/HeroSection.js +24 -0
  121. package/app/logic/sections/LogoListSection.js +6 -0
  122. package/app/logic/sections/MainClassificationSection.js +473 -0
  123. package/app/logic/sections/MainProductSection.js +211 -0
  124. package/app/logic/sections/MainSearchSection.js +6 -0
  125. package/app/logic/sections/MapSection.js +92 -0
  126. package/app/logic/sections/MediaContentSection.js +38 -0
  127. package/app/logic/sections/MediaWithTextSection.js +238 -0
  128. package/app/logic/sections/NewsletterSection.js +126 -0
  129. package/app/logic/sections/ProductHighlightSection.js +201 -0
  130. package/app/logic/sections/ProductListSection.js +45 -0
  131. package/app/logic/sections/ProductRecommendationsSection.js +113 -0
  132. package/app/logic/sections/RichTextSection.js +6 -0
  133. package/app/logic/sections/ScrollingTextSection.js +69 -0
  134. package/app/logic/sections/SearchHeaderSection.js +22 -0
  135. package/app/logic/sections/SlideshowSection.js +118 -0
  136. package/app/logic/sections/SpacerSection.js +37 -0
  137. package/app/logic/sections/SplitShowcaseSection.js +6 -0
  138. package/app/logic/sections/TestimonialListSection.js +6 -0
  139. package/app/logic/sections/VideoSection.js +235 -0
  140. package/app/pages/[category]/[product].vue +196 -0
  141. package/app/pages/__builder-preview.vue +38 -0
  142. package/app/pages/categories/[slug].vue +208 -0
  143. package/app/pages/checkout.vue +1972 -0
  144. package/app/pages/forgot-password.vue +115 -0
  145. package/app/pages/index.vue +64 -0
  146. package/app/pages/login.vue +585 -0
  147. package/app/pages/order-detail.vue +442 -0
  148. package/app/pages/pages/[handle].vue +58 -0
  149. package/app/pages/privacy-policy.vue +46 -0
  150. package/app/pages/resetpassword.vue +138 -0
  151. package/app/pages/resetverificationcode.vue +163 -0
  152. package/app/pages/result.vue +203 -0
  153. package/app/pages/return-policy.vue +50 -0
  154. package/app/pages/search.vue +100 -0
  155. package/app/pages/shipping-policy.vue +46 -0
  156. package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
  157. package/app/pages/user-profile/components/Whishlist.vue +137 -0
  158. package/app/pages/user-profile/components/left-bar.vue +233 -0
  159. package/app/pages/user-profile/components/orders.vue +162 -0
  160. package/app/pages/user-profile/components/profile-details.vue +566 -0
  161. package/app/pages/user-profile/components/settings.vue +194 -0
  162. package/app/pages/user-profile/index.vue +252 -0
  163. package/app/pages/verificationcode.vue +188 -0
  164. package/app/plugins/analytics.client.js +99 -0
  165. package/app/plugins/geo.ts +24 -0
  166. package/app/plugins/reveal.client.js +53 -0
  167. package/app/plugins/toast.js +10 -0
  168. package/app/plugins/vue-query.ts +5 -0
  169. package/app/services/ApiServices.js +74 -0
  170. package/app/services/AuthBus.js +162 -0
  171. package/app/services/Authentication/AuthenticationService.js +133 -0
  172. package/app/services/Authentication/TokenService.js +62 -0
  173. package/app/services/CartServices.js +91 -0
  174. package/app/services/CartToken.js +26 -0
  175. package/app/services/CategoryServices.js +54 -0
  176. package/app/services/CheckoutServices.js +113 -0
  177. package/app/services/HomeServices.js +133 -0
  178. package/app/services/NewsletterServices.js +29 -0
  179. package/app/services/PaymentServices.js +71 -0
  180. package/app/services/PoliciesServices.js +16 -0
  181. package/app/services/ProductServices.js +79 -0
  182. package/app/services/SearchServices.js +18 -0
  183. package/app/services/UserServices.js +105 -0
  184. package/app/services/UtilsServices.js +46 -0
  185. package/app/services/WishlistServices.js +51 -0
  186. package/app/services/storefront.service.js +57 -0
  187. package/app/stores/userStore.js +13 -0
  188. package/app/theme/categoryTree.js +68 -0
  189. package/app/theme/controls.js +88 -0
  190. package/app/theme/registry.js +56 -0
  191. package/app/utils/analytics.js +864 -0
  192. package/app/utils/device.js +50 -0
  193. package/app/utils/eventBus.js +24 -0
  194. package/app/utils/icon-presets.js +24 -0
  195. package/app/utils/productOffers.js +118 -0
  196. package/app/utils/requestCache.js +36 -0
  197. package/app/utils/scrollToError.js +59 -0
  198. package/app/utils/toPlainError.js +30 -0
  199. package/bin/osimart-theme.mjs +259 -0
  200. package/nuxt/osi-sections.ts +56 -0
  201. package/nuxt.config.ts +60 -0
  202. package/package.json +43 -3
  203. package/public/css/input.css +29 -0
  204. package/public/css/output.css +2716 -0
  205. package/public/favicon-32x32.png +0 -0
  206. 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
+ );