@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 +11 -1
- package/dist/index.cjs +307 -39
- package/dist/index.d.cts +139 -7
- package/dist/index.d.ts +139 -7
- package/dist/index.js +306 -39
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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) && !
|
|
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
|
|
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
|
-
|
|
1974
|
-
|
|
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 -
|
|
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,
|