@vio-live/web-sdk 0.21.0 → 0.23.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.
@@ -46,6 +46,9 @@ var ConfigurationSingleton = class {
46
46
  this.resolved = {
47
47
  apiKey: input.apiKey,
48
48
  environment: env,
49
+ platform: input.platform ?? "web",
50
+ contentId: input.contentId,
51
+ contentTitle: input.contentTitle,
49
52
  apiBase: input.apiBase ?? defaults.apiBase,
50
53
  graphQLBase: input.graphQLBase ?? defaults.graphQLBase,
51
54
  eventsBase: input.eventsBase ?? defaults.eventsBase,
@@ -58,6 +61,7 @@ var ConfigurationSingleton = class {
58
61
  const masked = input.apiKey.length > 4 ? "*".repeat(Math.max(0, input.apiKey.length - 4)) + input.apiKey.slice(-4) : "****";
59
62
  console.info("[Vio] Initialized", {
60
63
  environment: env,
64
+ platform: this.resolved.platform,
61
65
  apiBase: this.resolved.apiBase,
62
66
  apiKey: masked,
63
67
  stripeConfigured: !!input.stripePublishableKey,
@@ -118,9 +122,21 @@ var VioApi = class {
118
122
  /**
119
123
  * Bootstrap — fetches client app config (sponsors with commerce keys,
120
124
  * default market, campaign id). Called once at SDK init.
125
+ *
126
+ * With `platform`, `campaigns[].slots[]` only lists the assignments of that
127
+ * platform (plus the ones with no platform) — see ADR-0027.
121
128
  */
122
- async bootstrap() {
123
- return this.get("/v2/mobile/config");
129
+ async bootstrap(platform) {
130
+ const query = platform ? `?platform=${encodeURIComponent(platform)}` : "";
131
+ return this.get(`/v2/mobile/config${query}`);
132
+ }
133
+ /**
134
+ * Declare the slots of this page to the campaigns service
135
+ * (`POST /v2/mobile/components/manifest`, ADR-0034). The web SDK always
136
+ * sends `mode: 'merge'`; see `Vio.declareSlots()` for the dedupe on top.
137
+ */
138
+ async declareManifest(body) {
139
+ return this.post("/v2/mobile/components/manifest", body);
124
140
  }
125
141
  /**
126
142
  * Get placement components for a campaign. Used by the carousel auto-fetch
@@ -146,6 +162,22 @@ var VioApi = class {
146
162
  }
147
163
  return await res.json();
148
164
  }
165
+ async post(path, body) {
166
+ const url = `${this.apiBase}${path}`;
167
+ const res = await fetch(url, {
168
+ method: "POST",
169
+ headers: {
170
+ "X-API-Key": this.apiKey,
171
+ Accept: "application/json",
172
+ "Content-Type": "application/json"
173
+ },
174
+ body: JSON.stringify(body)
175
+ });
176
+ if (!res.ok) {
177
+ throw new VioApiError(res.status, await safeText(res), `POST ${path}`);
178
+ }
179
+ return await res.json();
180
+ }
149
181
  };
150
182
  var VioApiError = class extends Error {
151
183
  constructor(status, body, endpoint) {
@@ -1385,23 +1417,40 @@ function getCustomerSessionId() {
1385
1417
  }
1386
1418
  return id;
1387
1419
  }
1420
+ var CART_GRAPHQL_TIMEOUT_MS = 15e3;
1388
1421
  async function executeCartGraphQL(query, variables, options = {}) {
1389
1422
  const resolvedOptions = typeof options === "number" ? await getCartGraphQLOptions(options) : !options.apiKey && !options.commerceKey ? { ...await getCartGraphQLOptions(), ...options } : options;
1390
1423
  const { endpoint = "https://graph-ql-dev.vio.live", apiKey, commerceKey } = resolvedOptions;
1391
1424
  const headers = { "Content-Type": "application/json" };
1392
1425
  if (apiKey) headers["X-API-Key"] = apiKey;
1393
1426
  if (commerceKey || apiKey) headers["Authorization"] = commerceKey || apiKey;
1394
- const res = await fetch(endpoint, {
1395
- method: "POST",
1396
- headers,
1397
- body: JSON.stringify({ query, variables })
1427
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
1428
+ let timer;
1429
+ const timedOut = new Promise((_, reject) => {
1430
+ timer = setTimeout(() => {
1431
+ controller?.abort();
1432
+ reject(new Error(`GraphQL request timed out after ${CART_GRAPHQL_TIMEOUT_MS}ms`));
1433
+ }, CART_GRAPHQL_TIMEOUT_MS);
1398
1434
  });
1399
- const json = await res.json().catch(() => ({}));
1400
- if (!res.ok || json.errors) {
1401
- const errMsg = json.errors?.[0]?.message || `GraphQL request failed (HTTP ${res.status})`;
1402
- throw new Error(errMsg);
1435
+ try {
1436
+ const res = await Promise.race([
1437
+ fetch(endpoint, {
1438
+ method: "POST",
1439
+ headers,
1440
+ body: JSON.stringify({ query, variables }),
1441
+ signal: controller?.signal
1442
+ }),
1443
+ timedOut
1444
+ ]);
1445
+ const json = await Promise.race([res.json().catch(() => ({})), timedOut]);
1446
+ if (!res.ok || json.errors) {
1447
+ const errMsg = json.errors?.[0]?.message || `GraphQL request failed (HTTP ${res.status})`;
1448
+ throw new Error(errMsg);
1449
+ }
1450
+ return json;
1451
+ } finally {
1452
+ clearTimeout(timer);
1403
1453
  }
1404
- return json;
1405
1454
  }
1406
1455
  async function createCart(customerSessionId, currency, shippingCountry, options) {
1407
1456
  const json = await executeCartGraphQL(
@@ -1581,6 +1630,14 @@ var CartManager = class extends EventTarget {
1581
1630
  * landed (Alan, 2026-08-18: shippings queried before AddItem settled,
1582
1631
  * came back empty). */
1583
1632
  this.inFlightMutations = /* @__PURE__ */ new Map();
1633
+ /** Per-line quantity sync with the backend: at most ONE UpdateItem /
1634
+ * DeleteItem in flight per line. Targets that arrive while one is in flight
1635
+ * are coalesced into `pending` (only the latest survives) and sent when the
1636
+ * current request settles. Keyed by `${sponsorId}:${cartItemId}`.
1637
+ * (Alan, 2026-10-15: a burst of +/− clicks fired one UpdateItem per click
1638
+ * in parallel; each response re-applied the whole backend cart, so an
1639
+ * older one landing after a newer optimistic change rolled the line back.) */
1640
+ this.qtySyncs = /* @__PURE__ */ new Map();
1584
1641
  /** Quantity intents for lines whose backend id hasn't arrived yet — replayed
1585
1642
  * when the AddItem response reconciles (keyed by product::variant). */
1586
1643
  this.pendingQtyIntents = /* @__PURE__ */ new Map();
@@ -1591,21 +1648,75 @@ var CartManager = class extends EventTarget {
1591
1648
  this.mutationSeq.set(sponsorId, n);
1592
1649
  return n;
1593
1650
  }
1594
- /** Resolves once any in-flight AddItem/UpdateItem/DeleteItem for this
1595
- * sponsor has settled (success or failure). No-op if nothing is pending. */
1651
+ /** Resolves once every in-flight AddItem/UpdateItem/DeleteItem for this
1652
+ * sponsor has settled (success or failure), including ones started while
1653
+ * waiting (a quantity intent replayed by an AddItem reconcile). No-op if
1654
+ * nothing is pending. */
1596
1655
  async waitForCartMutations(sponsorId) {
1597
- const p = this.inFlightMutations.get(sponsorId);
1598
- if (!p) return;
1599
- try {
1600
- await p;
1601
- } catch {
1602
- }
1656
+ for (; ; ) {
1657
+ const set = this.inFlightMutations.get(sponsorId);
1658
+ if (!set || set.size === 0) return;
1659
+ await Promise.allSettled([...set]);
1660
+ }
1661
+ }
1662
+ /** Register a backend mutation so `waitForCartMutations` can await it. */
1663
+ trackMutation(sponsorId, mutation) {
1664
+ let set = this.inFlightMutations.get(sponsorId);
1665
+ if (!set) {
1666
+ set = /* @__PURE__ */ new Set();
1667
+ this.inFlightMutations.set(sponsorId, set);
1668
+ }
1669
+ const tracked = set;
1670
+ tracked.add(mutation);
1671
+ const untrack = () => {
1672
+ tracked.delete(mutation);
1673
+ if (tracked.size === 0 && this.inFlightMutations.get(sponsorId) === tracked) {
1674
+ this.inFlightMutations.delete(sponsorId);
1675
+ }
1676
+ };
1677
+ mutation.then(untrack, untrack);
1603
1678
  }
1604
- /** Apply a backend response only if no newer response was applied already. */
1679
+ qtySyncKey(sponsorId, cartItemId) {
1680
+ return `${sponsorId}:${cartItemId}`;
1681
+ }
1682
+ /** The quantity a line is heading to (pending beats in flight), or
1683
+ * undefined when no quantity sync is active for it. */
1684
+ intendedQuantity(sponsorId, cartItemId) {
1685
+ const sync = this.qtySyncs.get(this.qtySyncKey(sponsorId, cartItemId));
1686
+ return sync ? sync.pending ?? sync.sent : void 0;
1687
+ }
1688
+ /** Apply a backend response only if no newer response was applied already.
1689
+ * Returns false when it was discarded as stale. */
1605
1690
  applyBackendCart(sponsorId, backendCart, seq) {
1606
- if (seq < (this.appliedSeq.get(sponsorId) ?? 0)) return;
1691
+ if (seq < (this.appliedSeq.get(sponsorId) ?? 0)) return false;
1607
1692
  this.appliedSeq.set(sponsorId, seq);
1608
1693
  this.updateFromBackendCart(sponsorId, backendCart);
1694
+ return true;
1695
+ }
1696
+ /** The final answer of a line's sync lost the per-sponsor seq race (another
1697
+ * line answered later): the cart as a whole is stale, but for THIS line it
1698
+ * is the latest word. Copy its quantity (and price) so a server-side clamp
1699
+ * — stock — doesn't leave the optimistic value behind for good, with the
1700
+ * checkout / Apple Pay total built on it. */
1701
+ reconcileLineFromResponse(sponsorId, cartItemId, resCart) {
1702
+ const cart = this.cartsBySponsor.get(sponsorId);
1703
+ const line = cart?.items.find((i) => i.cartItemId === cartItemId || i.id === cartItemId);
1704
+ const server = resCart.line_items?.find((li) => li.id === cartItemId);
1705
+ if (!cart || !line || !server) return;
1706
+ const price = server.price?.amount_incl_taxes ?? server.price?.amount;
1707
+ let changed = false;
1708
+ if (server.quantity !== line.quantity) {
1709
+ line.quantity = server.quantity;
1710
+ line.origins = reconciledOrigins(line.origins, server.quantity);
1711
+ changed = true;
1712
+ }
1713
+ if (price != null && price !== line.unitPrice) {
1714
+ line.unitPrice = price;
1715
+ changed = true;
1716
+ }
1717
+ if (!changed) return;
1718
+ this.persist();
1719
+ this.emit();
1609
1720
  }
1610
1721
  /** Surface a cart sync problem to hosts/UI (and the console). */
1611
1722
  emitError(message, err) {
@@ -1684,6 +1795,7 @@ var CartManager = class extends EventTarget {
1684
1795
  if (backendCart.available_shipping_countries) {
1685
1796
  cart.availableShippingCountries = backendCart.available_shipping_countries;
1686
1797
  }
1798
+ const removedBeforeSync = [];
1687
1799
  if (Array.isArray(backendCart.line_items)) {
1688
1800
  const existingItems = cart.items || [];
1689
1801
  if (backendCart.line_items.length === 0 && existingItems.length > 0) {
@@ -1691,7 +1803,16 @@ var CartManager = class extends EventTarget {
1691
1803
  this.emit();
1692
1804
  return;
1693
1805
  }
1694
- cart.items = backendCart.line_items.map((li) => {
1806
+ cart.items = backendCart.line_items.flatMap((li) => {
1807
+ const intended = this.intendedQuantity(sponsorId, li.id);
1808
+ if (intended !== void 0 && intended <= 0) return [];
1809
+ const earlyKey = this.intentKey(li.product_id, li.variant_id);
1810
+ const earlyIntent = this.pendingQtyIntents.get(earlyKey);
1811
+ if (earlyIntent !== void 0 && earlyIntent <= 0) {
1812
+ this.pendingQtyIntents.delete(earlyKey);
1813
+ removedBeforeSync.push(li.id);
1814
+ return [];
1815
+ }
1695
1816
  const urlFromBackend = (typeof li.image === "string" ? li.image : li.image?.url) || li.imageUrl || li.image_url || "";
1696
1817
  const matchedLocal = existingItems.find((existing) => {
1697
1818
  const p1 = normId(existing.productId);
@@ -1707,7 +1828,7 @@ var CartManager = class extends EventTarget {
1707
1828
  return false;
1708
1829
  });
1709
1830
  const imageUrl = urlFromBackend || matchedLocal?.imageUrl || "";
1710
- return {
1831
+ const line = {
1711
1832
  id: li.id,
1712
1833
  cartItemId: li.id,
1713
1834
  productId: li.product_id,
@@ -1718,14 +1839,22 @@ var CartManager = class extends EventTarget {
1718
1839
  unitPrice: li.price?.amount_incl_taxes ?? li.price?.amount ?? matchedLocal?.unitPrice ?? 0,
1719
1840
  currency: li.price?.currency_code || backendCart.currency || matchedLocal?.currency || "NOK",
1720
1841
  imageUrl,
1721
- quantity: li.quantity,
1842
+ quantity: intended ?? li.quantity,
1843
+ // The server knows nothing about origins: keep the local ones,
1844
+ // squared with the quantity it answered.
1845
+ origins: matchedLocal ? reconciledOrigins(matchedLocal.origins, intended ?? li.quantity) : void 0,
1722
1846
  shipping: li.shipping,
1723
1847
  availableShippings: li.available_shippings
1724
1848
  };
1849
+ return [line];
1725
1850
  });
1726
1851
  }
1727
1852
  this.persist();
1728
1853
  this.emit();
1854
+ const syncedCartId = cart.cartId;
1855
+ if (syncedCartId) {
1856
+ for (const id of removedBeforeSync) this.syncQuantity(sponsorId, syncedCartId, id, 0);
1857
+ }
1729
1858
  if (this.pendingQtyIntents.size > 0) {
1730
1859
  const currentCart = this.cartsBySponsor.get(sponsorId);
1731
1860
  for (const [key, qty] of [...this.pendingQtyIntents]) {
@@ -1761,7 +1890,8 @@ var CartManager = class extends EventTarget {
1761
1890
  currency: price.currency_code,
1762
1891
  imageUrl,
1763
1892
  quantity: opts.quantity,
1764
- variantId: opts.variantId
1893
+ variantId: opts.variantId,
1894
+ origin: opts.origin
1765
1895
  });
1766
1896
  }
1767
1897
  /** Add a manually-specified line item (no Product DTO). */
@@ -1794,6 +1924,7 @@ var CartManager = class extends EventTarget {
1794
1924
  });
1795
1925
  if (existing) {
1796
1926
  existing.quantity += qty;
1927
+ addOriginQuantity(existing, opts.origin, qty);
1797
1928
  } else {
1798
1929
  const item = {
1799
1930
  id: lineId(),
@@ -1805,7 +1936,8 @@ var CartManager = class extends EventTarget {
1805
1936
  unitPrice: opts.unitPrice,
1806
1937
  currency: opts.currency,
1807
1938
  imageUrl: opts.imageUrl,
1808
- quantity: qty
1939
+ quantity: qty,
1940
+ origins: [{ ...normalizeOrigin(opts.origin), quantity: qty }]
1809
1941
  };
1810
1942
  cart.items.push(item);
1811
1943
  }
@@ -1836,6 +1968,7 @@ var CartManager = class extends EventTarget {
1836
1968
  const line = c?.items.find((i) => i.id === addedLineId);
1837
1969
  if (c && line) {
1838
1970
  line.quantity -= qty;
1971
+ removeOriginQuantity(line, qty);
1839
1972
  if (line.quantity <= 0) c.items = c.items.filter((i) => i !== line);
1840
1973
  if (c.items.length === 0) this.cartsBySponsor.delete(opts.sponsorId);
1841
1974
  this.persist();
@@ -1844,24 +1977,39 @@ var CartManager = class extends EventTarget {
1844
1977
  this.emitError("AddItem rejected by the backend \u2014 item removed from the cart", err);
1845
1978
  }
1846
1979
  })();
1847
- this.inFlightMutations.set(opts.sponsorId, mutation);
1848
- void mutation.finally(() => {
1849
- if (this.inFlightMutations.get(opts.sponsorId) === mutation) {
1850
- this.inFlightMutations.delete(opts.sponsorId);
1851
- }
1852
- });
1980
+ this.trackMutation(opts.sponsorId, mutation);
1853
1981
  return addedItem;
1854
1982
  }
1855
1983
  intentKey(productId, variantId) {
1856
1984
  return `${normId(productId)}::${normId(variantId)}`;
1857
1985
  }
1858
- updateQuantity(lineItemId, sponsorId, quantity) {
1986
+ findLine(sponsorId, lineItemId) {
1987
+ return this.cartsBySponsor.get(sponsorId)?.items.find((i) => i.id === lineItemId || i.cartItemId === lineItemId);
1988
+ }
1989
+ /** Change a line's quantity by `delta`, computed on the manager's CURRENT
1990
+ * quantity — not on whatever a UI rendered a moment ago. A burst of +/−
1991
+ * clicks before a re-render therefore asks for 2, 3, 4… and not for the
1992
+ * same target several times. Reaching 0 removes the line. */
1993
+ adjustQuantity(lineItemId, sponsorId, delta, opts = {}) {
1994
+ const item = this.findLine(sponsorId, lineItemId);
1995
+ if (!item) return;
1996
+ this.updateQuantity(lineItemId, sponsorId, item.quantity + delta, opts);
1997
+ }
1998
+ /** Set a line's quantity (0 or less removes it). Optimistic: the local
1999
+ * state and `change` update at once; the backend is synced through
2000
+ * `syncQuantity`, one request in flight per line, latest target wins. */
2001
+ updateQuantity(lineItemId, sponsorId, quantity, opts = {}) {
1859
2002
  const cart = this.cartsBySponsor.get(sponsorId);
1860
2003
  if (!cart) return;
1861
2004
  const item = cart.items.find((i) => i.id === lineItemId || i.cartItemId === lineItemId);
1862
2005
  if (!item) return;
2006
+ const target = quantity > 0 ? quantity : 0;
2007
+ if (target === item.quantity) return;
1863
2008
  const targetItemId = item.cartItemId;
1864
- if (quantity <= 0) {
2009
+ const removedOrigin = latestOrigin(item);
2010
+ if (target > item.quantity) addOriginQuantity(item, opts.origin, target - item.quantity);
2011
+ else removeOriginQuantity(item, item.quantity - target);
2012
+ if (target <= 0) {
1865
2013
  cart.items = cart.items.filter((i) => i !== item);
1866
2014
  if (typeof document !== "undefined") {
1867
2015
  document.dispatchEvent(
@@ -1875,42 +2023,84 @@ var CartManager = class extends EventTarget {
1875
2023
  price: item.unitPrice,
1876
2024
  quantity: item.quantity,
1877
2025
  currency: item.currency,
1878
- sponsorId
2026
+ sponsorId,
2027
+ // Attribution of the removed units: their most recent origin.
2028
+ slot: removedOrigin?.slot,
2029
+ campaignId: removedOrigin?.campaignId,
2030
+ contentId: removedOrigin?.contentId
1879
2031
  }
1880
2032
  })
1881
2033
  );
1882
2034
  }
1883
2035
  } else {
1884
- item.quantity = quantity;
2036
+ item.quantity = target;
1885
2037
  }
1886
2038
  if (cart.items.length === 0) this.cartsBySponsor.delete(sponsorId);
1887
2039
  this.persist();
1888
2040
  this.emit();
1889
2041
  if (!cart.cartId) return;
1890
2042
  if (!targetItemId) {
1891
- this.pendingQtyIntents.set(this.intentKey(item.productId, item.variantId), quantity);
2043
+ this.pendingQtyIntents.set(this.intentKey(item.productId, item.variantId), target);
1892
2044
  return;
1893
2045
  }
1894
- const backendCartId = cart.cartId;
1895
- const seq = this.nextSeq(sponsorId);
1896
- const mutation = (async () => {
1897
- try {
1898
- const gqlOpts = await getCartGraphQLOptions(sponsorId);
1899
- const resCart = quantity <= 0 ? await deleteItem(backendCartId, targetItemId, gqlOpts) : await updateItem(backendCartId, targetItemId, null, quantity, gqlOpts);
1900
- if (resCart && Array.isArray(resCart.line_items)) {
1901
- this.applyBackendCart(sponsorId, resCart, seq);
2046
+ this.syncQuantity(sponsorId, cart.cartId, targetItemId, target);
2047
+ }
2048
+ /** Send a line's target quantity to the backend — ONE request in flight per
2049
+ * line. A target that arrives while one is in flight replaces any earlier
2050
+ * waiting one and goes out when the current request settles, so the server
2051
+ * ends on the last intent and never sees two updates racing each other.
2052
+ * While the sync is active the line keeps its optimistic quantity through
2053
+ * any backend response (see `updateFromBackendCart`); a rejection drops
2054
+ * what was queued and reconciles the cart from the server. */
2055
+ syncQuantity(sponsorId, backendCartId, cartItemId, target) {
2056
+ const key = this.qtySyncKey(sponsorId, cartItemId);
2057
+ const active = this.qtySyncs.get(key);
2058
+ if (active) {
2059
+ active.pending = target;
2060
+ return;
2061
+ }
2062
+ const sync = { sent: target };
2063
+ this.qtySyncs.set(key, sync);
2064
+ const run = (async () => {
2065
+ let next = target;
2066
+ let removalRetried = false;
2067
+ while (next !== void 0) {
2068
+ sync.sent = next;
2069
+ sync.pending = void 0;
2070
+ const seq = this.nextSeq(sponsorId);
2071
+ try {
2072
+ const gqlOpts = await getCartGraphQLOptions(sponsorId);
2073
+ const resCart = next <= 0 ? await deleteItem(backendCartId, cartItemId, gqlOpts) : await updateItem(backendCartId, cartItemId, null, next, gqlOpts);
2074
+ next = sync.pending;
2075
+ if (next === sync.sent) next = void 0;
2076
+ if (next === void 0) this.qtySyncs.delete(key);
2077
+ if (resCart && Array.isArray(resCart.line_items)) {
2078
+ const applied = this.applyBackendCart(sponsorId, resCart, seq);
2079
+ if (!applied && next === void 0) {
2080
+ this.reconcileLineFromResponse(sponsorId, cartItemId, resCart);
2081
+ }
2082
+ }
2083
+ } catch (err) {
2084
+ const retryRemoval = !removalRetried && sync.pending !== void 0 && sync.pending <= 0;
2085
+ next = void 0;
2086
+ if (retryRemoval) {
2087
+ removalRetried = true;
2088
+ sync.sent = 0;
2089
+ sync.pending = void 0;
2090
+ next = 0;
2091
+ } else {
2092
+ this.qtySyncs.delete(key);
2093
+ }
2094
+ this.emitError("Update/DeleteItem failed \u2014 cart re-synced from backend", err);
2095
+ try {
2096
+ await this.fetchCartFromBackend(sponsorId);
2097
+ } catch (fetchErr) {
2098
+ this.emitError("cart re-sync after a rejected Update/DeleteItem failed", fetchErr);
2099
+ }
1902
2100
  }
1903
- } catch (err) {
1904
- this.emitError("Update/DeleteItem failed \u2014 cart re-synced from backend", err);
1905
- void this.fetchCartFromBackend(sponsorId);
1906
2101
  }
1907
2102
  })();
1908
- this.inFlightMutations.set(sponsorId, mutation);
1909
- void mutation.finally(() => {
1910
- if (this.inFlightMutations.get(sponsorId) === mutation) {
1911
- this.inFlightMutations.delete(sponsorId);
1912
- }
1913
- });
2103
+ this.trackMutation(sponsorId, run);
1914
2104
  }
1915
2105
  removeItem(lineItemId, sponsorId) {
1916
2106
  this.updateQuantity(lineItemId, sponsorId, 0);
@@ -2021,6 +2211,68 @@ var CartManager = class extends EventTarget {
2021
2211
  this.dispatchEvent(new CustomEvent("change", { detail }));
2022
2212
  }
2023
2213
  };
2214
+ function normalizeOrigin(origin) {
2215
+ const out = {};
2216
+ if (!origin || typeof origin !== "object") return out;
2217
+ const campaignId = Number(origin.campaignId);
2218
+ if (origin.campaignId !== void 0 && origin.campaignId !== null && Number.isFinite(campaignId)) {
2219
+ out.campaignId = campaignId;
2220
+ }
2221
+ if (typeof origin.slot === "string" && origin.slot.trim()) out.slot = origin.slot.trim();
2222
+ if (origin.contentId !== void 0 && origin.contentId !== null && String(origin.contentId).trim()) {
2223
+ out.contentId = String(origin.contentId).trim();
2224
+ }
2225
+ return out;
2226
+ }
2227
+ function sameOrigin(a, b) {
2228
+ return a.campaignId === b.campaignId && a.slot === b.slot && a.contentId === b.contentId;
2229
+ }
2230
+ function latestOrigin(line) {
2231
+ const origins = Array.isArray(line.origins) ? line.origins : [];
2232
+ return origins[origins.length - 1];
2233
+ }
2234
+ function addOriginQuantity(line, origin, qty) {
2235
+ if (!(qty > 0)) return;
2236
+ const origins = Array.isArray(line.origins) ? line.origins : [];
2237
+ const target = origin ? normalizeOrigin(origin) : latestOrigin({ origins });
2238
+ const index = target ? origins.findIndex((o) => sameOrigin(o, target)) : -1;
2239
+ if (index >= 0) {
2240
+ const [found] = origins.splice(index, 1);
2241
+ found.quantity += qty;
2242
+ origins.push(found);
2243
+ } else {
2244
+ origins.push({ ...target ?? {}, quantity: qty });
2245
+ }
2246
+ line.origins = origins;
2247
+ }
2248
+ function removeOriginQuantity(line, qty) {
2249
+ if (!(qty > 0) || !Array.isArray(line.origins)) return;
2250
+ let left = qty;
2251
+ const origins = line.origins;
2252
+ while (left > 0 && origins.length > 0) {
2253
+ const last = origins[origins.length - 1];
2254
+ const take = Math.min(last.quantity, left);
2255
+ last.quantity -= take;
2256
+ left -= take;
2257
+ if (last.quantity <= 0) origins.pop();
2258
+ }
2259
+ }
2260
+ function reconciledOrigins(origins, quantity) {
2261
+ if (!Array.isArray(origins)) return void 0;
2262
+ const copy = origins.map((o) => ({ ...o }));
2263
+ const total = copy.reduce((sum, o) => sum + (o.quantity > 0 ? o.quantity : 0), 0);
2264
+ const line = { origins: copy };
2265
+ if (quantity > total) addOriginQuantity(line, void 0, quantity - total);
2266
+ else if (quantity < total) removeOriginQuantity(line, total - quantity);
2267
+ return line.origins;
2268
+ }
2269
+ function lineOrigins(line) {
2270
+ const known = (Array.isArray(line.origins) ? line.origins : []).filter((o) => o && o.quantity > 0);
2271
+ const total = known.reduce((sum, o) => sum + o.quantity, 0);
2272
+ const quantity = line.quantity > 0 ? line.quantity : 0;
2273
+ if (total >= quantity) return known.map((o) => ({ ...o }));
2274
+ return [...known.map((o) => ({ ...o })), { quantity: quantity - total }];
2275
+ }
2024
2276
 
2025
2277
  // src/core/checkout/payments/apple-pay.ts
2026
2278
  var STRIPE_JS_URL = "https://js.stripe.com/v3";
@@ -2218,7 +2470,7 @@ async function prepareApplePay(config) {
2218
2470
  }
2219
2471
 
2220
2472
  // src/core/version.ts
2221
- var SDK_VERSION = "0.21.0";
2473
+ var SDK_VERSION = "0.23.0";
2222
2474
 
2223
2475
  // src/core/checkout/payments/stripe-embedded.ts
2224
2476
  var CREATE_PAYMENT_INTENT_STRIPE_MUTATION = `
@@ -2726,8 +2978,8 @@ function renderKustomSnippet(container, htmlSnippet) {
2726
2978
  const scripts = Array.from(container.querySelectorAll("script"));
2727
2979
  for (const oldScript of scripts) {
2728
2980
  const newScript = document.createElement("script");
2729
- for (const attr of Array.from(oldScript.attributes)) {
2730
- newScript.setAttribute(attr.name, attr.value);
2981
+ for (const attr2 of Array.from(oldScript.attributes)) {
2982
+ newScript.setAttribute(attr2.name, attr2.value);
2731
2983
  }
2732
2984
  newScript.text = oldScript.text;
2733
2985
  oldScript.parentNode?.replaceChild(newScript, oldScript);
@@ -3785,6 +4037,7 @@ var CheckoutManager = class _CheckoutManager extends EventTarget {
3785
4037
  /* eslint-disable @typescript-eslint/no-explicit-any */
3786
4038
  /** Create the backend checkout for the sponsor's cart (auto-accepts terms). */
3787
4039
  async createCheckout(sponsorId) {
4040
+ await this.cartManager.waitForCartMutations(sponsorId);
3788
4041
  const cart = this.cartManager.getCart(sponsorId);
3789
4042
  let cartId = cart?.cartId;
3790
4043
  if (!cartId) {
@@ -3977,6 +4230,7 @@ var CheckoutManager = class _CheckoutManager extends EventTarget {
3977
4230
  async prepareRedirectPayment(sponsorId, formData, extraUpdateVars = {}) {
3978
4231
  const spId = sponsorId ?? this.state?.sponsorId;
3979
4232
  if (!spId) throw new Error("[CheckoutManager] no sponsor for payment");
4233
+ await this.cartManager.waitForCartMutations(spId);
3980
4234
  const cart = this.cartManager.getCart(spId);
3981
4235
  let cartId = cart?.cartId;
3982
4236
  if (!cartId) {
@@ -4091,6 +4345,8 @@ var CheckoutManager = class _CheckoutManager extends EventTarget {
4091
4345
  * plain payment once the shopper chose one in our form.
4092
4346
  */
4093
4347
  async startVippsPayment(sponsorId, formData, options = {}) {
4348
+ const waitFor = sponsorId ?? this.state?.sponsorId;
4349
+ if (waitFor) await this.cartManager.waitForCartMutations(waitFor);
4094
4350
  const { spId, checkoutId, emailVal, opts } = await this.prepareRedirectPayment(
4095
4351
  sponsorId,
4096
4352
  formData,
@@ -5588,6 +5844,194 @@ function toSmallestUnit(amount, currency) {
5588
5844
  return Math.round(amount * 100);
5589
5845
  }
5590
5846
 
5847
+ // src/core/content.ts
5848
+ var CONTENT_ID_QUERY = "vio_content";
5849
+ var CONTENT_TITLE_QUERY = "vio_content_title";
5850
+ function resolveContent(init, doc = typeof document !== "undefined" ? document : null, loc = typeof location !== "undefined" ? location : null) {
5851
+ try {
5852
+ const query = parseQuery(loc?.search);
5853
+ const id = text(init?.contentId) ?? text(query.get(CONTENT_ID_QUERY)) ?? text(meta(doc, "name", "vio:content-id")) ?? stripQueryAndHash(text(attr(doc, 'link[rel="canonical"]', "href")) ?? text(meta(doc, "property", "og:url"))) ?? text(loc?.pathname);
5854
+ const title = text(init?.contentTitle) ?? text(query.get(CONTENT_TITLE_QUERY)) ?? text(meta(doc, "name", "vio:content-title")) ?? text(meta(doc, "property", "og:title")) ?? text(doc?.title);
5855
+ const out = {};
5856
+ if (id !== void 0) out.id = id;
5857
+ if (title !== void 0) out.title = title.slice(0, 255);
5858
+ return out;
5859
+ } catch {
5860
+ return {};
5861
+ }
5862
+ }
5863
+ function text(value) {
5864
+ if (value === void 0 || value === null) return void 0;
5865
+ const s = String(value).trim();
5866
+ return s ? s : void 0;
5867
+ }
5868
+ function parseQuery(search) {
5869
+ try {
5870
+ return new URLSearchParams(typeof search === "string" ? search : "");
5871
+ } catch {
5872
+ return new URLSearchParams();
5873
+ }
5874
+ }
5875
+ function meta(doc, key, value) {
5876
+ return attr(doc, `meta[${key}="${value}"]`, "content");
5877
+ }
5878
+ function attr(doc, selector, name) {
5879
+ try {
5880
+ if (!doc || typeof doc.querySelector !== "function") return void 0;
5881
+ const el = doc.querySelector(selector);
5882
+ const v = el?.getAttribute?.(name);
5883
+ return typeof v === "string" ? v : void 0;
5884
+ } catch {
5885
+ return void 0;
5886
+ }
5887
+ }
5888
+ function stripQueryAndHash(url) {
5889
+ if (!url) return void 0;
5890
+ const cut = url.search(/[?#]/);
5891
+ const clean = cut >= 0 ? url.slice(0, cut) : url;
5892
+ return clean.trim() || void 0;
5893
+ }
5894
+
5895
+ // src/core/slots.ts
5896
+ var SLOT_ID_MAX_LENGTH = 100;
5897
+ var SLOT_DECLARATION_TTL_MS = 24 * 60 * 60 * 1e3;
5898
+ function resolveSlotCampaign(boot, slot, now = Date.now(), platform) {
5899
+ if (!boot) return null;
5900
+ return findSlotAssignment(boot, slot, now, platform) ?? defaultCampaignEntry(boot);
5901
+ }
5902
+ function findSlotAssignment(boot, slot, now = Date.now(), platform) {
5903
+ const name = typeof slot === "string" ? slot.trim() : "";
5904
+ if (!boot || !name) return null;
5905
+ try {
5906
+ const matches = activeCampaigns(boot).filter(
5907
+ (c) => slotsOf(c).some((s) => assignmentMatches(s, name, now, platform))
5908
+ );
5909
+ if (matches.length === 0) return null;
5910
+ const defaultId = toNumber(boot.campaign?.id);
5911
+ return matches.find((c) => toNumber(c.id) === defaultId) ?? matches[0] ?? null;
5912
+ } catch {
5913
+ return null;
5914
+ }
5915
+ }
5916
+ function defaultCampaignEntry(boot) {
5917
+ if (!boot?.campaign || typeof boot.campaign !== "object") return null;
5918
+ const defaultId = toNumber(boot.campaign.id);
5919
+ if (defaultId === void 0) return null;
5920
+ try {
5921
+ const listed = wellFormedCampaigns(boot).find((c) => toNumber(c.id) === defaultId);
5922
+ if (listed) return listed;
5923
+ } catch {
5924
+ }
5925
+ return {
5926
+ ...boot.campaign,
5927
+ isDefault: true,
5928
+ primarySponsor: boot.primarySponsor ?? null,
5929
+ secondarySponsors: Array.isArray(boot.secondarySponsors) ? boot.secondarySponsors : [],
5930
+ slots: []
5931
+ };
5932
+ }
5933
+ function wellFormedCampaigns(boot) {
5934
+ const list = Array.isArray(boot.campaigns) ? boot.campaigns : [];
5935
+ return list.filter((c) => !!c && typeof c === "object");
5936
+ }
5937
+ function activeCampaigns(boot) {
5938
+ return wellFormedCampaigns(boot).filter((c) => c.status === "active");
5939
+ }
5940
+ function slotsOf(campaign) {
5941
+ const slots = Array.isArray(campaign.slots) ? campaign.slots : [];
5942
+ return slots.filter((s) => !!s && typeof s === "object");
5943
+ }
5944
+ function assignmentMatches(s, name, now, platform) {
5945
+ if (typeof s.name !== "string" || s.name.trim() !== name) return false;
5946
+ if (platform && s.platform != null && s.platform !== platform) return false;
5947
+ const start = parseTime(s.startDate);
5948
+ if (start !== void 0 && start > now) return false;
5949
+ const end = parseTime(s.endDate);
5950
+ if (end !== void 0 && now >= end) return false;
5951
+ return true;
5952
+ }
5953
+ function parseTime(value) {
5954
+ if (value == null || value === "") return void 0;
5955
+ const t = Date.parse(String(value));
5956
+ return Number.isFinite(t) ? t : void 0;
5957
+ }
5958
+ function toNumber(value) {
5959
+ if (value === void 0 || value === null || value === "") return void 0;
5960
+ const n = Number(value);
5961
+ return Number.isFinite(n) ? n : void 0;
5962
+ }
5963
+ function normalizeSlotId(value) {
5964
+ if (typeof value !== "string") return "";
5965
+ return value.trim().slice(0, SLOT_ID_MAX_LENGTH);
5966
+ }
5967
+ function normalizeSlotLocations(slots) {
5968
+ const out = [];
5969
+ const seen = /* @__PURE__ */ new Set();
5970
+ if (!Array.isArray(slots)) return out;
5971
+ for (const raw of slots) {
5972
+ const id = normalizeSlotId(typeof raw === "string" ? raw : raw?.id);
5973
+ if (!id || seen.has(id)) continue;
5974
+ seen.add(id);
5975
+ const displayName = raw && typeof raw === "object" && typeof raw.displayName === "string" ? raw.displayName.trim() : "";
5976
+ out.push(displayName ? { id, displayName } : { id });
5977
+ }
5978
+ return out;
5979
+ }
5980
+ function autoSlotId(kind, ordinal, scope) {
5981
+ const prefix = normalizeScope(scope);
5982
+ return `${prefix ? `${prefix}-` : ""}${kind}-${ordinal + 1}`;
5983
+ }
5984
+ function autoSlotDisplayName(kind, ordinal, scopeName) {
5985
+ const label = `${kind.charAt(0).toUpperCase()}${kind.slice(1)} ${ordinal + 1}`;
5986
+ const name = typeof scopeName === "string" ? scopeName.trim() : "";
5987
+ return name ? `${label} \xB7 ${name}` : label;
5988
+ }
5989
+ function normalizeScope(scope) {
5990
+ if (typeof scope !== "string") return "";
5991
+ return scope.trim().replace(/\s+/g, "-");
5992
+ }
5993
+ function slotDeclarationKeys(locations) {
5994
+ return locations.map((l) => l.displayName ? `${l.id}${l.displayName}` : l.id).sort();
5995
+ }
5996
+ function slotDeclarationStorageKey(platform, apiKey) {
5997
+ return `vio.slots.${platform}.${fnv1a(apiKey)}`;
5998
+ }
5999
+ function fnv1a(input) {
6000
+ let hash = 2166136261;
6001
+ for (let i = 0; i < input.length; i++) {
6002
+ hash ^= input.charCodeAt(i);
6003
+ hash = Math.imul(hash, 16777619) >>> 0;
6004
+ }
6005
+ return hash.toString(16).padStart(8, "0");
6006
+ }
6007
+ function readSlotDeclaration(storageKey) {
6008
+ if (typeof localStorage === "undefined") return null;
6009
+ try {
6010
+ const raw = localStorage.getItem(storageKey);
6011
+ if (!raw) return null;
6012
+ const parsed = JSON.parse(raw);
6013
+ if (!parsed || !Array.isArray(parsed.keys) || typeof parsed.at !== "number") return null;
6014
+ return { keys: parsed.keys.filter((x) => typeof x === "string"), at: parsed.at };
6015
+ } catch {
6016
+ return null;
6017
+ }
6018
+ }
6019
+ function writeSlotDeclaration(storageKey, memory) {
6020
+ if (typeof localStorage === "undefined") return;
6021
+ try {
6022
+ localStorage.setItem(storageKey, JSON.stringify(memory));
6023
+ } catch {
6024
+ }
6025
+ }
6026
+ function isSlotDeclarationFresh(memory, keys, now = Date.now()) {
6027
+ if (!memory) return false;
6028
+ if (now - memory.at >= SLOT_DECLARATION_TTL_MS || now < memory.at) return false;
6029
+ if (memory.keys.length !== keys.length) return false;
6030
+ const remembered = [...memory.keys].sort();
6031
+ const wanted = [...keys].sort();
6032
+ return remembered.every((key, i) => key === wanted[i]);
6033
+ }
6034
+
5591
6035
  // src/core/analytics/analytics-manager.ts
5592
6036
  var SESSION_TTL_MS = 30 * 60 * 1e3;
5593
6037
  var FLUSH_INTERVAL_MS = 5e3;
@@ -5785,22 +6229,25 @@ var AnalyticsManager = class extends EventTarget {
5785
6229
  if (!anonId) return null;
5786
6230
  const boot = vioBootstrapCache();
5787
6231
  const ctx = opts.context ?? {};
6232
+ const content = ctx.contentId === void 0 || ctx.contentTitle === void 0 ? vioContent() : {};
5788
6233
  const context = compact({
5789
6234
  campaign_id: resolveCampaignId(ctx, boot),
5790
6235
  broadcast_id: ctx.broadcastId,
5791
6236
  campaign_component_id: ctx.campaignComponentId,
5792
6237
  app_placement_id: ctx.appPlacementId,
5793
- location_id: ctx.locationId,
6238
+ // A slot is a location id to the campaigns service (ADR-0027).
6239
+ location_id: ctx.locationId ?? ctx.slot,
5794
6240
  component_template_id: ctx.componentTemplateId,
5795
6241
  sponsor_id: ctx.sponsorId,
5796
6242
  activation_id: ctx.activationId,
5797
6243
  content_url: ctx.contentUrl ?? (typeof location !== "undefined" ? location.href : void 0),
5798
6244
  // The host's own content id — its join key back onto its reporting.
5799
- content_id: ctx.contentId,
6245
+ // Explicit on the event, else what the SDK resolved for the page.
6246
+ content_id: ctx.contentId ?? content.id,
5800
6247
  // Deletion-proof reporting: sources have no trash bin — the title
5801
6248
  // snapshotted at event time keeps reports legible if content dies.
5802
6249
  error_code: ctx.errorCode,
5803
- content_title: ctx.contentTitle ?? (typeof document !== "undefined" && document.title ? document.title.slice(0, 255) : void 0),
6250
+ content_title: ctx.contentTitle ?? content.title ?? (typeof document !== "undefined" && document.title ? document.title.slice(0, 255) : void 0),
5804
6251
  variant: ctx.variant
5805
6252
  });
5806
6253
  const com = opts.commerce;
@@ -5948,7 +6395,7 @@ var AnalyticsManager = class extends EventTarget {
5948
6395
  const d = ev.detail ?? {};
5949
6396
  const line = this.findLine(d.productId, d.variantId);
5950
6397
  this.track("add_to_cart", {
5951
- context: { sponsorId: d.sponsorId !== void 0 ? Number(d.sponsorId) : void 0 },
6398
+ context: { sponsorId: d.sponsorId !== void 0 ? Number(d.sponsorId) : void 0, slot: slotOf(d), ...contentOf(d) },
5952
6399
  commerce: {
5953
6400
  items: [
5954
6401
  {
@@ -5969,7 +6416,7 @@ var AnalyticsManager = class extends EventTarget {
5969
6416
  const d = ev.detail ?? {};
5970
6417
  if (d.productId === void 0) return;
5971
6418
  this.track("remove_from_cart", {
5972
- context: { sponsorId: d.sponsorId },
6419
+ context: { sponsorId: d.sponsorId, slot: slotOf(d), ...contentOf(d) },
5973
6420
  commerce: {
5974
6421
  items: [
5975
6422
  {
@@ -5998,7 +6445,7 @@ var AnalyticsManager = class extends EventTarget {
5998
6445
  if (d.productId === void 0) return;
5999
6446
  const price = Number(d.price);
6000
6447
  this.track("select_item", {
6001
- context: { sponsorId: d.sponsorId !== void 0 ? Number(d.sponsorId) : void 0 },
6448
+ context: { sponsorId: d.sponsorId !== void 0 ? Number(d.sponsorId) : void 0, slot: slotOf(d), ...contentOf(d) },
6002
6449
  commerce: {
6003
6450
  items: [
6004
6451
  {
@@ -6017,18 +6464,38 @@ var AnalyticsManager = class extends EventTarget {
6017
6464
  });
6018
6465
  on("vio:checkout-open", (ev) => {
6019
6466
  const d = ev.detail ?? {};
6467
+ const slices = this.cartSlices(d.sponsorId);
6468
+ if (slices.length > 1) {
6469
+ for (const slice of slices) {
6470
+ this.track("begin_checkout", {
6471
+ context: this.sliceContext(slice, { sponsorId: d.sponsorId, slot: slotOf(d), ...contentOf(d) }),
6472
+ commerce: slice.commerce
6473
+ });
6474
+ }
6475
+ return;
6476
+ }
6020
6477
  const snap = this.cartCommerce(d.sponsorId);
6021
6478
  this.track("begin_checkout", {
6022
- context: { sponsorId: d.sponsorId },
6479
+ context: { sponsorId: d.sponsorId, slot: slotOf(d), ...contentOf(d) },
6023
6480
  ...snap ? { commerce: snap } : {}
6024
6481
  });
6025
6482
  });
6026
6483
  on("vio:payment-success", (ev) => {
6027
6484
  const d = ev.detail ?? {};
6028
6485
  const sponsorId = d.sponsorId !== void 0 ? Number(d.sponsorId) : void 0;
6486
+ const slices = this.cartSlices(sponsorId);
6487
+ if (slices.length > 1) {
6488
+ for (const slice of slices) {
6489
+ this.track("purchase", {
6490
+ context: this.sliceContext(slice, { sponsorId, slot: slotOf(d), ...contentOf(d) }),
6491
+ commerce: { ...slice.commerce, ...purchaseFromPaymentSuccess(d), paymentMethod: d.method }
6492
+ });
6493
+ }
6494
+ return;
6495
+ }
6029
6496
  const snap = this.cartCommerce(sponsorId);
6030
6497
  this.track("purchase", {
6031
- context: { sponsorId },
6498
+ context: { sponsorId, slot: slotOf(d), ...contentOf(d) },
6032
6499
  commerce: {
6033
6500
  // value / currency / items: the cart snapshot, on every path.
6034
6501
  ...snap ?? {},
@@ -6039,6 +6506,86 @@ var AnalyticsManager = class extends EventTarget {
6039
6506
  });
6040
6507
  });
6041
6508
  }
6509
+ /** The lines of the cart (current, else the pre-clear snapshot), optionally one sponsor's. */
6510
+ cartLines(sponsorId) {
6511
+ let items = [];
6512
+ const cart = vioCart();
6513
+ if (cart) {
6514
+ cart.getAllCarts().forEach((state, id) => {
6515
+ if (sponsorId === void 0 || id === sponsorId) items.push(...state.items);
6516
+ });
6517
+ }
6518
+ if (items.length === 0 && this.lastCartSnapshot) {
6519
+ items = this.lastCartSnapshot.items.filter(
6520
+ (i) => sponsorId === void 0 || i.sponsorId === sponsorId
6521
+ );
6522
+ }
6523
+ return items;
6524
+ }
6525
+ /**
6526
+ * The cart split per campaign (ADR-0034): every unit of every line goes to
6527
+ * the campaign of its origin — the one the place showed when it was added
6528
+ * — and units without one to the campaign the current rule resolves for
6529
+ * the line's sponsor. One slice per campaign: its items (quantities from
6530
+ * the origins), its value, and the slot / content when they are the same
6531
+ * for the whole slice. A cart with one campaign is one slice.
6532
+ */
6533
+ cartSlices(sponsorId) {
6534
+ const lines = this.cartLines(sponsorId);
6535
+ if (lines.length === 0) return [];
6536
+ const boot = vioBootstrapCache();
6537
+ const slices = /* @__PURE__ */ new Map();
6538
+ for (const line of lines) {
6539
+ for (const origin of lineOrigins(line)) {
6540
+ const campaignId = origin.campaignId ?? resolveCampaignId({ slot: origin.slot, sponsorId: line.sponsorId }, boot);
6541
+ const key = campaignId === void 0 ? "default" : String(campaignId);
6542
+ let slice = slices.get(key);
6543
+ if (!slice) {
6544
+ slice = {
6545
+ campaignId,
6546
+ commerce: { items: [], value: 0, currency: line.currency ?? this.lastCartSnapshot?.currency },
6547
+ slots: /* @__PURE__ */ new Set(),
6548
+ contents: /* @__PURE__ */ new Set(),
6549
+ lines: /* @__PURE__ */ new Map()
6550
+ };
6551
+ slices.set(key, slice);
6552
+ }
6553
+ if (origin.slot) slice.slots.add(origin.slot);
6554
+ if (origin.contentId) slice.contents.add(origin.contentId);
6555
+ const itemKey = `${line.productId}::${line.variantId ?? ""}`;
6556
+ const item = slice.lines.get(itemKey);
6557
+ if (item) item.quantity = (item.quantity ?? 0) + origin.quantity;
6558
+ else {
6559
+ const fresh = {
6560
+ productId: line.productId,
6561
+ name: line.name,
6562
+ brand: line.brand,
6563
+ variantId: line.variantId,
6564
+ price: line.unitPrice,
6565
+ quantity: origin.quantity
6566
+ };
6567
+ slice.lines.set(itemKey, fresh);
6568
+ slice.commerce.items.push(fresh);
6569
+ }
6570
+ slice.commerce.value = (slice.commerce.value ?? 0) + line.unitPrice * origin.quantity;
6571
+ }
6572
+ }
6573
+ return [...slices.values()].map((s) => ({
6574
+ campaignId: s.campaignId,
6575
+ slot: s.slots.size === 1 ? [...s.slots][0] : void 0,
6576
+ contentId: s.contents.size === 1 ? [...s.contents][0] : void 0,
6577
+ commerce: s.commerce
6578
+ }));
6579
+ }
6580
+ /** The context of a per-campaign event: the slice's campaign, slot and content, over the event's own. */
6581
+ sliceContext(slice, base) {
6582
+ return compact({
6583
+ ...base,
6584
+ campaignId: slice.campaignId ?? base.campaignId,
6585
+ slot: slice.slot ?? base.slot,
6586
+ contentId: slice.contentId ?? base.contentId
6587
+ });
6588
+ }
6042
6589
  /** Current cart → commerce payload; falls back to the pre-clear snapshot. */
6043
6590
  cartCommerce(sponsorId) {
6044
6591
  let items = [];
@@ -6124,6 +6671,14 @@ function vioFacade() {
6124
6671
  function vioBootstrapCache() {
6125
6672
  return vioFacade()?.bootstrapCache ?? null;
6126
6673
  }
6674
+ function vioContent() {
6675
+ try {
6676
+ const facade = vioFacade();
6677
+ return typeof facade?.content === "function" ? facade.content() : resolveContent();
6678
+ } catch {
6679
+ return {};
6680
+ }
6681
+ }
6127
6682
  function vioCart() {
6128
6683
  try {
6129
6684
  return vioFacade()?.cart ?? null;
@@ -6134,7 +6689,12 @@ function vioCart() {
6134
6689
  function resolveCampaignId(ctx, boot) {
6135
6690
  if (ctx.campaignId !== void 0) return ctx.campaignId;
6136
6691
  const defaultId = boot?.campaign?.id;
6137
- const sponsorId = toNumber(ctx.sponsorId);
6692
+ if (ctx.slot) {
6693
+ const platform = Configuration.isInitialized ? Configuration.get().platform : void 0;
6694
+ const assigned = toNumber2(findSlotAssignment(boot, ctx.slot, Date.now(), platform)?.id);
6695
+ if (assigned !== void 0) return assigned;
6696
+ }
6697
+ const sponsorId = toNumber2(ctx.sponsorId);
6138
6698
  if (sponsorId === void 0) return defaultId;
6139
6699
  try {
6140
6700
  const campaigns = Array.isArray(boot?.campaigns) ? boot.campaigns : [];
@@ -6142,19 +6702,29 @@ function resolveCampaignId(ctx, boot) {
6142
6702
  (c) => !!c && typeof c === "object" && c.status === "active" && campaignSponsorIds(c).includes(sponsorId)
6143
6703
  );
6144
6704
  const chosen = matches.find((c) => c.id === defaultId) ?? matches[0];
6145
- const chosenId = toNumber(chosen?.id);
6705
+ const chosenId = toNumber2(chosen?.id);
6146
6706
  if (chosenId !== void 0) return chosenId;
6147
6707
  } catch {
6148
6708
  }
6149
6709
  return defaultId;
6150
6710
  }
6711
+ function slotOf(detail) {
6712
+ return typeof detail.slot === "string" && detail.slot.trim() ? detail.slot.trim() : void 0;
6713
+ }
6714
+ function contentOf(detail) {
6715
+ const out = {};
6716
+ if (typeof detail.contentId === "number" && Number.isFinite(detail.contentId)) out.contentId = detail.contentId;
6717
+ else if (typeof detail.contentId === "string" && detail.contentId.trim()) out.contentId = detail.contentId.trim();
6718
+ if (typeof detail.contentTitle === "string" && detail.contentTitle.trim()) out.contentTitle = detail.contentTitle.trim();
6719
+ return out;
6720
+ }
6151
6721
  function campaignSponsorIds(campaign) {
6152
6722
  const ids = [];
6153
- const primary = toNumber(campaign.primarySponsor?.id);
6723
+ const primary = toNumber2(campaign.primarySponsor?.id);
6154
6724
  if (primary !== void 0) ids.push(primary);
6155
6725
  const secondaries = Array.isArray(campaign.secondarySponsors) ? campaign.secondarySponsors : [];
6156
6726
  for (const s of secondaries) {
6157
- const id = toNumber(s?.id);
6727
+ const id = toNumber2(s?.id);
6158
6728
  if (id !== void 0) ids.push(id);
6159
6729
  }
6160
6730
  return ids;
@@ -6172,7 +6742,7 @@ function firstId(...candidates) {
6172
6742
  }
6173
6743
  return void 0;
6174
6744
  }
6175
- function toNumber(value) {
6745
+ function toNumber2(value) {
6176
6746
  if (value === void 0 || value === null || value === "") return void 0;
6177
6747
  const n = Number(value);
6178
6748
  return Number.isFinite(n) ? n : void 0;
@@ -6213,6 +6783,7 @@ function pushGa4(event) {
6213
6783
  }
6214
6784
 
6215
6785
  // src/core/client.ts
6786
+ var SLOT_DECLARATION_DEBOUNCE_MS = 1e3;
6216
6787
  var VioFacade = class {
6217
6788
  constructor() {
6218
6789
  this.apiInstance = null;
@@ -6226,6 +6797,14 @@ var VioFacade = class {
6226
6797
  * (another key or environment) is still in flight after a re-init; its answer
6227
6798
  * belongs to that configuration and must not land in this one. */
6228
6799
  this.generation = 0;
6800
+ /** Slot ids present on the page (`Vio.registerSlot`): how many elements hold each + its display name. */
6801
+ this.slotRegistry = /* @__PURE__ */ new Map();
6802
+ /** Pending debounced declaration of the registry. */
6803
+ this.slotDeclareTimer = null;
6804
+ /** A name appeared (or the key changed) since the registry was last declared. */
6805
+ this.slotsDirty = false;
6806
+ /** `Vio.content()` resolved once per page; forgotten on every init(). */
6807
+ this.contentCache = null;
6229
6808
  // Client-side managers — created lazily on first access. They don't
6230
6809
  // need Vio.init() to work (state is purely local).
6231
6810
  this.cartManager = null;
@@ -6241,6 +6820,31 @@ var VioFacade = class {
6241
6820
  this.bootstrapFailedAt = 0;
6242
6821
  this.generation++;
6243
6822
  this.checkoutManager?.clearPaymentMethodsCache();
6823
+ if (this.slotDeclareTimer) {
6824
+ clearTimeout(this.slotDeclareTimer);
6825
+ this.slotDeclareTimer = null;
6826
+ }
6827
+ this.slotsDirty = this.slotRegistry.size > 0;
6828
+ this.contentCache = null;
6829
+ }
6830
+ /**
6831
+ * The content (article, page) this SDK instance is rendered in — what every
6832
+ * analytics event carries as `content_id` / `content_title` when the event
6833
+ * has no explicit one. Resolved once per page (init option → URL query →
6834
+ * `vio:content-*` metas → canonical/og:url → pathname; see
6835
+ * `resolveContent`), forgotten on every `init()`. Never throws; `{}` without
6836
+ * a document.
6837
+ */
6838
+ content() {
6839
+ if (this.contentCache) return this.contentCache;
6840
+ let init;
6841
+ if (Configuration.isInitialized) {
6842
+ const cfg = Configuration.get();
6843
+ init = { contentId: cfg.contentId, contentTitle: cfg.contentTitle };
6844
+ }
6845
+ const resolved = resolveContent(init);
6846
+ if (typeof document !== "undefined") this.contentCache = resolved;
6847
+ return resolved;
6244
6848
  }
6245
6849
  get isInitialized() {
6246
6850
  return Configuration.isInitialized;
@@ -6292,11 +6896,12 @@ var VioFacade = class {
6292
6896
  throw new Error("[Vio] bootstrap recently failed \u2014 backing off");
6293
6897
  }
6294
6898
  const generation = this.generation;
6295
- const promise = this.api.bootstrap().then(
6899
+ const promise = this.api.bootstrap(Configuration.get().platform).then(
6296
6900
  (res) => {
6297
6901
  if (generation !== this.generation) return this.bootstrap();
6298
6902
  this.cachedBootstrap = res;
6299
6903
  this.bootstrapFailedAt = 0;
6904
+ if (this.slotsDirty) this.scheduleSlotDeclaration();
6300
6905
  return res;
6301
6906
  },
6302
6907
  (err) => {
@@ -6337,14 +6942,176 @@ var VioFacade = class {
6337
6942
  const cfg = Configuration.get();
6338
6943
  return createCommerceClient({ endpoint: cfg.graphQLBase, apiKey: commerceApiKey });
6339
6944
  }
6945
+ // MARK: - Slots (ADR-0027 / ADR-0034)
6946
+ /**
6947
+ * Declare this page's slots to the campaigns service
6948
+ * (`POST /v2/mobile/components/manifest`, `mode: 'merge'`, the init
6949
+ * platform). Best-effort: it NEVER throws — a failure is logged and resolves
6950
+ * `null`. The same set is not re-posted from this browser for 24 h unless
6951
+ * `force` (then `null` also means "skipped, already declared"). Ids are
6952
+ * trimmed, cut at 100 chars, deduped; empties are dropped.
6953
+ */
6954
+ async declareSlots(slots, opts = {}) {
6955
+ try {
6956
+ const locations = normalizeSlotLocations(slots);
6957
+ if (locations.length === 0) return null;
6958
+ if (!Configuration.isInitialized) {
6959
+ warn("declareSlots() before Vio.init() \u2014 nothing declared");
6960
+ return null;
6961
+ }
6962
+ const cfg = Configuration.get();
6963
+ const storageKey = slotDeclarationStorageKey(cfg.platform, cfg.apiKey);
6964
+ const keys = slotDeclarationKeys(locations);
6965
+ const now = Date.now();
6966
+ if (!opts.force && isSlotDeclarationFresh(readSlotDeclaration(storageKey), keys, now)) {
6967
+ return null;
6968
+ }
6969
+ const result = await this.api.declareManifest({
6970
+ platform: cfg.platform,
6971
+ mode: "merge",
6972
+ locations
6973
+ });
6974
+ if (result?.mode === "merge") {
6975
+ writeSlotDeclaration(storageKey, { keys, at: now });
6976
+ } else {
6977
+ warn(
6978
+ "slots declared but the server ignored mode=merge (old server); not caching, will retry next load"
6979
+ );
6980
+ }
6981
+ return result;
6982
+ } catch (err) {
6983
+ warn("declareSlots() failed \u2014", err);
6984
+ return null;
6985
+ }
6986
+ }
6987
+ /**
6988
+ * Register a slot present on the page (the `<vio-*>` elements call it with
6989
+ * their effective id — the host's `vio-slot` or the auto-generated one —
6990
+ * and the optional display name). The registry is declared ONCE, debounced,
6991
+ * after the first successful bootstrap — and again whenever a new id or a
6992
+ * new display name appears. A `displayName` given later for an id already
6993
+ * registered replaces the one kept; `undefined` leaves it alone. The
6994
+ * returned function unregisters; removal never re-declares.
6995
+ */
6996
+ registerSlot(name, displayName) {
6997
+ const id = normalizeSlotId(name);
6998
+ if (!id) return () => {
6999
+ };
7000
+ const label = typeof displayName === "string" && displayName.trim() ? displayName.trim() : void 0;
7001
+ const entry = this.slotRegistry.get(id);
7002
+ let changed = false;
7003
+ if (!entry) {
7004
+ this.slotRegistry.set(id, { count: 1, displayName: label });
7005
+ changed = true;
7006
+ } else {
7007
+ entry.count++;
7008
+ if (label !== void 0 && label !== entry.displayName) {
7009
+ entry.displayName = label;
7010
+ changed = true;
7011
+ }
7012
+ }
7013
+ if (changed) {
7014
+ this.slotsDirty = true;
7015
+ if (this.cachedBootstrap) this.scheduleSlotDeclaration();
7016
+ }
7017
+ let released = false;
7018
+ return () => {
7019
+ if (released) return;
7020
+ released = true;
7021
+ const current = this.slotRegistry.get(id);
7022
+ if (!current) return;
7023
+ if (current.count <= 1) this.slotRegistry.delete(id);
7024
+ else current.count--;
7025
+ };
7026
+ }
7027
+ /**
7028
+ * The campaign a slot shows now, from the last bootstrap: its assigned and
7029
+ * open campaign for the init platform, else the surface's default. With no
7030
+ * slot, the default. `null` before the bootstrap. See `resolveSlotCampaign`.
7031
+ */
7032
+ campaignForSlot(slot) {
7033
+ const platform = Configuration.isInitialized ? Configuration.get().platform : void 0;
7034
+ return resolveSlotCampaign(this.cachedBootstrap, slot ?? null, Date.now(), platform);
7035
+ }
7036
+ /**
7037
+ * THE attribution helper — the SDK elements, Vev and Replit all use this
7038
+ * one. For a place (`slot`): the campaign it shows right now
7039
+ * (`campaignForSlot`: its assignment, else the surface's default) as
7040
+ * `campaignId` + that campaign's primary sponsor as `sponsorId`, plus the
7041
+ * page's content (`content()`). Pure, never throws; `{}` before the
7042
+ * bootstrap, except the content.
7043
+ *
7044
+ * `hint.sponsorId` — when the caller already knows the sponsor (a card with
7045
+ * `sponsor-id`) it is kept as `sponsorId`, and the campaign follows the same
7046
+ * cascade analytics uses (slot assignment → the sponsor's active campaign →
7047
+ * default), so what the cart remembers matches what the events say.
7048
+ */
7049
+ attribution(slot, hint) {
7050
+ const out = {};
7051
+ try {
7052
+ const name = typeof slot === "string" ? slot.trim() : "";
7053
+ if (name) out.slot = name;
7054
+ const boot = this.cachedBootstrap;
7055
+ const hinted = toNumber3(hint?.sponsorId);
7056
+ if (boot) {
7057
+ if (hinted !== void 0) {
7058
+ const campaignId = resolveCampaignId({ slot: name || void 0, sponsorId: hinted }, boot);
7059
+ if (campaignId !== void 0) out.campaignId = campaignId;
7060
+ out.sponsorId = hinted;
7061
+ } else {
7062
+ const campaign = this.campaignForSlot(name || null);
7063
+ const campaignId = toNumber3(campaign?.id);
7064
+ if (campaignId !== void 0) out.campaignId = campaignId;
7065
+ const sponsorId = toNumber3(campaign?.primarySponsor?.id);
7066
+ if (sponsorId !== void 0) out.sponsorId = sponsorId;
7067
+ }
7068
+ } else if (hinted !== void 0) {
7069
+ out.sponsorId = hinted;
7070
+ }
7071
+ const content = this.content();
7072
+ if (content.id) out.contentId = content.id;
7073
+ if (content.title) out.contentTitle = content.title;
7074
+ } catch {
7075
+ }
7076
+ return out;
7077
+ }
7078
+ scheduleSlotDeclaration() {
7079
+ if (this.slotDeclareTimer) clearTimeout(this.slotDeclareTimer);
7080
+ this.slotDeclareTimer = setTimeout(() => {
7081
+ this.slotDeclareTimer = null;
7082
+ this.slotsDirty = false;
7083
+ void this.declareSlots(
7084
+ [...this.slotRegistry.entries()].map(
7085
+ ([id, { displayName }]) => displayName ? { id, displayName } : id
7086
+ )
7087
+ );
7088
+ }, SLOT_DECLARATION_DEBOUNCE_MS);
7089
+ }
6340
7090
  findSponsor(sponsorId) {
6341
7091
  const boot = this.cachedBootstrap;
6342
7092
  if (!boot) return void 0;
6343
7093
  const id = Number(sponsorId);
6344
7094
  if (Number(boot.primarySponsor?.id) === id) return boot.primarySponsor;
6345
- return boot.secondarySponsors?.find((s) => Number(s.id) === id);
7095
+ const secondary = boot.secondarySponsors?.find((s) => Number(s.id) === id);
7096
+ if (secondary) return secondary;
7097
+ const campaigns = Array.isArray(boot.campaigns) ? boot.campaigns : [];
7098
+ for (const c of campaigns) {
7099
+ if (!c || typeof c !== "object") continue;
7100
+ if (Number(c.primarySponsor?.id) === id) return c.primarySponsor ?? void 0;
7101
+ const s = Array.isArray(c.secondarySponsors) ? c.secondarySponsors.find((x) => Number(x?.id) === id) : void 0;
7102
+ if (s) return s;
7103
+ }
7104
+ return void 0;
6346
7105
  }
6347
7106
  };
7107
+ function warn(...args) {
7108
+ if (typeof console !== "undefined") console.warn("[Vio]", ...args);
7109
+ }
7110
+ function toNumber3(value) {
7111
+ if (value === void 0 || value === null || value === "") return void 0;
7112
+ const n = Number(value);
7113
+ return Number.isFinite(n) ? n : void 0;
7114
+ }
6348
7115
  var _vioGlobal = globalThis;
6349
7116
  var Vio = _vioGlobal.__VIO_FACADE__ ?? (_vioGlobal.__VIO_FACADE__ = new VioFacade());
6350
7117
 
@@ -6397,6 +7164,6 @@ function vippsButtonAttributes(verb = "buy", opts = {}) {
6397
7164
  };
6398
7165
  }
6399
7166
 
6400
- export { ADD_ITEM_MUTATION, ADYEN_RETURN_METHOD, ADYEN_RETURN_QUERY_KEYS, AdyenOriginError, 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, KUSTOM_RETURN_QUERY_KEYS, SDK_VERSION, UPDATE_CHECKOUT_MUTATION, UPDATE_ITEM_MUTATION, UPDATE_SHIPPINGS_BY_SUPPLIER_MUTATION, VIPPS_BUTTON_SCRIPT, VIPPS_BUTTON_TAG, Vio, VioApi, VioApiError, __decorateClass, addItem, adyenReturnOutcome, adyenReturnParams, checkApplePayAvailability, createCart, createCheckout, createCommerceClient, createPaymentStripe, createPaymentVipps, createVioApi, deleteItem, displayPrice, ensureVippsButton, executeCartGraphQL, formatPrice, getAvailablePaymentMethods, getCart, getCartGraphQLOptions, getCheckout, getCustomerSessionId, getGlobalCurrency, getLineItemsBySupplier, getVippsStatus, kustomReturnOutcome, nexiReturnPaymentId, primaryImageUrl, purchaseFromPaymentSuccess, readNexiPending, resolveCampaignId, showApplePaySheet, updateCheckout, updateItem, updateShippingsBySupplier, vippsButtonAttributes };
6401
- //# sourceMappingURL=chunk-WTVGVDZX.js.map
6402
- //# sourceMappingURL=chunk-WTVGVDZX.js.map
7167
+ export { ADD_ITEM_MUTATION, ADYEN_RETURN_METHOD, ADYEN_RETURN_QUERY_KEYS, AdyenOriginError, 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, KUSTOM_RETURN_QUERY_KEYS, SDK_VERSION, UPDATE_CHECKOUT_MUTATION, UPDATE_ITEM_MUTATION, UPDATE_SHIPPINGS_BY_SUPPLIER_MUTATION, VIPPS_BUTTON_SCRIPT, VIPPS_BUTTON_TAG, Vio, VioApi, VioApiError, __decorateClass, addItem, adyenReturnOutcome, adyenReturnParams, autoSlotDisplayName, autoSlotId, checkApplePayAvailability, createCart, createCheckout, createCommerceClient, createPaymentStripe, createPaymentVipps, createVioApi, deleteItem, displayPrice, ensureVippsButton, executeCartGraphQL, formatPrice, getAvailablePaymentMethods, getCart, getCartGraphQLOptions, getCheckout, getCustomerSessionId, getGlobalCurrency, getLineItemsBySupplier, getVippsStatus, kustomReturnOutcome, lineOrigins, nexiReturnPaymentId, normalizeOrigin, normalizeScope, primaryImageUrl, purchaseFromPaymentSuccess, readNexiPending, resolveCampaignId, resolveContent, resolveSlotCampaign, sameOrigin, showApplePaySheet, updateCheckout, updateItem, updateShippingsBySupplier, vippsButtonAttributes };
7168
+ //# sourceMappingURL=chunk-AMDI7KH3.js.map
7169
+ //# sourceMappingURL=chunk-AMDI7KH3.js.map