@moonbase.sh/storefront-api 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,7 +122,17 @@ host — is returned untouched and logs a warning, since it will be served at fu
122
122
 
123
123
  ## Token storage and logging
124
124
 
125
- By default, credentials are stored in localStorage (or in-memory if localStorage is unavailable).
125
+ By default, credentials are stored in localStorage, falling back to memory only when localStorage
126
+ cannot be reached at all. Even that check sits inside a `try`, because reading
127
+ `window.localStorage` itself throws in Firefox when the buyer has blocked cookies and site data.
128
+
129
+ Reachability, not writability, is the test on purpose: an origin that is merely full is still
130
+ holding the buyer's existing session, so it is used rather than discarded. Individual reads and
131
+ writes that fail degrade to an in-memory copy and warn through the configured logger. The session
132
+ stays valid for that page load; it just doesn't survive a reload, and
133
+ `client.tokenStore.isPersistent` goes false once that has happened if you want to tell the buyer.
134
+ A failure to *persist* credentials is never reported as a failure to sign in, so a locked-down
135
+ browser can't strand someone in a sign-in loop.
126
136
 
127
137
  You can inject your own storage/logger implementations:
128
138
 
package/dist/index.cjs CHANGED
@@ -49,6 +49,7 @@ __export(index_exports, {
49
49
  SubscriptionStatus: () => SubscriptionStatus,
50
50
  TokenStore: () => TokenStore,
51
51
  imageWidths: () => imageWidths,
52
+ isLocalStorageAvailable: () => isLocalStorageAvailable,
52
53
  isPromotionDisplay: () => isPromotionDisplay,
53
54
  isTransformableImageUrl: () => isTransformableImageUrl,
54
55
  maxImageDimension: () => maxImageDimension,
@@ -266,6 +267,10 @@ var productSummarySchema = import_zod2.z.object({
266
267
  releaseDescription: import_zod2.z.string().optional(),
267
268
  downloadsNeedsUser: import_zod2.z.boolean(),
268
269
  downloadsNeedsOwnership: import_zod2.z.boolean(),
270
+ downloadsNeedsGroupMembership: import_zod2.z.boolean(),
271
+ // Whether this caller clears every requirement above, including the group membership the client
272
+ // can't see for itself.
273
+ downloadsAllowed: import_zod2.z.boolean(),
269
274
  downloads: import_zod2.z.array(downloadSchema).optional()
270
275
  });
271
276
 
@@ -753,15 +758,15 @@ var IdentityEndpoints = class {
753
758
  throw new NotFoundError("User not found");
754
759
  if (response.status >= 400)
755
760
  await handleResponseProblem(response, this.logger);
761
+ let identity;
756
762
  try {
757
- const data = await response.json();
758
- const user = identitySchema.parse(data);
759
- this.tokenStore.setUser(user);
760
- return userSchema.parse(user);
763
+ identity = identitySchema.parse(await response.json());
761
764
  } catch (err) {
762
765
  this.logger.warn("Could not sign in user", { email, response, err });
763
766
  throw new MoonbaseError("Bad response", "Could not sign in user", response.status);
764
767
  }
768
+ this.tokenStore.setUser(identity);
769
+ return userSchema.parse(identity);
765
770
  }
766
771
  async signUp(name, email, password, address, acceptedPrivacyPolicy, acceptedTermsAndConditions, communicationOptIn) {
767
772
  const path = `/api/customer/identity/sign-up?scheme=JWT&communicationOptIn=${communicationOptIn ? "true" : "false"}`;
@@ -788,14 +793,15 @@ var IdentityEndpoints = class {
788
793
  const parsed = pendingActivationSchema.parse(data);
789
794
  return { status: "activation-pending", email: parsed.email };
790
795
  }
796
+ let identity;
791
797
  try {
792
- const identity = identitySchema.parse(data);
793
- this.tokenStore.setUser(identity);
794
- return { status: "signed-in", user: userSchema.parse(identity) };
798
+ identity = identitySchema.parse(data);
795
799
  } catch (err) {
796
800
  this.logger.warn("Could not sign up user", { email, response, err });
797
801
  throw new MoonbaseError("Bad response", "Could not sign up user", response.status);
798
802
  }
803
+ this.tokenStore.setUser(identity);
804
+ return { status: "signed-in", user: userSchema.parse(identity) };
799
805
  }
800
806
  async signOut() {
801
807
  this.tokenStore.clear();
@@ -1075,6 +1081,15 @@ var MoonbaseApi = class {
1075
1081
  status: response.status,
1076
1082
  duration: finishedAt.getTime() - startedAt.getTime()
1077
1083
  });
1084
+ if (response.type === "opaqueredirect" || response.status === 0) {
1085
+ const err = new MoonbaseError(
1086
+ "Unexpected redirect",
1087
+ `The Moonbase API redirected ${path}, which the SDK is not configured to follow`,
1088
+ response.status
1089
+ );
1090
+ this.reportRequestProblem(path, request, response, err);
1091
+ throw err;
1092
+ }
1078
1093
  if (response.status >= 400) {
1079
1094
  try {
1080
1095
  await handleResponseProblem(response, this.logger);
@@ -1430,6 +1445,9 @@ var OrderEndpoints = class {
1430
1445
  if (options.checkout.returnUrl) {
1431
1446
  query.append("returnUrl", options.checkout.returnUrl);
1432
1447
  }
1448
+ if (options.checkout.embedded !== void 0) {
1449
+ query.append("embedded", options.checkout.embedded ? "true" : "false");
1450
+ }
1433
1451
  }
1434
1452
  const response = await this.api.fetch(`/api/customer/orders/${orderId}?${query.toString()}`, orderSchema, {
1435
1453
  abort: options == null ? void 0 : options.abort
@@ -1444,6 +1462,8 @@ var OrderEndpoints = class {
1444
1462
  query.checkout = "true";
1445
1463
  if (checkout.returnUrl)
1446
1464
  query.returnUrl = checkout.returnUrl;
1465
+ if (checkout.embedded !== void 0)
1466
+ query.embedded = checkout.embedded ? "true" : "false";
1447
1467
  }
1448
1468
  const response = await this.api.fetch(
1449
1469
  `/api/customer/orders/${order.id}?${new URLSearchParams(query).toString()}`,
@@ -1534,28 +1554,81 @@ var ConsoleLogger = class {
1534
1554
  };
1535
1555
 
1536
1556
  // src/utils/store.ts
1557
+ function isLocalStorageAvailable() {
1558
+ try {
1559
+ return typeof window !== "undefined" && !!window.localStorage;
1560
+ } catch (e) {
1561
+ return false;
1562
+ }
1563
+ }
1537
1564
  var LocalStorageStore = class {
1565
+ constructor(onDegraded) {
1566
+ this.onDegraded = onDegraded;
1567
+ // Values whose write to localStorage failed. Read back in preference to
1568
+ // localStorage so the rest of the session sees what it just wrote.
1569
+ this.fallback = /* @__PURE__ */ new Map();
1570
+ this.degraded = false;
1571
+ }
1572
+ get isDegraded() {
1573
+ return this.degraded;
1574
+ }
1538
1575
  get(key) {
1539
- const item = localStorage.getItem(key);
1540
- if (item) {
1541
- return JSON.parse(item);
1576
+ if (this.fallback.has(key))
1577
+ return this.fallback.get(key);
1578
+ try {
1579
+ const item = localStorage.getItem(key);
1580
+ if (item) {
1581
+ return JSON.parse(item);
1582
+ }
1583
+ return null;
1584
+ } catch (err) {
1585
+ this.reportDegraded(err);
1586
+ return null;
1542
1587
  }
1543
- return null;
1544
1588
  }
1545
1589
  set(key, item) {
1546
- localStorage.setItem(key, JSON.stringify(item));
1590
+ try {
1591
+ localStorage.setItem(key, JSON.stringify(item));
1592
+ this.fallback.delete(key);
1593
+ } catch (err) {
1594
+ this.reportDegraded(err);
1595
+ this.fallback.set(key, item);
1596
+ }
1547
1597
  }
1548
1598
  remove(key) {
1549
- localStorage.removeItem(key);
1599
+ try {
1600
+ localStorage.removeItem(key);
1601
+ this.fallback.delete(key);
1602
+ return;
1603
+ } catch (err) {
1604
+ this.reportDegraded(err);
1605
+ }
1606
+ this.fallback.set(key, null);
1607
+ try {
1608
+ localStorage.setItem(key, "");
1609
+ } catch (e) {
1610
+ }
1550
1611
  }
1551
1612
  listen(key, callback) {
1552
- if (typeof window !== "undefined") {
1553
- window.addEventListener("storage", (event) => {
1554
- if (event.key === key) {
1555
- callback(event.newValue ? JSON.parse(event.newValue) : null);
1556
- }
1557
- });
1558
- }
1613
+ if (typeof window === "undefined")
1614
+ return;
1615
+ window.addEventListener("storage", (event) => {
1616
+ if (event.key !== key)
1617
+ return;
1618
+ this.fallback.delete(key);
1619
+ try {
1620
+ callback(event.newValue ? JSON.parse(event.newValue) : null);
1621
+ } catch (err) {
1622
+ console.warn(`Moonbase could not parse the stored value for ${key}`, err);
1623
+ }
1624
+ });
1625
+ }
1626
+ reportDegraded(err) {
1627
+ var _a;
1628
+ if (this.degraded)
1629
+ return;
1630
+ this.degraded = true;
1631
+ (_a = this.onDegraded) == null ? void 0 : _a.call(this, err);
1559
1632
  }
1560
1633
  };
1561
1634
  var InMemoryStore = class {
@@ -1593,8 +1666,11 @@ var _TokenStore = class _TokenStore {
1593
1666
  this.tokens = null;
1594
1667
  this.refreshTimeoutId = null;
1595
1668
  this.refreshPromise = null;
1596
- var _a;
1597
- this.store = (_a = configuration.store) != null ? _a : typeof window !== "undefined" && localStorage ? new LocalStorageStore() : new InMemoryStore();
1669
+ var _a, _b;
1670
+ this.logger = (_a = configuration.logger) != null ? _a : new ConsoleLogger();
1671
+ this.store = (_b = configuration.store) != null ? _b : isLocalStorageAvailable() ? new LocalStorageStore((err) => this.warnAboutStorage(err)) : new InMemoryStore();
1672
+ if (typeof window !== "undefined" && !configuration.store && !(this.store instanceof LocalStorageStore))
1673
+ this.warnAboutStorage();
1598
1674
  const storedToken = this.store.get(_TokenStore.storageKey);
1599
1675
  if (storedToken) {
1600
1676
  this.tokens = {
@@ -1609,18 +1685,17 @@ var _TokenStore = class _TokenStore {
1609
1685
  return this.tokens;
1610
1686
  return null;
1611
1687
  }
1688
+ get isPersistent() {
1689
+ return this.store instanceof LocalStorageStore && !this.store.isDegraded;
1690
+ }
1612
1691
  async getAccessToken() {
1613
1692
  var _a, _b, _c;
1614
- if (this.isExpired) {
1615
- if (this.refreshPromise) {
1616
- const tokens2 = await this.refreshPromise;
1617
- return (_a = tokens2 == null ? void 0 : tokens2.accessToken) != null ? _a : null;
1618
- }
1619
- this.refreshPromise = this.refreshTokens();
1620
- const tokens = await this.refreshPromise;
1621
- return (_b = tokens == null ? void 0 : tokens.accessToken) != null ? _b : null;
1693
+ if (!this.isExpired) {
1694
+ return ((_a = this.tokens) == null ? void 0 : _a.accessToken) || null;
1622
1695
  }
1623
- return ((_c = this.tokens) == null ? void 0 : _c.accessToken) || null;
1696
+ const pending = (_b = this.refreshPromise) != null ? _b : this.startRefresh();
1697
+ const tokens = await pending;
1698
+ return (_c = tokens == null ? void 0 : tokens.accessToken) != null ? _c : null;
1624
1699
  }
1625
1700
  setUser(user) {
1626
1701
  const identity = user;
@@ -1645,7 +1720,7 @@ var _TokenStore = class _TokenStore {
1645
1720
  if (this.refreshTimeoutId != null)
1646
1721
  window.clearTimeout(this.refreshTimeoutId);
1647
1722
  this.refreshTimeoutId = window.setTimeout(() => {
1648
- this.refreshPromise = this.refreshTokens();
1723
+ this.startRefresh().catch((err) => this.logger.warn("Could not refresh access token", err));
1649
1724
  }, 10 * 60 * 1e3);
1650
1725
  }
1651
1726
  return this.tokens;
@@ -1660,10 +1735,25 @@ var _TokenStore = class _TokenStore {
1660
1735
  get isExpired() {
1661
1736
  return this.tokens != null && this.tokens.expiresAt < /* @__PURE__ */ new Date();
1662
1737
  }
1738
+ /**
1739
+ * Start a refresh and hold it so concurrent callers join it rather than firing
1740
+ * their own. The slot is cleared as soon as it settles: a retained promise
1741
+ * would hand every later expiry the same stale access token, and a rejected one
1742
+ * that stayed put would re-throw on every subsequent request (including the
1743
+ * anonymous ones) for the rest of the session.
1744
+ */
1745
+ startRefresh() {
1746
+ const pending = this.refreshTokens().finally(() => {
1747
+ if (this.refreshPromise === pending)
1748
+ this.refreshPromise = null;
1749
+ });
1750
+ this.refreshPromise = pending;
1751
+ return pending;
1752
+ }
1663
1753
  async refreshTokens() {
1664
1754
  if (!this.tokens)
1665
1755
  throw new Error("No tokens found to refresh");
1666
- const response = await fetch(`${this.configuration.endpoint}/api/customer/identity/refresh?token=${this.tokens.refreshToken}`, {
1756
+ const response = await fetch(`${this.configuration.endpoint}/api/customer/identity/refresh?token=${encodeURIComponent(this.tokens.refreshToken)}`, {
1667
1757
  method: "POST",
1668
1758
  headers: {
1669
1759
  "Accept": "application/json",
@@ -1685,12 +1775,18 @@ var _TokenStore = class _TokenStore {
1685
1775
  }
1686
1776
  handleStorageUpdate(updatedTokens) {
1687
1777
  this.tokens = updatedTokens;
1688
- if (this.refreshTimeoutId != null)
1778
+ if (this.refreshTimeoutId != null && typeof window !== "undefined")
1689
1779
  window.clearTimeout(this.refreshTimeoutId);
1690
1780
  if (this.tokens) {
1691
1781
  this.tokens.expiresAt = new Date(this.tokens.expiresAt);
1692
1782
  }
1693
1783
  }
1784
+ warnAboutStorage(err) {
1785
+ this.logger.warn(
1786
+ "Moonbase could not persist credentials to localStorage: this browser is blocking or has exhausted site storage. Sign-in will work for this page load but will not survive a reload.",
1787
+ err
1788
+ );
1789
+ }
1694
1790
  };
1695
1791
  _TokenStore.storageKey = "moonbase_auth";
1696
1792
  var TokenStore = _TokenStore;
@@ -1878,10 +1974,29 @@ var OfferUtils = class _OfferUtils {
1878
1974
  static relevantTargets(offer, cartItems) {
1879
1975
  if (offer.targets.length > 1) {
1880
1976
  const allOwned = offer.targets.every((t) => t.item.owned);
1881
- return offer.targets.filter((t) => (allOwned || !t.item.owned) && !cartItems.some((l) => l.type === t.item.type && t.item.id === (l.type === "Product" ? l.productId : l.bundleId)));
1977
+ return offer.targets.filter((t) => (allOwned || !t.item.owned) && !_OfferUtils.targetInCart(t, cartItems));
1882
1978
  }
1883
1979
  return offer.targets;
1884
1980
  }
1981
+ /**
1982
+ * Whether every item an offer targets is already in the cart, so the offer has no upsell left to
1983
+ * show.
1984
+ *
1985
+ * This is the set of offers the cart hides: a card inviting the buyer to add what they have already
1986
+ * added says nothing, so it is dropped. Which makes it exactly the set whose discount nothing can
1987
+ * currently claim: an offer is only good for one line, and there is no card left to spend it from.
1988
+ *
1989
+ * An offer with no targets names nothing and so is never in this set.
1990
+ */
1991
+ static allTargetsInCart(offer, cartItems) {
1992
+ return offer.targets.length > 0 && offer.targets.every((target) => _OfferUtils.targetInCart(target, cartItems));
1993
+ }
1994
+ // Matched on the item alone, not the variation: a target narrowed to one variation still counts as
1995
+ // in the cart when another variation of it is. That is the reading the cart's own upsell has always
1996
+ // taken, and the two have to agree or an offer could be hidden and auto-applied at once.
1997
+ static targetInCart(target, cartItems) {
1998
+ return cartItems.some((l) => l.type === target.item.type && target.item.id === (l.type === "Product" ? l.productId : l.bundleId));
1999
+ }
1885
2000
  // The pricing variations a target restricts the offer to. An empty (or missing) variation list
1886
2001
  // means the offer is not narrowed to specific variations, in which case we keep the established
1887
2002
  // single default-variation upsell.
@@ -1912,7 +2027,7 @@ var OfferUtils = class _OfferUtils {
1912
2027
  };
1913
2028
 
1914
2029
  // src/utils/cart.ts
1915
- var CartUtils = class _CartUtils {
2030
+ var _CartUtils = class _CartUtils {
1916
2031
  // The pricing variation a line item points at, falling back to the item's default variation when
1917
2032
  // the variation id doesn't resolve, which is what the cart has always displayed.
1918
2033
  static resolveVariation(catalog, item) {
@@ -1970,8 +2085,13 @@ var CartUtils = class _CartUtils {
1970
2085
  // Which lines a cart offer discounts. No targets means the whole cart, a reading that only holds
1971
2086
  // for cart-scope offers, where an empty target list is the API's way of saying "every line".
1972
2087
  static cartOfferAppliesToLine(offer, item) {
1973
- if (offer.targets.length === 0)
1974
- return true;
2088
+ return offer.targets.length === 0 || _CartUtils.offerTargetsLine(offer, item);
2089
+ }
2090
+ // Whether an offer names this line among its targets. An empty variation list on a target leaves
2091
+ // the offer open to every variation of that item. An offer with no targets at all names nothing
2092
+ // here, which is why the "every line" reading stays in `cartOfferAppliesToLine`: it is only true
2093
+ // of a cart offer.
2094
+ static offerTargetsLine(offer, item) {
1975
2095
  return offer.targets.some(({ item: target, variations }) => target.type === item.type && target.id === (item.type === "Product" ? item.productId : item.bundleId) && (variations.length === 0 || variations.includes(item.variationId)));
1976
2096
  }
1977
2097
  // The cart offer actually discounting this cart, as opposed to the one attached to the order. An
@@ -2048,6 +2168,148 @@ var CartUtils = class _CartUtils {
2048
2168
  return aggregate + DiscountUtils.apply(offer.discount, { [currency]: unitPrice })[currency] * item.quantity;
2049
2169
  }, 0);
2050
2170
  }
2171
+ /**
2172
+ * The item-scope offer worth attaching to a single line, or `undefined` when none is.
2173
+ *
2174
+ * Ranked by what it would actually take off this line rather than by percentage, the same way
2175
+ * `bestCartOffer` ranks a cart: a flat amount off can beat a bigger-looking percentage on a cheap
2176
+ * line, and the other way round on an expensive one.
2177
+ *
2178
+ * `unavailableOfferIds` are offers the rest of the cart has already claimed. An offer is only ever
2179
+ * good for one line, since the cart treats an offer held by any line as in use. So a caller walking
2180
+ * the cart must reserve each one as it goes, or two lines would both count on the same offer and
2181
+ * the API would honour it once. `assignItemOffers` does that bookkeeping for a whole cart.
2182
+ */
2183
+ static bestItemOffer(catalog, item, currency, context, unavailableOfferIds) {
2184
+ let best;
2185
+ for (const candidate of _CartUtils.itemOfferCandidates(catalog, [item], currency, context, unavailableOfferIds)) {
2186
+ if (!best || candidate.value > best.value)
2187
+ best = candidate;
2188
+ }
2189
+ return best == null ? void 0 : best.offer;
2190
+ }
2191
+ /**
2192
+ * Which item-scope offer to attach to which line, for the lines not already carrying one.
2193
+ *
2194
+ * Chosen for what the pairings are worth together rather than by cart order. An offer is only good
2195
+ * for one line, so walking the cart and taking the best offer for each line in turn hands a
2196
+ * multi-target offer to whichever line the buyer happened to add first, which is arbitrary and can
2197
+ * be the cheapest one. A buyer claiming these from the upsell cards would have placed them where
2198
+ * they are worth most, so that is what this does.
2199
+ *
2200
+ * Lines already holding an offer are left alone and their offers reserved: an offer on a line is
2201
+ * intent, whether the buyer's from the upsell or the API's from a refreshed order, and this is not
2202
+ * the place to second-guess it.
2203
+ */
2204
+ static assignItemOffers(catalog, order, currency, context) {
2205
+ const conditionContext = context != null ? context : _CartUtils.conditionContext(catalog, order, currency);
2206
+ const claimed = new Set(order.items.map((i) => i.offerId).filter((id) => !!id));
2207
+ return _CartUtils.maximalItemOfferAssignment(
2208
+ _CartUtils.itemOfferCandidates(catalog, order.items.filter((i) => !i.offerId), currency, conditionContext, claimed)
2209
+ );
2210
+ }
2211
+ /**
2212
+ * The pairings worth the most together, given each offer can only be spent once.
2213
+ *
2214
+ * Taking the most valuable pairing first is not enough: with one offer worth 100 on line A and 99 on
2215
+ * line B, and a second offer worth 98 on line A alone, claiming the 100 strands the second offer and
2216
+ * saves 100, where putting the first offer on B and the second on A saves 197. The pairings have to
2217
+ * be chosen as a set.
2218
+ *
2219
+ * Walked line by line, memoised on which offers are still free. The candidates have already been
2220
+ * through targeting and eligibility, so in a real cart there are a handful; past `searchableOffers`
2221
+ * distinct offers it takes the most valuable pairing first instead, which is never worse than
2222
+ * applying nothing.
2223
+ */
2224
+ static maximalItemOfferAssignment(candidates) {
2225
+ var _a;
2226
+ const optionsByItem = /* @__PURE__ */ new Map();
2227
+ const offerBits = /* @__PURE__ */ new Map();
2228
+ for (const { item, offer, value } of candidates) {
2229
+ let bit = offerBits.get(offer.id);
2230
+ if (bit === void 0) {
2231
+ bit = offerBits.size;
2232
+ offerBits.set(offer.id, bit);
2233
+ }
2234
+ const options = (_a = optionsByItem.get(item.id)) != null ? _a : [];
2235
+ options.push({ offerId: offer.id, bit, value });
2236
+ optionsByItem.set(item.id, options);
2237
+ }
2238
+ if (offerBits.size > _CartUtils.searchableOffers)
2239
+ return _CartUtils.mostValuableItemOfferPairings(candidates);
2240
+ const itemIds = [...optionsByItem.keys()];
2241
+ const memo = /* @__PURE__ */ new Map();
2242
+ const from = (index, spent) => {
2243
+ var _a2;
2244
+ if (index >= itemIds.length)
2245
+ return { value: 0, assignments: [] };
2246
+ const key = `${index}:${spent}`;
2247
+ const memoised = memo.get(key);
2248
+ if (memoised)
2249
+ return memoised;
2250
+ let best = from(index + 1, spent);
2251
+ const itemId = itemIds[index];
2252
+ for (const option of (_a2 = optionsByItem.get(itemId)) != null ? _a2 : []) {
2253
+ if (spent & 1 << option.bit)
2254
+ continue;
2255
+ const rest = from(index + 1, spent | 1 << option.bit);
2256
+ const value = option.value + rest.value;
2257
+ if (value > best.value)
2258
+ best = { value, assignments: [{ itemId, offerId: option.offerId }, ...rest.assignments] };
2259
+ }
2260
+ memo.set(key, best);
2261
+ return best;
2262
+ };
2263
+ return from(0, 0).assignments;
2264
+ }
2265
+ // The fallback for a candidate set too large to search: repeatedly take the most valuable pairing
2266
+ // still available. Can strand a better combination, which is the whole reason it is only a fallback.
2267
+ static mostValuableItemOfferPairings(candidates) {
2268
+ const assignments = [];
2269
+ const assignedItemIds = /* @__PURE__ */ new Set();
2270
+ const spentOfferIds = /* @__PURE__ */ new Set();
2271
+ for (const { item, offer } of [...candidates].sort((a, b) => b.value - a.value)) {
2272
+ if (assignedItemIds.has(item.id) || spentOfferIds.has(offer.id))
2273
+ continue;
2274
+ assignedItemIds.add(item.id);
2275
+ spentOfferIds.add(offer.id);
2276
+ assignments.push({ itemId: item.id, offerId: offer.id });
2277
+ }
2278
+ return assignments;
2279
+ }
2280
+ // Every line-and-offer pairing the cart could make, and what each would save. Enumerated in cart
2281
+ // order and then catalog order, which `sort` preserves for equal savings, so an unchanged cart
2282
+ // always resolves the same way.
2283
+ static itemOfferCandidates(catalog, items, currency, context, unavailableOfferIds) {
2284
+ var _a;
2285
+ const candidates = [];
2286
+ for (const item of items) {
2287
+ const unitPrice = _CartUtils.unitPrice(catalog, item, currency);
2288
+ if (unitPrice === void 0)
2289
+ continue;
2290
+ for (const offer of (_a = catalog.offers) != null ? _a : []) {
2291
+ if (offer.scope === "Cart" || (unavailableOfferIds == null ? void 0 : unavailableOfferIds.has(offer.id)))
2292
+ continue;
2293
+ if (!_CartUtils.offerTargetsLine(offer, item))
2294
+ continue;
2295
+ if (!OfferUtils.allTargetsInCart(offer, context.order.items))
2296
+ continue;
2297
+ if (!OfferUtils.eligible(offer, context))
2298
+ continue;
2299
+ const value = _CartUtils.itemOfferUnitDiscount(offer, unitPrice, currency) * item.quantity;
2300
+ if (value <= 0)
2301
+ continue;
2302
+ candidates.push({ item, offer, value });
2303
+ }
2304
+ }
2305
+ return candidates;
2306
+ }
2307
+ // What an item offer takes off one unit of a line. Measured off the post-product-discount price,
2308
+ // which is what an item offer comes off in the API's order: product discount -> line offer -> cart
2309
+ // offer -> coupons.
2310
+ static itemOfferUnitDiscount(offer, unitPrice, currency) {
2311
+ return DiscountUtils.apply(offer.discount, { [currency]: unitPrice })[currency];
2312
+ }
2051
2313
  // A line's unit price once its own offer has come off, which is what a cart offer is applied to:
2052
2314
  // the API discounts in the order product discount -> line offer -> cart offer -> coupons. An
2053
2315
  // unresolvable price counts as zero, matching `total`'s "still render something" stance rather
@@ -2058,9 +2320,14 @@ var CartUtils = class _CartUtils {
2058
2320
  const itemOffer = item.offerId ? (_b = catalog.offers) == null ? void 0 : _b.find((o) => o.id === item.offerId) : void 0;
2059
2321
  if (!itemOffer || !OfferUtils.eligible(itemOffer, context))
2060
2322
  return unitPrice;
2061
- return unitPrice - DiscountUtils.apply(itemOffer.discount, { [currency]: unitPrice })[currency];
2323
+ return unitPrice - _CartUtils.itemOfferUnitDiscount(itemOffer, unitPrice, currency);
2062
2324
  }
2063
2325
  };
2326
+ // How many distinct offers can compete for the same cart before the exact search is given up on.
2327
+ // 12 leaves 4096 states per line, which is nothing, and no real storefront has a dozen item offers
2328
+ // eligible against one cart at once.
2329
+ _CartUtils.searchableOffers = 12;
2330
+ var CartUtils = _CartUtils;
2064
2331
 
2065
2332
  // src/utils/image.ts
2066
2333
  var imageWidths = [16, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048];
@@ -2252,6 +2519,7 @@ var MoonbaseClient = class {
2252
2519
  SubscriptionStatus,
2253
2520
  TokenStore,
2254
2521
  imageWidths,
2522
+ isLocalStorageAvailable,
2255
2523
  isPromotionDisplay,
2256
2524
  isTransformableImageUrl,
2257
2525
  maxImageDimension,