@vio-live/web-sdk 0.4.1 → 0.5.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.
@@ -15,16 +15,19 @@ var __decorateClass = (decorators, target, key, kind) => {
15
15
  var DEFAULT_URLS = {
16
16
  development: {
17
17
  apiBase: "https://api-dev.vio.live",
18
- graphQLBase: "https://graph-ql-dev.vio.live"
18
+ graphQLBase: "https://graph-ql-dev.vio.live",
19
+ eventsBase: "https://events-dev.vio.live"
19
20
  },
20
21
  // No separate staging GraphQL deployment yet — shares dev's.
21
22
  testing: {
22
23
  apiBase: "https://api-staging.vio.live",
23
- graphQLBase: "https://graph-ql-dev.vio.live"
24
+ graphQLBase: "https://graph-ql-dev.vio.live",
25
+ eventsBase: "https://events-staging.vio.live"
24
26
  },
25
27
  production: {
26
28
  apiBase: "https://api.vio.live",
27
- graphQLBase: "https://graph-ql.vio.live"
29
+ graphQLBase: "https://graph-ql.vio.live",
30
+ eventsBase: "https://events.vio.live"
28
31
  }
29
32
  };
30
33
  var ConfigurationSingleton = class {
@@ -43,6 +46,7 @@ var ConfigurationSingleton = class {
43
46
  environment: env,
44
47
  apiBase: input.apiBase ?? defaults.apiBase,
45
48
  graphQLBase: input.graphQLBase ?? defaults.graphQLBase,
49
+ eventsBase: input.eventsBase ?? defaults.eventsBase,
46
50
  stripePublishableKey: input.stripePublishableKey,
47
51
  klarnaClientId: input.klarnaClientId,
48
52
  // Sandbox by default — live Klarna must be opted into explicitly.
@@ -1512,6 +1516,13 @@ var CartManager = class extends EventTarget {
1512
1516
  * slow response can't overwrite the state of a newer one. */
1513
1517
  this.mutationSeq = /* @__PURE__ */ new Map();
1514
1518
  this.appliedSeq = /* @__PURE__ */ new Map();
1519
+ /** In-flight AddItem/UpdateItem/DeleteItem per sponsor. Callers that need
1520
+ * the backend cart to reflect a just-made change (e.g. fetching shippings
1521
+ * right after an add) should `await waitForCartMutations(sponsorId)` first
1522
+ * — otherwise they can race the mutation and see the cart from before it
1523
+ * landed (Alan, 2026-08-18: shippings queried before AddItem settled,
1524
+ * came back empty). */
1525
+ this.inFlightMutations = /* @__PURE__ */ new Map();
1515
1526
  /** Quantity intents for lines whose backend id hasn't arrived yet — replayed
1516
1527
  * when the AddItem response reconciles (keyed by product::variant). */
1517
1528
  this.pendingQtyIntents = /* @__PURE__ */ new Map();
@@ -1522,6 +1533,16 @@ var CartManager = class extends EventTarget {
1522
1533
  this.mutationSeq.set(sponsorId, n);
1523
1534
  return n;
1524
1535
  }
1536
+ /** Resolves once any in-flight AddItem/UpdateItem/DeleteItem for this
1537
+ * sponsor has settled (success or failure). No-op if nothing is pending. */
1538
+ async waitForCartMutations(sponsorId) {
1539
+ const p = this.inFlightMutations.get(sponsorId);
1540
+ if (!p) return;
1541
+ try {
1542
+ await p;
1543
+ } catch {
1544
+ }
1545
+ }
1525
1546
  /** Apply a backend response only if no newer response was applied already. */
1526
1547
  applyBackendCart(sponsorId, backendCart, seq) {
1527
1548
  if (seq < (this.appliedSeq.get(sponsorId) ?? 0)) return;
@@ -1735,7 +1756,7 @@ var CartManager = class extends EventTarget {
1735
1756
  const addedItem = existing ?? cart.items[cart.items.length - 1];
1736
1757
  const addedLineId = addedItem.id;
1737
1758
  const seq = this.nextSeq(opts.sponsorId);
1738
- void (async () => {
1759
+ const mutation = (async () => {
1739
1760
  try {
1740
1761
  const cartId = await this.ensureCartId(opts.sponsorId, opts.currency || void 0);
1741
1762
  if (cartId) {
@@ -1765,6 +1786,12 @@ var CartManager = class extends EventTarget {
1765
1786
  this.emitError("AddItem rejected by the backend \u2014 item removed from the cart", err);
1766
1787
  }
1767
1788
  })();
1789
+ this.inFlightMutations.set(opts.sponsorId, mutation);
1790
+ void mutation.finally(() => {
1791
+ if (this.inFlightMutations.get(opts.sponsorId) === mutation) {
1792
+ this.inFlightMutations.delete(opts.sponsorId);
1793
+ }
1794
+ });
1768
1795
  return addedItem;
1769
1796
  }
1770
1797
  intentKey(productId, variantId) {
@@ -1791,7 +1818,7 @@ var CartManager = class extends EventTarget {
1791
1818
  }
1792
1819
  const backendCartId = cart.cartId;
1793
1820
  const seq = this.nextSeq(sponsorId);
1794
- void (async () => {
1821
+ const mutation = (async () => {
1795
1822
  try {
1796
1823
  const gqlOpts = await getCartGraphQLOptions(sponsorId);
1797
1824
  const resCart = quantity <= 0 ? await deleteItem(backendCartId, targetItemId, gqlOpts) : await updateItem(backendCartId, targetItemId, null, quantity, gqlOpts);
@@ -1803,6 +1830,12 @@ var CartManager = class extends EventTarget {
1803
1830
  void this.fetchCartFromBackend(sponsorId);
1804
1831
  }
1805
1832
  })();
1833
+ this.inFlightMutations.set(sponsorId, mutation);
1834
+ void mutation.finally(() => {
1835
+ if (this.inFlightMutations.get(sponsorId) === mutation) {
1836
+ this.inFlightMutations.delete(sponsorId);
1837
+ }
1838
+ });
1806
1839
  }
1807
1840
  removeItem(lineItemId, sponsorId) {
1808
1841
  this.updateQuantity(lineItemId, sponsorId, 0);
@@ -2357,41 +2390,95 @@ function findPayNowCategory(categories) {
2357
2390
  const byId = (re) => categories.find((c) => c?.identifier && re.test(c.identifier));
2358
2391
  const byName = (re) => categories.find((c) => c?.name && re.test(c.name));
2359
2392
  const cat = categories.find((c) => c?.identifier === "pay_now") ?? byName(/betal\s*n[åa]|pay\s*now/i) ?? byId(/pay_now|direct_debit|direct_bank_transfer|pay_in_full|klarna_pay_now/i) ?? byName(/direkte|bank|kort|straks|sofort/i) ?? categories.find((c) => c?.identifier && !/pay_later|pay_over_time|slice_it/.test(c.identifier));
2393
+ console.log("findPayNowCategory - Selected: ", cat);
2360
2394
  return cat?.identifier ?? null;
2361
2395
  }
2362
2396
  async function createKlarnaPaymentsWidget(input) {
2363
2397
  const Klarna = await loadKpLib();
2398
+ console.log("[DEBUG Klarna] Initializing Klarna Payments");
2364
2399
  Klarna.Payments.init({ client_token: input.clientToken });
2400
+ console.log("[DEBUG Klarna] Klarna Payments initialized");
2401
+ Klarna.Payments.on?.("fullscreenOverlayShown", () => {
2402
+ console.log("[DEBUG Klarna] FULLSCREEN OVERLAY SHOWN");
2403
+ });
2404
+ Klarna.Payments.on?.("fullscreenOverlayHidden", () => {
2405
+ console.log("[DEBUG Klarna] FULLSCREEN OVERLAY HIDDEN");
2406
+ });
2365
2407
  let selected = input.category ?? findPayNowCategory(input.categories) ?? input.categories[0]?.identifier ?? "pay_now";
2408
+ if (typeof console !== "undefined") {
2409
+ console.log("[VioKlarna] Received payment_method_categories from backend:", input.categories);
2410
+ console.log("[VioKlarna] Selected initial payment_method_category:", selected);
2411
+ }
2412
+ const load = (category) => {
2413
+ if (category) selected = category;
2414
+ if (typeof console !== "undefined") {
2415
+ console.log(
2416
+ "[VioKlarna] Klarna.Payments.load calling with payment_method_category:",
2417
+ selected,
2418
+ "container:",
2419
+ input.container
2420
+ );
2421
+ }
2422
+ if (input.container && "innerHTML" in input.container) {
2423
+ input.container.innerHTML = "";
2424
+ }
2425
+ return new Promise((resolve, reject) => {
2426
+ try {
2427
+ Klarna.Payments.load(
2428
+ { container: input.container, payment_method_category: selected },
2429
+ {},
2430
+ (res) => {
2431
+ if (typeof console !== "undefined") {
2432
+ console.log(
2433
+ "[VioKlarna] Klarna.Payments.load response for payment_method_category:",
2434
+ selected,
2435
+ res
2436
+ );
2437
+ }
2438
+ if (res?.error) reject(res.error);
2439
+ else resolve({ showForm: res?.show_form !== false });
2440
+ }
2441
+ );
2442
+ } catch (err) {
2443
+ reject(err);
2444
+ }
2445
+ });
2446
+ };
2366
2447
  return {
2367
2448
  categories: input.categories,
2368
2449
  get selected() {
2369
2450
  return selected;
2370
2451
  },
2371
- load(category) {
2372
- if (category) selected = category;
2373
- return new Promise((resolve, reject) => {
2374
- try {
2375
- Klarna.Payments.load(
2376
- { container: input.container, payment_method_category: selected },
2377
- {},
2378
- (res) => {
2379
- if (res?.error) reject(res.error);
2380
- else resolve({ showForm: res?.show_form !== false });
2381
- }
2382
- );
2383
- } catch (err) {
2384
- reject(err);
2385
- }
2386
- });
2387
- },
2388
- authorize(data) {
2452
+ load,
2453
+ async authorize(category, data) {
2454
+ const cat = category || selected;
2455
+ if (typeof console !== "undefined") {
2456
+ console.log("[DEBUG Klarna] BEFORE AUTHORIZE", {
2457
+ argumentCategory: category,
2458
+ argumentData: data,
2459
+ selected,
2460
+ resolvedCategory: cat
2461
+ });
2462
+ }
2463
+ if (cat && cat !== selected) {
2464
+ await load(cat);
2465
+ }
2466
+ const finalData = { payment_method_category: cat, ...data ?? {} };
2467
+ if (typeof console !== "undefined") {
2468
+ console.log("[DEBUG Klarna] FINAL AUTHORIZE", { cat, selected, finalData });
2469
+ }
2389
2470
  return new Promise((resolve, reject) => {
2390
2471
  try {
2472
+ if (typeof console !== "undefined") {
2473
+ console.log("[DEBUG Klarna] ABOUT TO CALL AUTHORIZE");
2474
+ }
2391
2475
  Klarna.Payments.authorize(
2392
- { payment_method_category: selected, auto_finalize: true },
2393
- data ?? {},
2476
+ { payment_method_category: cat, auto_finalize: true },
2477
+ finalData,
2394
2478
  (res) => {
2479
+ if (typeof console !== "undefined") {
2480
+ console.log("[DEBUG Klarna] RAW AUTHORIZE RESPONSE:", JSON.stringify(res, null, 2));
2481
+ }
2395
2482
  if (res?.approved && res.authorization_token) {
2396
2483
  resolve(res.authorization_token);
2397
2484
  } else if (res?.error) {
@@ -2403,6 +2490,9 @@ async function createKlarnaPaymentsWidget(input) {
2403
2490
  }
2404
2491
  }
2405
2492
  );
2493
+ if (typeof console !== "undefined") {
2494
+ console.log("[DEBUG Klarna] AUTHORIZE CALL RETURNED");
2495
+ }
2406
2496
  } catch (err) {
2407
2497
  reject(err);
2408
2498
  }
@@ -2770,13 +2860,18 @@ var CheckoutManager = class _CheckoutManager extends EventTarget {
2770
2860
  }
2771
2861
  /**
2772
2862
  * Real shipping options from the backend cart (per supplier), mapped to the
2773
- * KlarnaShippingOption shape (minor units). Empty array on failure — callers
2774
- * fall back to the static KLARNA_SHIPPING_OPTIONS.
2863
+ * KlarnaShippingOption shape (minor units). Empty array on failure/no
2864
+ * options — Klarna callers use that as-is (no static fallback anymore, see
2865
+ * 2026-08-18: it only ever papered over a race, now fixed via
2866
+ * `waitForCartMutations`). `KLARNA_SHIPPING_OPTIONS` is still used
2867
+ * separately by Apple Pay's synchronous sheet setup, which can't await
2868
+ * this at all.
2775
2869
  */
2776
2870
  async fetchAvailableShippings(sponsorId) {
2777
2871
  try {
2778
2872
  const spId = sponsorId ?? this.state?.sponsorId;
2779
2873
  if (!spId) return [];
2874
+ await this.cartManager.waitForCartMutations(spId);
2780
2875
  const cart = this.cartManager.getCart(spId);
2781
2876
  let cartId = cart?.cartId;
2782
2877
  if (!cartId) {
@@ -3335,8 +3430,7 @@ var CheckoutManager = class _CheckoutManager extends EventTarget {
3335
3430
  async mountKlarnaPayments(container, opts) {
3336
3431
  if (!this.state) throw new Error("[VioCheckout] no active checkout \u2014 call open() first");
3337
3432
  const sponsorId = this.state.sponsorId;
3338
- const fetchedShippings = await this.fetchAvailableShippings(sponsorId);
3339
- const shippings = fetchedShippings.length > 0 ? fetchedShippings : KLARNA_SHIPPING_OPTIONS;
3433
+ const shippings = await this.fetchAvailableShippings(sponsorId);
3340
3434
  const { ctx } = opts?.withShipping ? this.buildKlarnaInstantContext(opts.shippingId, shippings) : { ctx: this.buildKlarnaPendingContext() };
3341
3435
  const selectedOpt = shippings.find((s) => s.id === opts?.shippingId) ?? shippings[0];
3342
3436
  if (selectedOpt?.id && selectedOpt?.supplierId) {
@@ -3396,6 +3490,12 @@ var CheckoutManager = class _CheckoutManager extends EventTarget {
3396
3490
  if (!nativeRes || !nativeRes.client_token) {
3397
3491
  throw new Error("Klarna session creation failed: missing client_token");
3398
3492
  }
3493
+ if (typeof console !== "undefined") {
3494
+ console.log(
3495
+ "[VioCheckout] CreatePaymentKlarnaNative payment_method_categories:",
3496
+ nativeRes.payment_method_categories
3497
+ );
3498
+ }
3399
3499
  if (nativeRes.checkout_id) {
3400
3500
  ctx.checkoutId = nativeRes.checkout_id;
3401
3501
  this.state = { ...this.state, checkoutId: nativeRes.checkout_id };
@@ -3421,9 +3521,15 @@ var CheckoutManager = class _CheckoutManager extends EventTarget {
3421
3521
  return widget.selected;
3422
3522
  },
3423
3523
  load: (category) => widget.load(category),
3424
- authorize: async () => {
3524
+ authorize: async (category) => {
3425
3525
  try {
3426
- const token = await widget.authorize();
3526
+ const addr = buildKlarnaAddress(this.state?.address);
3527
+ const customer = buildKlarnaCustomer(this.state?.address);
3528
+ const authData = {
3529
+ ...addr ? { billing_address: addr, shipping_address: addr } : {},
3530
+ ...customer ? { customer } : {}
3531
+ };
3532
+ const token = await widget.authorize(category, authData);
3427
3533
  await this.completeKlarnaOrder(token, ctx, { authorizationToken: token });
3428
3534
  } catch (err) {
3429
3535
  if (typeof console !== "undefined") {
@@ -3451,7 +3557,7 @@ var CheckoutManager = class _CheckoutManager extends EventTarget {
3451
3557
  /** Cart snapshot + chosen shipping line/amount, for express purchases. */
3452
3558
  buildKlarnaInstantContext(shippingId, availableShippings = []) {
3453
3559
  const base = this.buildKlarnaPendingContext();
3454
- const options = availableShippings.length > 0 ? availableShippings : KLARNA_SHIPPING_OPTIONS;
3560
+ const options = availableShippings;
3455
3561
  const shipping = (shippingId ? options.find((o) => o.id === shippingId) : void 0) ?? options.find((o) => o.preselected) ?? options[0];
3456
3562
  const orderLines = [...base.orderLines];
3457
3563
  if (shipping) {
@@ -3483,8 +3589,7 @@ var CheckoutManager = class _CheckoutManager extends EventTarget {
3483
3589
  async startKlarnaInstant() {
3484
3590
  if (!this.state) throw new Error("[VioCheckout] no active checkout \u2014 call open() first");
3485
3591
  const sponsorId = this.state.sponsorId;
3486
- const fetchedShippings = await this.fetchAvailableShippings(sponsorId);
3487
- const shippings = fetchedShippings.length > 0 ? fetchedShippings : KLARNA_SHIPPING_OPTIONS;
3592
+ const shippings = await this.fetchAvailableShippings(sponsorId);
3488
3593
  const { ctx } = this.buildKlarnaInstantContext(void 0, shippings);
3489
3594
  let clientToken;
3490
3595
  let categories;
@@ -3744,6 +3849,498 @@ function toSmallestUnit(amount, currency) {
3744
3849
  return Math.round(amount * 100);
3745
3850
  }
3746
3851
 
3852
+ // src/core/version.ts
3853
+ var SDK_VERSION = "0.5.0";
3854
+
3855
+ // src/core/analytics/analytics-manager.ts
3856
+ var SESSION_TTL_MS = 30 * 60 * 1e3;
3857
+ var FLUSH_INTERVAL_MS = 5e3;
3858
+ var FLUSH_AT_COUNT = 20;
3859
+ var MAX_QUEUE = 500;
3860
+ var RETRY_BACKOFF_MS = [2e3, 4e3, 8e3];
3861
+ var ANON_KEY = "vio.anon.v1";
3862
+ var SESSION_KEY = "vio.session.v1";
3863
+ var IMPRESSION_THRESHOLD = 0.5;
3864
+ var IMPRESSION_DWELL_MS = 1e3;
3865
+ var AnalyticsManager = class extends EventTarget {
3866
+ constructor() {
3867
+ super(...arguments);
3868
+ this.started = false;
3869
+ this.options = { host: "custom", collector: true, dataLayer: false, debug: false, autoTrack: true };
3870
+ this.queue = [];
3871
+ this.flushTimer = null;
3872
+ this.retryAttempt = 0;
3873
+ this.retryNotBefore = 0;
3874
+ this.sessionId = null;
3875
+ this.sessionStartedAt = 0;
3876
+ /** Impression once-per-(session, component) guard; reset on session rotation. */
3877
+ this.impressedComponents = /* @__PURE__ */ new Set();
3878
+ /** Last NON-EMPTY cart snapshot — `purchase` uses it because several flows
3879
+ * (Apple Pay) clear the cart BEFORE `vio:payment-success` fires. */
3880
+ this.lastCartSnapshot = null;
3881
+ this.teardown = [];
3882
+ }
3883
+ // MARK: - Public API
3884
+ /**
3885
+ * Install listeners + begin flushing. Explicit on purpose (no module
3886
+ * side-effects). Safe to call twice (second call only updates options).
3887
+ */
3888
+ start(options = {}) {
3889
+ this.options = {
3890
+ host: options.host ?? this.options.host,
3891
+ collector: options.collector ?? this.options.collector,
3892
+ dataLayer: options.dataLayer ?? this.options.dataLayer,
3893
+ debug: options.debug ?? this.options.debug,
3894
+ autoTrack: options.autoTrack ?? this.options.autoTrack
3895
+ };
3896
+ if (this.started || !hasDom()) return;
3897
+ this.started = true;
3898
+ this.ensureSession();
3899
+ this.flushTimer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
3900
+ const onPageHide = () => {
3901
+ this.trackSessionEnd();
3902
+ this.flushWithBeacon();
3903
+ };
3904
+ window.addEventListener("pagehide", onPageHide);
3905
+ this.teardown.push(() => window.removeEventListener("pagehide", onPageHide));
3906
+ if (this.options.autoTrack) this.installAutoListeners();
3907
+ }
3908
+ /** Stop timers + listeners (tests / SPA unmount). Queue survives. */
3909
+ stop() {
3910
+ if (this.flushTimer) clearInterval(this.flushTimer);
3911
+ this.flushTimer = null;
3912
+ for (const fn of this.teardown.splice(0)) fn();
3913
+ this.started = false;
3914
+ }
3915
+ /** Attach the partner's user id (opaque) to all subsequent events. */
3916
+ identify(externalUserId) {
3917
+ this.externalUserId = externalUserId ?? void 0;
3918
+ }
3919
+ /** Queue one event. Works pre-init and pre-start (SSR: no-op). */
3920
+ track(name, opts = {}) {
3921
+ if (!hasDom()) return;
3922
+ const event = this.buildEvent(name, opts);
3923
+ if (!event) return;
3924
+ if (this.options.debug) {
3925
+ console.debug("[Vio.analytics]", event.name, event);
3926
+ }
3927
+ try {
3928
+ window.dispatchEvent(new CustomEvent("vio:analytics", { detail: event }));
3929
+ } catch {
3930
+ }
3931
+ if (this.options.dataLayer) pushGa4(event);
3932
+ this.queue.push(event);
3933
+ if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE);
3934
+ if (this.queue.length >= FLUSH_AT_COUNT) void this.flush();
3935
+ }
3936
+ /**
3937
+ * Impression helper: observes an element and tracks ONE
3938
+ * `component_impression` per (session, campaignComponentId) when the
3939
+ * contract's rule is met (≥50% visible, ≥1s dwell). Returns an
3940
+ * unobserve function.
3941
+ */
3942
+ observeImpression(element, context = {}, opts = {}) {
3943
+ if (!hasDom() || typeof IntersectionObserver === "undefined") return () => {
3944
+ };
3945
+ const name = opts.name ?? "component_impression";
3946
+ const dedupe = opts.key ?? String(
3947
+ context.campaignComponentId ?? context.locationId ?? context.componentTemplateId ?? "component"
3948
+ );
3949
+ let dwellTimer = null;
3950
+ const observer = new IntersectionObserver(
3951
+ (entries) => {
3952
+ for (const entry of entries) {
3953
+ if (entry.isIntersecting && entry.intersectionRatio >= IMPRESSION_THRESHOLD) {
3954
+ if (dwellTimer) continue;
3955
+ dwellTimer = setTimeout(() => {
3956
+ const key = `${name}:${dedupe}`;
3957
+ this.ensureSession();
3958
+ if (this.impressedComponents.has(key)) return;
3959
+ this.impressedComponents.add(key);
3960
+ this.track(name, { context });
3961
+ opts.onImpression?.();
3962
+ observer.disconnect();
3963
+ }, IMPRESSION_DWELL_MS);
3964
+ } else if (dwellTimer) {
3965
+ clearTimeout(dwellTimer);
3966
+ dwellTimer = null;
3967
+ }
3968
+ }
3969
+ },
3970
+ { threshold: [IMPRESSION_THRESHOLD] }
3971
+ );
3972
+ observer.observe(element);
3973
+ return () => {
3974
+ if (dwellTimer) clearTimeout(dwellTimer);
3975
+ observer.disconnect();
3976
+ };
3977
+ }
3978
+ /** Force a flush (mostly for tests). */
3979
+ async flush() {
3980
+ if (!this.options.collector) {
3981
+ this.queue = [];
3982
+ return;
3983
+ }
3984
+ if (this.queue.length === 0) return;
3985
+ if (!Configuration.isInitialized) return;
3986
+ if (Date.now() < this.retryNotBefore) return;
3987
+ const batch = this.queue.splice(0, MAX_QUEUE);
3988
+ const body = JSON.stringify({
3989
+ apiKey: Configuration.get().apiKey,
3990
+ sent_at: (/* @__PURE__ */ new Date()).toISOString(),
3991
+ events: batch
3992
+ });
3993
+ try {
3994
+ const res = await fetch(`${eventsBase()}/v1/events`, {
3995
+ method: "POST",
3996
+ headers: { "Content-Type": "application/json" },
3997
+ body,
3998
+ keepalive: batch.length <= 20
3999
+ // keepalive caps payload at ~64KB
4000
+ });
4001
+ if (res.status === 401 || res.status === 400) {
4002
+ console.warn(`[Vio.analytics] collector rejected batch (HTTP ${res.status}) \u2014 dropped`);
4003
+ this.retryAttempt = 0;
4004
+ return;
4005
+ }
4006
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
4007
+ this.retryAttempt = 0;
4008
+ } catch {
4009
+ this.queue.unshift(...batch);
4010
+ const backoff = RETRY_BACKOFF_MS[Math.min(this.retryAttempt, RETRY_BACKOFF_MS.length - 1)] ?? 8e3;
4011
+ this.retryAttempt++;
4012
+ this.retryNotBefore = Date.now() + backoff;
4013
+ }
4014
+ }
4015
+ // MARK: - Internals
4016
+ buildEvent(name, opts) {
4017
+ this.ensureSession();
4018
+ if (!this.sessionId) return null;
4019
+ const anonId = this.anonId();
4020
+ if (!anonId) return null;
4021
+ const boot = vioBootstrapCache();
4022
+ const ctx = opts.context ?? {};
4023
+ const context = compact({
4024
+ campaign_id: ctx.campaignId ?? boot?.campaignId,
4025
+ broadcast_id: ctx.broadcastId,
4026
+ campaign_component_id: ctx.campaignComponentId,
4027
+ app_placement_id: ctx.appPlacementId,
4028
+ location_id: ctx.locationId,
4029
+ component_template_id: ctx.componentTemplateId,
4030
+ sponsor_id: ctx.sponsorId,
4031
+ activation_id: ctx.activationId,
4032
+ content_url: ctx.contentUrl ?? (typeof location !== "undefined" ? location.href : void 0),
4033
+ variant: ctx.variant
4034
+ });
4035
+ const com = opts.commerce;
4036
+ const commerce = com ? compact({
4037
+ items: com.items?.map(
4038
+ (i) => compact({
4039
+ product_id: String(i.productId),
4040
+ name: i.name,
4041
+ brand: i.brand,
4042
+ variant_id: i.variantId !== void 0 ? String(i.variantId) : void 0,
4043
+ price: i.price,
4044
+ quantity: i.quantity
4045
+ })
4046
+ ),
4047
+ value: com.value,
4048
+ currency: com.currency,
4049
+ order_id: com.orderId,
4050
+ payment_method: com.paymentMethod
4051
+ }) : void 0;
4052
+ return {
4053
+ event_id: uuid(),
4054
+ name,
4055
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
4056
+ surface: "web",
4057
+ host: this.options.host,
4058
+ sdk_version: SDK_VERSION,
4059
+ session_id: this.sessionId,
4060
+ anon_id: anonId,
4061
+ ...this.externalUserId ? { external_user_id: this.externalUserId } : {},
4062
+ ...Object.keys(context).length ? { context } : {},
4063
+ ...commerce ? { commerce } : {},
4064
+ ...opts.props ? { props: opts.props } : {}
4065
+ };
4066
+ }
4067
+ anonId() {
4068
+ const stored = safeStorageGet(ANON_KEY);
4069
+ if (stored) return stored;
4070
+ const id = `a-${uuid()}`;
4071
+ safeStorageSet(ANON_KEY, id);
4072
+ return safeStorageGet(ANON_KEY) ?? id;
4073
+ }
4074
+ /** Rolling session: renewed by activity, rotated after 30 min idle. */
4075
+ ensureSession() {
4076
+ const now = Date.now();
4077
+ if (!this.sessionId) {
4078
+ const stored = safeStorageGet(SESSION_KEY);
4079
+ if (stored) {
4080
+ try {
4081
+ const parsed = JSON.parse(stored);
4082
+ if (now - parsed.ts < SESSION_TTL_MS) {
4083
+ this.sessionId = parsed.id;
4084
+ this.sessionStartedAt = parsed.startedAt ?? now;
4085
+ }
4086
+ } catch {
4087
+ }
4088
+ }
4089
+ } else {
4090
+ const stored = safeStorageGet(SESSION_KEY);
4091
+ if (stored) {
4092
+ try {
4093
+ if (now - JSON.parse(stored).ts >= SESSION_TTL_MS) {
4094
+ this.sessionId = null;
4095
+ }
4096
+ } catch {
4097
+ this.sessionId = null;
4098
+ }
4099
+ }
4100
+ }
4101
+ if (!this.sessionId) {
4102
+ this.sessionId = `s-${uuid()}`;
4103
+ this.sessionStartedAt = now;
4104
+ this.impressedComponents.clear();
4105
+ safeStorageSet(
4106
+ SESSION_KEY,
4107
+ JSON.stringify({ id: this.sessionId, ts: now, startedAt: now })
4108
+ );
4109
+ this.track("session_start");
4110
+ } else {
4111
+ safeStorageSet(
4112
+ SESSION_KEY,
4113
+ JSON.stringify({ id: this.sessionId, ts: now, startedAt: this.sessionStartedAt })
4114
+ );
4115
+ }
4116
+ }
4117
+ trackSessionEnd() {
4118
+ if (!this.sessionId) return;
4119
+ this.track("session_end", {
4120
+ props: { duration_ms: Date.now() - this.sessionStartedAt }
4121
+ });
4122
+ }
4123
+ flushWithBeacon() {
4124
+ if (!this.options.collector || this.queue.length === 0) return;
4125
+ if (!Configuration.isInitialized) return;
4126
+ const batch = this.queue.splice(0, MAX_QUEUE);
4127
+ const body = JSON.stringify({
4128
+ apiKey: Configuration.get().apiKey,
4129
+ sent_at: (/* @__PURE__ */ new Date()).toISOString(),
4130
+ events: batch
4131
+ });
4132
+ const ok = typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" && navigator.sendBeacon(`${eventsBase()}/v1/events`, new Blob([body], { type: "text/plain" }));
4133
+ if (!ok) this.queue.unshift(...batch);
4134
+ }
4135
+ /**
4136
+ * Auto-instrumentation over the SDK's own DOM events — the host app
4137
+ * writes ZERO tracking code for the commerce funnel.
4138
+ */
4139
+ installAutoListeners() {
4140
+ const on = (type, handler) => {
4141
+ window.addEventListener(type, handler);
4142
+ this.teardown.push(() => window.removeEventListener(type, handler));
4143
+ };
4144
+ const cart = vioCart();
4145
+ if (cart) {
4146
+ const onChange = (ev) => {
4147
+ const detail = ev.detail;
4148
+ if (!detail) return;
4149
+ const items = [];
4150
+ detail.cartsBySponsor?.forEach((state) => items.push(...state.items));
4151
+ if (items.length > 0) {
4152
+ this.lastCartSnapshot = { items: items.map((i) => ({ ...i })), currency: items[0]?.currency };
4153
+ }
4154
+ };
4155
+ cart.addEventListener("change", onChange);
4156
+ this.teardown.push(() => cart.removeEventListener("change", onChange));
4157
+ }
4158
+ on("vio:added-to-cart", (ev) => {
4159
+ const d = ev.detail ?? {};
4160
+ const line = this.findLine(d.productId, d.variantId);
4161
+ this.track("add_to_cart", {
4162
+ context: { sponsorId: d.sponsorId !== void 0 ? Number(d.sponsorId) : void 0 },
4163
+ commerce: {
4164
+ items: [
4165
+ {
4166
+ productId: d.productId ?? line?.productId ?? "",
4167
+ name: line?.name,
4168
+ brand: line?.brand,
4169
+ variantId: d.variantId ?? line?.variantId,
4170
+ price: line?.unitPrice,
4171
+ quantity: d.quantity ?? 1
4172
+ }
4173
+ ],
4174
+ value: line ? line.unitPrice * (d.quantity ?? 1) : void 0,
4175
+ currency: line?.currency
4176
+ }
4177
+ });
4178
+ });
4179
+ on("vio:product-click", (ev) => {
4180
+ const d = ev.detail ?? {};
4181
+ if (d.productId === void 0) return;
4182
+ const price = Number(d.price);
4183
+ this.track("select_item", {
4184
+ context: { sponsorId: d.sponsorId !== void 0 ? Number(d.sponsorId) : void 0 },
4185
+ commerce: {
4186
+ items: [
4187
+ {
4188
+ productId: d.productId,
4189
+ name: d.name,
4190
+ brand: d.brand,
4191
+ price: Number.isFinite(price) ? price : void 0
4192
+ }
4193
+ ]
4194
+ }
4195
+ });
4196
+ });
4197
+ on("vio:open-cart", () => {
4198
+ const snap = this.cartCommerce();
4199
+ this.track("view_cart", snap ? { commerce: snap } : {});
4200
+ });
4201
+ on("vio:checkout-open", (ev) => {
4202
+ const d = ev.detail ?? {};
4203
+ const snap = this.cartCommerce(d.sponsorId);
4204
+ this.track("begin_checkout", {
4205
+ context: { sponsorId: d.sponsorId },
4206
+ ...snap ? { commerce: snap } : {}
4207
+ });
4208
+ });
4209
+ on("vio:payment-success", (ev) => {
4210
+ const d = ev.detail ?? {};
4211
+ const sponsorId = d.sponsorId !== void 0 ? Number(d.sponsorId) : void 0;
4212
+ const snap = this.cartCommerce(sponsorId);
4213
+ this.track("purchase", {
4214
+ context: { sponsorId },
4215
+ commerce: {
4216
+ ...snap ?? {},
4217
+ orderId: d.orderId,
4218
+ paymentMethod: d.method
4219
+ }
4220
+ });
4221
+ });
4222
+ }
4223
+ /** Current cart → commerce payload; falls back to the pre-clear snapshot. */
4224
+ cartCommerce(sponsorId) {
4225
+ let items = [];
4226
+ const cart = vioCart();
4227
+ if (cart) {
4228
+ cart.getAllCarts().forEach((state, id) => {
4229
+ if (sponsorId === void 0 || id === sponsorId) items.push(...state.items);
4230
+ });
4231
+ }
4232
+ if (items.length === 0 && this.lastCartSnapshot) {
4233
+ items = this.lastCartSnapshot.items.filter(
4234
+ (i) => sponsorId === void 0 || i.sponsorId === sponsorId
4235
+ );
4236
+ }
4237
+ if (items.length === 0) return null;
4238
+ return {
4239
+ items: items.map((i) => ({
4240
+ productId: i.productId,
4241
+ name: i.name,
4242
+ brand: i.brand,
4243
+ variantId: i.variantId,
4244
+ price: i.unitPrice,
4245
+ quantity: i.quantity
4246
+ })),
4247
+ value: items.reduce((sum, i) => sum + i.unitPrice * (i.quantity ?? 1), 0),
4248
+ currency: items[0]?.currency ?? this.lastCartSnapshot?.currency
4249
+ };
4250
+ }
4251
+ findLine(productId, variantId) {
4252
+ const pool = [];
4253
+ vioCart()?.getAllCarts().forEach((state) => pool.push(...state.items));
4254
+ if (pool.length === 0 && this.lastCartSnapshot) pool.push(...this.lastCartSnapshot.items);
4255
+ return pool.find(
4256
+ (i) => String(i.productId) === String(productId) && (variantId === void 0 || String(i.variantId) === String(variantId))
4257
+ );
4258
+ }
4259
+ };
4260
+ function hasDom() {
4261
+ return typeof window !== "undefined";
4262
+ }
4263
+ function uuid() {
4264
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
4265
+ return crypto.randomUUID();
4266
+ }
4267
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
4268
+ const r = Math.random() * 16 | 0;
4269
+ return (c === "x" ? r : r & 3 | 8).toString(16);
4270
+ });
4271
+ }
4272
+ function safeStorageGet(key) {
4273
+ try {
4274
+ return window.localStorage.getItem(key);
4275
+ } catch {
4276
+ return null;
4277
+ }
4278
+ }
4279
+ function safeStorageSet(key, value) {
4280
+ try {
4281
+ window.localStorage.setItem(key, value);
4282
+ } catch {
4283
+ }
4284
+ }
4285
+ function compact(obj) {
4286
+ const out = {};
4287
+ for (const [k, v] of Object.entries(obj)) {
4288
+ if (v !== void 0 && v !== null && v !== "") out[k] = v;
4289
+ }
4290
+ return out;
4291
+ }
4292
+ function eventsBase() {
4293
+ return Configuration.get().eventsBase;
4294
+ }
4295
+ function vioFacade() {
4296
+ const g = globalThis;
4297
+ return g.__VIO_FACADE__ ?? null;
4298
+ }
4299
+ function vioBootstrapCache() {
4300
+ return vioFacade()?.bootstrapCache ?? null;
4301
+ }
4302
+ function vioCart() {
4303
+ try {
4304
+ return vioFacade()?.cart ?? null;
4305
+ } catch {
4306
+ return null;
4307
+ }
4308
+ }
4309
+ function pushGa4(event) {
4310
+ const commerceNames = /* @__PURE__ */ new Set([
4311
+ "view_item_list",
4312
+ "select_item",
4313
+ "view_item",
4314
+ "add_to_cart",
4315
+ "view_cart",
4316
+ "begin_checkout",
4317
+ "purchase"
4318
+ ]);
4319
+ if (!commerceNames.has(event.name)) return;
4320
+ const w = window;
4321
+ w.dataLayer = w.dataLayer || [];
4322
+ const commerce = event.commerce ?? {};
4323
+ w.dataLayer.push({ ecommerce: null });
4324
+ w.dataLayer.push({
4325
+ event: event.name,
4326
+ ecommerce: compact({
4327
+ currency: commerce.currency,
4328
+ value: commerce.value,
4329
+ transaction_id: commerce.order_id,
4330
+ items: commerce.items?.map(
4331
+ (i) => compact({
4332
+ item_id: i.product_id,
4333
+ item_name: i.name,
4334
+ item_brand: i.brand,
4335
+ item_variant: i.variant_id,
4336
+ price: i.price,
4337
+ quantity: i.quantity
4338
+ })
4339
+ )
4340
+ })
4341
+ });
4342
+ }
4343
+
3747
4344
  // src/core/client.ts
3748
4345
  var VioFacade = class {
3749
4346
  constructor() {
@@ -3758,6 +4355,7 @@ var VioFacade = class {
3758
4355
  // need Vio.init() to work (state is purely local).
3759
4356
  this.cartManager = null;
3760
4357
  this.checkoutManager = null;
4358
+ this.analyticsManager = null;
3761
4359
  }
3762
4360
  init(config) {
3763
4361
  Configuration.init(config);
@@ -3785,6 +4383,14 @@ var VioFacade = class {
3785
4383
  if (!this.checkoutManager) this.checkoutManager = new CheckoutManager(this.cart);
3786
4384
  return this.checkoutManager;
3787
4385
  }
4386
+ /**
4387
+ * Analytics — track() queues even pre-init; nothing is sent (and no
4388
+ * listeners are installed) until an explicit `Vio.analytics.start()`.
4389
+ */
4390
+ get analytics() {
4391
+ if (!this.analyticsManager) this.analyticsManager = new AnalyticsManager();
4392
+ return this.analyticsManager;
4393
+ }
3788
4394
  // MARK: - Backend-bound
3789
4395
  /** Lazy VioApi for REST endpoints on `api-dev.vio.live`. */
3790
4396
  get api() {
@@ -3854,6 +4460,6 @@ var VioFacade = class {
3854
4460
  var _vioGlobal = globalThis;
3855
4461
  var Vio = _vioGlobal.__VIO_FACADE__ ?? (_vioGlobal.__VIO_FACADE__ = new VioFacade());
3856
4462
 
3857
- export { ADD_ITEM_MUTATION, CREATE_CART_MUTATION, CREATE_CHECKOUT_MUTATION, CREATE_PAYMENT_STRIPE_MUTATION, CREATE_PAYMENT_VIPPS_MUTATION, CartManager, CheckoutManager, CommerceClient, Configuration, DELETE_ITEM_MUTATION, GET_AVAILABLE_PAYMENT_METHODS_QUERY, GET_CART_QUERY, GET_CHECKOUT_QUERY, GET_LINE_ITEMS_BY_SUPPLIER_QUERY, GET_VIPPS_STATUS_QUERY, KLARNA_SHIPPING_OPTIONS, UPDATE_CHECKOUT_MUTATION, UPDATE_ITEM_MUTATION, UPDATE_SHIPPINGS_BY_SUPPLIER_MUTATION, Vio, VioApi, VioApiError, __decorateClass, addItem, checkApplePayAvailability, createCart, createCheckout, createCommerceClient, createPaymentStripe, createPaymentVipps, createVioApi, deleteItem, displayPrice, executeCartGraphQL, formatPrice, getAvailablePaymentMethods, getCart, getCartGraphQLOptions, getCheckout, getCustomerSessionId, getGlobalCurrency, getLineItemsBySupplier, getVippsStatus, primaryImageUrl, showApplePaySheet, updateCheckout, updateItem, updateShippingsBySupplier };
3858
- //# sourceMappingURL=chunk-NP2KCM7X.js.map
3859
- //# sourceMappingURL=chunk-NP2KCM7X.js.map
4463
+ export { ADD_ITEM_MUTATION, AnalyticsManager, CREATE_CART_MUTATION, CREATE_CHECKOUT_MUTATION, CREATE_PAYMENT_STRIPE_MUTATION, CREATE_PAYMENT_VIPPS_MUTATION, CartManager, CheckoutManager, CommerceClient, Configuration, DELETE_ITEM_MUTATION, GET_AVAILABLE_PAYMENT_METHODS_QUERY, GET_CART_QUERY, GET_CHECKOUT_QUERY, GET_LINE_ITEMS_BY_SUPPLIER_QUERY, GET_VIPPS_STATUS_QUERY, SDK_VERSION, UPDATE_CHECKOUT_MUTATION, UPDATE_ITEM_MUTATION, UPDATE_SHIPPINGS_BY_SUPPLIER_MUTATION, Vio, VioApi, VioApiError, __decorateClass, addItem, checkApplePayAvailability, createCart, createCheckout, createCommerceClient, createPaymentStripe, createPaymentVipps, createVioApi, deleteItem, displayPrice, executeCartGraphQL, formatPrice, getAvailablePaymentMethods, getCart, getCartGraphQLOptions, getCheckout, getCustomerSessionId, getGlobalCurrency, getLineItemsBySupplier, getVippsStatus, primaryImageUrl, showApplePaySheet, updateCheckout, updateItem, updateShippingsBySupplier };
4464
+ //# sourceMappingURL=chunk-HNQG56SD.js.map
4465
+ //# sourceMappingURL=chunk-HNQG56SD.js.map