@moonbase.sh/storefront-api 2.5.0 → 3.1.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/dist/index.js CHANGED
@@ -18,10 +18,14 @@ import { z as z4 } from "zod";
18
18
  var schemas_exports2 = {};
19
19
  __export(schemas_exports2, {
20
20
  offerConditionSchema: () => offerConditionSchema,
21
+ offerScopeSchema: () => offerScopeSchema,
21
22
  storefrontBundleSchema: () => storefrontBundleSchema,
22
23
  storefrontOfferSchema: () => storefrontOfferSchema,
23
24
  storefrontOfferTargetSchema: () => storefrontOfferTargetSchema,
24
25
  storefrontProductSchema: () => storefrontProductSchema,
26
+ storefrontPromotionCtaSchema: () => storefrontPromotionCtaSchema,
27
+ storefrontPromotionSchema: () => storefrontPromotionSchema,
28
+ storefrontPromotionTargetSchema: () => storefrontPromotionTargetSchema,
25
29
  storefrontSchema: () => storefrontSchema,
26
30
  taxEstimateSchema: () => taxEstimateSchema
27
31
  });
@@ -61,6 +65,11 @@ function utmToObject(utm) {
61
65
 
62
66
  // src/globalSchemas.ts
63
67
  var priceCollectionSchema = z.record(z.number());
68
+ var discountPromotionFields = {
69
+ promotionId: z.string().optional(),
70
+ validFrom: z.string().optional(),
71
+ validUntil: z.string().optional()
72
+ };
64
73
  var percentageOffDiscountSchema = z.object({
65
74
  type: z.literal("PercentageOffDiscount"),
66
75
  name: z.string(),
@@ -68,7 +77,8 @@ var percentageOffDiscountSchema = z.object({
68
77
  percentage: z.number(),
69
78
  total: priceCollectionSchema.optional(),
70
79
  isExclusive: z.boolean(),
71
- recurringPaymentUseCount: z.number().optional()
80
+ recurringPaymentUseCount: z.number().optional(),
81
+ ...discountPromotionFields
72
82
  });
73
83
  var flatAmountOffDiscountSchema = z.object({
74
84
  type: z.literal("FlatAmountOffDiscount"),
@@ -76,7 +86,8 @@ var flatAmountOffDiscountSchema = z.object({
76
86
  description: z.string().optional(),
77
87
  total: priceCollectionSchema.optional(),
78
88
  isExclusive: z.boolean(),
79
- recurringPaymentUseCount: z.number().optional()
89
+ recurringPaymentUseCount: z.number().optional(),
90
+ ...discountPromotionFields
80
91
  });
81
92
  var moneySchema = z.object({
82
93
  currency: z.string(),
@@ -110,6 +121,7 @@ var rawPropertyValueSchema = z.lazy(
110
121
  z.string(),
111
122
  z.number(),
112
123
  z.boolean(),
124
+ z.null(),
113
125
  z.record(rawPropertyValueSchema),
114
126
  z.array(rawPropertyValueSchema)
115
127
  ])
@@ -196,6 +208,10 @@ var productSummarySchema = z2.object({
196
208
  releaseDescription: z2.string().optional(),
197
209
  downloadsNeedsUser: z2.boolean(),
198
210
  downloadsNeedsOwnership: z2.boolean(),
211
+ downloadsNeedsGroupMembership: z2.boolean(),
212
+ // Whether this caller clears every requirement above, including the group membership the client
213
+ // can't see for itself.
214
+ downloadsAllowed: z2.boolean(),
199
215
  downloads: z2.array(downloadSchema).optional()
200
216
  });
201
217
 
@@ -236,7 +252,22 @@ var cartContainsItemsConditionSchema = z3.object({
236
252
  minimumItems: z3.number(),
237
253
  relevantItemVariations: z3.record(z3.string(), z3.string().array())
238
254
  });
239
- var offerConditionSchema = z3.discriminatedUnion("type", [cartContainsItemsConditionSchema]);
255
+ var cartTotalConditionSchema = z3.object({
256
+ type: z3.literal("CartTotal"),
257
+ // Both bounds are optional, but never both absent. They serialize as an explicit `null` when
258
+ // unset, hence `.nullish()` rather than `.optional()`. The API converts them into every enabled
259
+ // currency up front, precisely so we can tell the buyer how much further they have to go without
260
+ // doing any currency conversion ourselves.
261
+ minimumTotal: priceCollectionSchema.nullish(),
262
+ maximumTotal: priceCollectionSchema.nullish()
263
+ });
264
+ var knownOfferConditionSchema = z3.discriminatedUnion("type", [
265
+ cartContainsItemsConditionSchema,
266
+ cartTotalConditionSchema
267
+ ]);
268
+ var unsupportedOfferConditionSchema = z3.object({ type: z3.string() }).passthrough().transform(({ type }) => ({ type: "Unsupported", unsupportedType: type }));
269
+ var offerConditionSchema = z3.union([knownOfferConditionSchema, unsupportedOfferConditionSchema]);
270
+ var offerScopeSchema = z3.enum(["Item", "Cart"]).catch("Item").optional().default("Item");
240
271
  var offerTargetItemSchema = z3.union([storefrontProductSchema, storefrontBundleSchema]);
241
272
  var storefrontOfferTargetSchema = z3.object({
242
273
  // The product or bundle the offer applies to.
@@ -252,8 +283,10 @@ function legacyOfferTargets(target, targetVariations) {
252
283
  }
253
284
  var storefrontOfferSchema = z3.object({
254
285
  id: z3.string(),
286
+ scope: offerScopeSchema,
255
287
  // Each target pairs a product/bundle with the pricing variations the offer applies to (an empty
256
- // list means all variations). This is the model to consume.
288
+ // list means all variations). This is the model to consume. A cart offer may have no targets at
289
+ // all, which means it discounts every line item.
257
290
  targets: z3.array(storefrontOfferTargetSchema).optional(),
258
291
  // Legacy fields, kept optional only so old cached storefront responses still parse. The API now
259
292
  // exposes the unified `targets` model; we derive `targets` from these when a response predates it.
@@ -267,6 +300,57 @@ var storefrontOfferSchema = z3.object({
267
300
  ...rest,
268
301
  targets: targets != null ? targets : legacyOfferTargets(target, targetVariations)
269
302
  }));
303
+ var storefrontPromotionTargetSchema = z3.object({
304
+ // A prefixed reference to the item the sale covers, `Product/<id>` or `Bundle/<id>`. The item
305
+ // itself is already serialized in `products`/`bundles`; embedding it again would duplicate the
306
+ // bulk of the payload for a sale spanning dozens of products.
307
+ referenceId: z3.string(),
308
+ // The pricing variation IDs the sale is restricted to; an empty list means all variations.
309
+ variations: z3.string().array()
310
+ });
311
+ var storefrontPromotionCtaSchema = z3.object({
312
+ url: z3.string(),
313
+ label: z3.string()
314
+ });
315
+ var storefrontPromotionSchema = z3.object({
316
+ id: z3.string(),
317
+ name: z3.string(),
318
+ description: z3.string().nullish(),
319
+ imageUrl: z3.string().nullish(),
320
+ // Absent when the merchant has not set one, in which case the storefront decides whether the
321
+ // sale leads anywhere.
322
+ cta: storefrontPromotionCtaSchema.nullish(),
323
+ // How the merchant wants the sale surfaced, e.g. `['Banner', 'Popup']`; an empty list means no
324
+ // preference. Kept as free-form strings and narrowed with `isPromotionDisplay`, so a display
325
+ // mode added server-side later is ignored rather than failing the whole storefront.
326
+ display: z3.string().array(),
327
+ // The promotion's *intended* scope, not what is actually showing the sale price: each variation
328
+ // displays only its single best discount, so a targeted item may be showing a better one. The
329
+ // authoritative signal is `promotionId` on a variation's own discount.
330
+ targets: storefrontPromotionTargetSchema.array(),
331
+ // The promotion's own discount, for banner copy ("20% off"). Per-item prices are already
332
+ // discounted on each variation.
333
+ discount: discountSchema,
334
+ validFrom: z3.string().nullish(),
335
+ validUntil: z3.string().nullish(),
336
+ properties: z3.record(rawPropertyValueSchema).nullish()
337
+ });
338
+ var storefrontOffersSchema = z3.array(z3.unknown()).transform((offers) => offers.flatMap((offer) => {
339
+ const parsed = storefrontOfferSchema.safeParse(offer);
340
+ if (!parsed.success) {
341
+ console.warn("Ignoring storefront offer that could not be parsed:", parsed.error.issues);
342
+ return [];
343
+ }
344
+ return [parsed.data];
345
+ }));
346
+ var storefrontPromotionsSchema = z3.array(z3.unknown()).transform((promotions) => promotions.flatMap((promotion) => {
347
+ const parsed = storefrontPromotionSchema.safeParse(promotion);
348
+ if (!parsed.success) {
349
+ console.warn("Ignoring storefront promotion that could not be parsed:", parsed.error.issues);
350
+ return [];
351
+ }
352
+ return [parsed.data];
353
+ }));
270
354
  var taxEstimateSchema = z3.object({
271
355
  rate: z3.number(),
272
356
  mode: z3.enum(["Exclusive", "Inclusive"]),
@@ -280,7 +364,9 @@ var storefrontSchema = z3.object({
280
364
  products: storefrontProductSchema.array(),
281
365
  bundles: storefrontBundleSchema.array(),
282
366
  // Offers need to be optional since we may still have old, cached representations in browsers
283
- offers: storefrontOfferSchema.array().optional(),
367
+ offers: storefrontOffersSchema.optional(),
368
+ // Promotions need to be optional since we may still have old, cached representations in browsers
369
+ promotions: storefrontPromotionsSchema.optional(),
284
370
  // Depending on circumstances, we may have a tax estimate available
285
371
  estimatedTax: taxEstimateSchema.optional()
286
372
  });
@@ -1026,6 +1112,7 @@ import { z as z13 } from "zod";
1026
1112
  var schemas_exports7 = {};
1027
1113
  __export(schemas_exports7, {
1028
1114
  completedOrderSchema: () => completedOrderSchema,
1115
+ lineItemTotalSchema: () => lineItemTotalSchema,
1029
1116
  openBundleLineItem: () => openBundleLineItem,
1030
1117
  openOrderLineItem: () => openOrderLineItem,
1031
1118
  openOrderSchema: () => openOrderSchema,
@@ -1061,7 +1148,10 @@ var lineItemTotalSchema = z12.object({
1061
1148
  original: moneySchema,
1062
1149
  discount: moneySchema,
1063
1150
  subtotal: moneySchema,
1064
- due: moneySchema
1151
+ due: moneySchema,
1152
+ // This line's per-unit share of the order's cart offer, kept apart from the offer the buyer picked
1153
+ // for this item. Both are part of `discount`. Absent on orders priced before cart offers existed.
1154
+ cartOfferDiscount: moneySchema.optional()
1065
1155
  });
1066
1156
  var openProductLineItem = z12.object({
1067
1157
  id: z12.string(),
@@ -1113,7 +1203,10 @@ var orderTotalSchema = z12.object({
1113
1203
  discount: moneySchema,
1114
1204
  subtotal: moneySchema,
1115
1205
  taxes: moneySchema,
1116
- due: moneySchema
1206
+ due: moneySchema,
1207
+ // How much of `discount` came from the cart offer, so it can be shown as one order-wide saving
1208
+ // rather than a share on every line. Absent when no cart offer applied.
1209
+ cartOfferDiscount: moneySchema.optional()
1117
1210
  });
1118
1211
  var openOrderSchema = z12.object({
1119
1212
  id: z12.string(),
@@ -1121,6 +1214,12 @@ var openOrderSchema = z12.object({
1121
1214
  total: orderTotalSchema.nullish(),
1122
1215
  items: openOrderLineItem.array(),
1123
1216
  couponsApplied: couponSchema.array(),
1217
+ // The cart offer on the order as a whole: the buyer's intent. It is part of the cart payload, so
1218
+ // a content push that leaves it out clears it. The API echoes it back even while the cart sits
1219
+ // below the offer's threshold.
1220
+ offerId: z12.string().optional(),
1221
+ // The cart offer as actually applied: the result. Absent while the offer's condition doesn't hold.
1222
+ appliedOffer: discountSchema.optional(),
1124
1223
  checkoutUrl: z12.string().optional(),
1125
1224
  hostedCheckoutUrl: z12.string().optional(),
1126
1225
  embeddedCheckoutUrl: z12.string().optional()
@@ -1140,7 +1239,8 @@ var completedOrderSchema = z12.object({
1140
1239
  customer: customerSnapshotSchema,
1141
1240
  total: orderTotalSchema,
1142
1241
  items: openOrderLineItem.array(),
1143
- couponsApplied: couponSchema.array()
1242
+ couponsApplied: couponSchema.array(),
1243
+ appliedOffer: discountSchema.optional()
1144
1244
  });
1145
1245
  var orderSchema = z12.discriminatedUnion("status", [
1146
1246
  openOrderSchema.extend({
@@ -1276,6 +1376,9 @@ var OrderEndpoints = class {
1276
1376
  if (options.checkout.returnUrl) {
1277
1377
  query.append("returnUrl", options.checkout.returnUrl);
1278
1378
  }
1379
+ if (options.checkout.embedded !== void 0) {
1380
+ query.append("embedded", options.checkout.embedded ? "true" : "false");
1381
+ }
1279
1382
  }
1280
1383
  const response = await this.api.fetch(`/api/customer/orders/${orderId}?${query.toString()}`, orderSchema, {
1281
1384
  abort: options == null ? void 0 : options.abort
@@ -1290,6 +1393,8 @@ var OrderEndpoints = class {
1290
1393
  query.checkout = "true";
1291
1394
  if (checkout.returnUrl)
1292
1395
  query.returnUrl = checkout.returnUrl;
1396
+ if (checkout.embedded !== void 0)
1397
+ query.embedded = checkout.embedded ? "true" : "false";
1293
1398
  }
1294
1399
  const response = await this.api.fetch(
1295
1400
  `/api/customer/orders/${order.id}?${new URLSearchParams(query).toString()}`,
@@ -1298,6 +1403,7 @@ var OrderEndpoints = class {
1298
1403
  method: "PATCH",
1299
1404
  body: {
1300
1405
  currency: order.currency || void 0,
1406
+ offerId: order.offerId,
1301
1407
  items: order.items
1302
1408
  }
1303
1409
  }
@@ -1312,6 +1418,12 @@ var OrderEndpoints = class {
1312
1418
  const response = await this.api.fetch(`/api/customer/orders/${orderId}/billing-details`, openOrderSchema, { method: "DELETE" });
1313
1419
  return response.data;
1314
1420
  }
1421
+ // Takes the cart offer off the order without re-sending the cart. Offers picked for a single item
1422
+ // come off by pushing that item without its `offerId` instead.
1423
+ async removeOffer(orderId) {
1424
+ const response = await this.api.fetch(`/api/customer/orders/${orderId}/offer`, openOrderSchema, { method: "DELETE" });
1425
+ return response.data;
1426
+ }
1315
1427
  async addCouponCode(orderId, code) {
1316
1428
  const response = await this.api.fetch(`/api/customer/orders/${orderId}/coupon?code=${encodeURIComponent(code)}`, openOrderSchema, { method: "PATCH" });
1317
1429
  return response.data;
@@ -1616,6 +1728,12 @@ __export(schemas_exports11, {
1616
1728
  subscriptions: () => schemas_exports8
1617
1729
  });
1618
1730
 
1731
+ // src/storefront/models.ts
1732
+ var promotionDisplays = ["Banner", "Popup"];
1733
+ function isPromotionDisplay(value) {
1734
+ return promotionDisplays.includes(value);
1735
+ }
1736
+
1619
1737
  // src/utils/discount.ts
1620
1738
  var DiscountUtils = class {
1621
1739
  static apply(discount, price) {
@@ -1642,25 +1760,68 @@ var MoneyCollectionUtils = class {
1642
1760
  };
1643
1761
 
1644
1762
  // src/utils/offer.ts
1763
+ function evaluated(unit, currency, current, target, eligible) {
1764
+ return {
1765
+ eligible,
1766
+ progress: {
1767
+ unit,
1768
+ currency,
1769
+ current,
1770
+ target,
1771
+ remaining: Math.max(0, target - current),
1772
+ fraction: target <= 0 ? 1 : Math.min(1, Math.max(0, current / target)),
1773
+ eligible
1774
+ }
1775
+ };
1776
+ }
1645
1777
  var OfferUtils = class _OfferUtils {
1646
- static eligible(offer, order) {
1647
- switch (offer.condition.type) {
1778
+ // Callers that only have an order keep working and simply have no cart total, so a monetary
1779
+ // condition fails closed for them. Pass the full context to have `CartTotal` actually evaluate.
1780
+ static eligible(offer, context) {
1781
+ return _OfferUtils.evaluate(offer, context).eligible;
1782
+ }
1783
+ // How close the cart is to satisfying the offer, or `null` when there is nothing to show progress
1784
+ // toward: an unsupported condition, an unknown cart total, or a condition with only an upper
1785
+ // bound (which the buyer starts out satisfying rather than working toward).
1786
+ static progress(offer, context) {
1787
+ return _OfferUtils.evaluate(offer, context).progress;
1788
+ }
1789
+ // The single evaluator behind both faces, so the "spend €12 more" nudge and the gate that unlocks
1790
+ // the discount can never disagree about the same cart.
1791
+ static evaluate(offer, context) {
1792
+ var _a, _b;
1793
+ const { order, cartTotal } = "items" in context ? { order: context, cartTotal: void 0 } : context;
1794
+ const condition = offer.condition;
1795
+ switch (condition.type) {
1648
1796
  case "CartContainsItems": {
1649
1797
  const relevantItems = order.items.filter(
1650
- (i) => i.type === "Product" && offer.condition.relevantItemVariations[`Product/${i.productId}`] && offer.condition.relevantItemVariations[`Product/${i.productId}`].includes(i.variationId) || i.type === "Bundle" && offer.condition.relevantItemVariations[`Bundle/${i.bundleId}`] && offer.condition.relevantItemVariations[`Bundle/${i.bundleId}`].includes(i.variationId)
1798
+ (i) => i.type === "Product" && condition.relevantItemVariations[`Product/${i.productId}`] && condition.relevantItemVariations[`Product/${i.productId}`].includes(i.variationId) || i.type === "Bundle" && condition.relevantItemVariations[`Bundle/${i.bundleId}`] && condition.relevantItemVariations[`Bundle/${i.bundleId}`].includes(i.variationId)
1651
1799
  );
1652
1800
  const relevantReplacedItems = order.items.flatMap((i) => {
1653
- var _a;
1654
- return offer.id === i.offerId && i.type === "Bundle" && _OfferUtils.targetContainsBundle(offer, i.id) ? (_a = i.replaced) != null ? _a : [] : [];
1801
+ var _a2;
1802
+ return offer.id === i.offerId && i.type === "Bundle" && _OfferUtils.targetContainsBundle(offer, i.id) ? (_a2 = i.replaced) != null ? _a2 : [] : [];
1655
1803
  }).filter((ref) => {
1656
1804
  const [productId, variationId] = ref.split("/");
1657
- return offer.condition.relevantItemVariations[`Product/${productId}`] && offer.condition.relevantItemVariations[`Product/${productId}`].includes(variationId);
1805
+ return condition.relevantItemVariations[`Product/${productId}`] && condition.relevantItemVariations[`Product/${productId}`].includes(variationId);
1658
1806
  });
1659
- return relevantItems.length + relevantReplacedItems.length >= offer.condition.minimumItems;
1807
+ const current = relevantItems.length + relevantReplacedItems.length;
1808
+ return evaluated("Items", void 0, current, condition.minimumItems, current >= condition.minimumItems);
1809
+ }
1810
+ case "CartTotal": {
1811
+ if (!cartTotal)
1812
+ return { eligible: false, progress: null };
1813
+ const minimum = (_a = condition.minimumTotal) == null ? void 0 : _a[cartTotal.currency];
1814
+ const maximum = (_b = condition.maximumTotal) == null ? void 0 : _b[cartTotal.currency];
1815
+ if (minimum === void 0 && maximum === void 0)
1816
+ return { eligible: false, progress: null };
1817
+ const eligible = (minimum === void 0 || cartTotal.amount >= minimum) && (maximum === void 0 || cartTotal.amount <= maximum);
1818
+ if (minimum === void 0)
1819
+ return { eligible, progress: null };
1820
+ return evaluated("Money", cartTotal.currency, cartTotal.amount, minimum, eligible);
1660
1821
  }
1661
1822
  }
1662
- console.warn("Unsupported offer condition found:", offer.condition);
1663
- return false;
1823
+ console.warn("Unsupported offer condition found:", condition);
1824
+ return { eligible: false, progress: null };
1664
1825
  }
1665
1826
  // The relevant targets of an offer with more than a single target needs to:
1666
1827
  // a. Not be owned, or all targets need to be owned
@@ -1668,10 +1829,29 @@ var OfferUtils = class _OfferUtils {
1668
1829
  static relevantTargets(offer, cartItems) {
1669
1830
  if (offer.targets.length > 1) {
1670
1831
  const allOwned = offer.targets.every((t) => t.item.owned);
1671
- 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)));
1832
+ return offer.targets.filter((t) => (allOwned || !t.item.owned) && !_OfferUtils.targetInCart(t, cartItems));
1672
1833
  }
1673
1834
  return offer.targets;
1674
1835
  }
1836
+ /**
1837
+ * Whether every item an offer targets is already in the cart, so the offer has no upsell left to
1838
+ * show.
1839
+ *
1840
+ * This is the set of offers the cart hides: a card inviting the buyer to add what they have already
1841
+ * added says nothing, so it is dropped. Which makes it exactly the set whose discount nothing can
1842
+ * currently claim: an offer is only good for one line, and there is no card left to spend it from.
1843
+ *
1844
+ * An offer with no targets names nothing and so is never in this set.
1845
+ */
1846
+ static allTargetsInCart(offer, cartItems) {
1847
+ return offer.targets.length > 0 && offer.targets.every((target) => _OfferUtils.targetInCart(target, cartItems));
1848
+ }
1849
+ // Matched on the item alone, not the variation: a target narrowed to one variation still counts as
1850
+ // in the cart when another variation of it is. That is the reading the cart's own upsell has always
1851
+ // taken, and the two have to agree or an offer could be hidden and auto-applied at once.
1852
+ static targetInCart(target, cartItems) {
1853
+ return cartItems.some((l) => l.type === target.item.type && target.item.id === (l.type === "Product" ? l.productId : l.bundleId));
1854
+ }
1675
1855
  // The pricing variations a target restricts the offer to. An empty (or missing) variation list
1676
1856
  // means the offer is not narrowed to specific variations, in which case we keep the established
1677
1857
  // single default-variation upsell.
@@ -1701,6 +1881,428 @@ var OfferUtils = class _OfferUtils {
1701
1881
  }
1702
1882
  };
1703
1883
 
1884
+ // src/utils/cart.ts
1885
+ var _CartUtils = class _CartUtils {
1886
+ // The pricing variation a line item points at, falling back to the item's default variation when
1887
+ // the variation id doesn't resolve, which is what the cart has always displayed.
1888
+ static resolveVariation(catalog, item) {
1889
+ var _a, _b;
1890
+ const source = item.type === "Product" ? catalog.products.find((p) => p.id === item.productId) : catalog.bundles.find((b) => b.id === item.bundleId);
1891
+ return (_b = (_a = source == null ? void 0 : source.variations) == null ? void 0 : _a.find((v) => v.id === item.variationId)) != null ? _b : source == null ? void 0 : source.defaultVariation;
1892
+ }
1893
+ // A line's unit price after its product discount and any pricing tier, and *before* any offer.
1894
+ // This is the basis the API measures cart total conditions on, which is why it reads the catalog
1895
+ // rather than the line item's own `price`: `enrichLineItems` rewrites that to the offer-discounted
1896
+ // price, and reading it here would take the offer off twice.
1897
+ static unitPrice(catalog, item, currency) {
1898
+ var _a;
1899
+ return (_a = _CartUtils.unitPricing(catalog, item, currency)) == null ? void 0 : _a.price;
1900
+ }
1901
+ // A line's list and post-product-discount unit prices, from whichever pricing tier applies. Both
1902
+ // come from the same tier so they can be subtracted from one another: taking the list price off one
1903
+ // tier and the paid price off another would invent a discount neither of them offers.
1904
+ static unitPricing(catalog, item, currency) {
1905
+ var _a, _b, _c, _d, _e, _f;
1906
+ const variation = _CartUtils.resolveVariation(catalog, item);
1907
+ const price = variation == null ? void 0 : variation.price[currency];
1908
+ if (price === void 0 || variation === void 0)
1909
+ return void 0;
1910
+ const eligiblePricingTiers = (_b = (_a = variation.pricingTiers) == null ? void 0 : _a.filter((tier) => item.quantity >= tier.minQuantity)) != null ? _b : [];
1911
+ const matchingPricingTier = eligiblePricingTiers.length > 0 ? eligiblePricingTiers[eligiblePricingTiers.length - 1] : null;
1912
+ const tierPrice = matchingPricingTier == null ? void 0 : matchingPricingTier.price[currency];
1913
+ if (tierPrice !== void 0 && tierPrice < price) {
1914
+ return {
1915
+ price: tierPrice,
1916
+ originalPrice: (_d = (_c = matchingPricingTier == null ? void 0 : matchingPricingTier.originalPrice) == null ? void 0 : _c[currency]) != null ? _d : tierPrice
1917
+ };
1918
+ }
1919
+ return { price, originalPrice: (_f = (_e = variation.originalPrice) == null ? void 0 : _e[currency]) != null ? _f : price };
1920
+ }
1921
+ // The number the API measures cart conditions against: each line's price after its product
1922
+ // discount, times quantity, before offers and coupons. `undefined` when any line's price can't be
1923
+ // resolved in `currency`. One unreadable line makes the whole measurement a guess, and a guess
1924
+ // must not unlock an offer.
1925
+ static measure(catalog, items, currency) {
1926
+ if (!currency)
1927
+ return void 0;
1928
+ let amount = 0;
1929
+ for (const item of items) {
1930
+ const unitPrice = _CartUtils.unitPrice(catalog, item, currency);
1931
+ if (unitPrice === void 0)
1932
+ return void 0;
1933
+ amount += unitPrice * item.quantity;
1934
+ }
1935
+ return { currency, amount };
1936
+ }
1937
+ static conditionContext(catalog, order, currency) {
1938
+ return { order, cartTotal: _CartUtils.measure(catalog, order.items, currency) };
1939
+ }
1940
+ // Which lines a cart offer discounts. No targets means the whole cart, a reading that only holds
1941
+ // for cart-scope offers, where an empty target list is the API's way of saying "every line".
1942
+ static cartOfferAppliesToLine(offer, item) {
1943
+ return offer.targets.length === 0 || _CartUtils.offerTargetsLine(offer, item);
1944
+ }
1945
+ // Whether an offer names this line among its targets. An empty variation list on a target leaves
1946
+ // the offer open to every variation of that item. An offer with no targets at all names nothing
1947
+ // here, which is why the "every line" reading stays in `cartOfferAppliesToLine`: it is only true
1948
+ // of a cart offer.
1949
+ static offerTargetsLine(offer, item) {
1950
+ 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)));
1951
+ }
1952
+ // The cart offer actually discounting this cart, as opposed to the one attached to the order. An
1953
+ // offer whose targets name nothing in the cart discounts nothing however healthy the total looks,
1954
+ // so it has not really applied.
1955
+ static appliedCartOffer(catalog, order, context) {
1956
+ var _a;
1957
+ if (!order.offerId)
1958
+ return void 0;
1959
+ const offer = (_a = catalog.offers) == null ? void 0 : _a.find((o) => o.id === order.offerId);
1960
+ if (!offer || offer.scope !== "Cart" || !OfferUtils.eligible(offer, context))
1961
+ return void 0;
1962
+ return order.items.some((item) => _CartUtils.cartOfferAppliesToLine(offer, item)) ? offer : void 0;
1963
+ }
1964
+ // What the cart is worth to the buyer, broken down so the cart can show the saving on its own row.
1965
+ // Unlike `measure`, an unresolvable price counts as zero here: this number fills a label, so a
1966
+ // half-loaded catalog should still render a total rather than nothing.
1967
+ static total(catalog, order, currency) {
1968
+ var _a, _b;
1969
+ const context = _CartUtils.conditionContext(catalog, order, currency);
1970
+ const cartOffer = _CartUtils.appliedCartOffer(catalog, order, context);
1971
+ let original = 0;
1972
+ let subtotal = 0;
1973
+ let cartOfferDiscount = 0;
1974
+ for (const item of order.items) {
1975
+ const unitPrice = _CartUtils.unitPriceAfterItemOffer(catalog, item, currency, context);
1976
+ original += ((_b = (_a = _CartUtils.unitPricing(catalog, item, currency)) == null ? void 0 : _a.originalPrice) != null ? _b : unitPrice) * item.quantity;
1977
+ subtotal += unitPrice * item.quantity;
1978
+ if (cartOffer && _CartUtils.cartOfferAppliesToLine(cartOffer, item))
1979
+ cartOfferDiscount += DiscountUtils.apply(cartOffer.discount, { [currency]: unitPrice })[currency] * item.quantity;
1980
+ }
1981
+ const amount = subtotal - cartOfferDiscount;
1982
+ return {
1983
+ currency,
1984
+ original,
1985
+ subtotal,
1986
+ cartOffer: cartOffer ? { offer: cartOffer, discount: cartOfferDiscount } : null,
1987
+ discount: original - amount,
1988
+ amount
1989
+ };
1990
+ }
1991
+ // The cart offer worth attaching to the order. Cart offers are always percentages, but 10% off
1992
+ // everything can still beat 50% off one cheap line, so they are ranked by what they would actually
1993
+ // take off this cart rather than by percentage.
1994
+ static bestCartOffer(catalog, order, currency) {
1995
+ var _a;
1996
+ const context = _CartUtils.conditionContext(catalog, order, currency);
1997
+ let best;
1998
+ for (const offer of (_a = catalog.offers) != null ? _a : []) {
1999
+ if (offer.scope !== "Cart" || !OfferUtils.eligible(offer, context))
2000
+ continue;
2001
+ const value = _CartUtils.cartOfferValue(catalog, order, offer, currency, context);
2002
+ if (value <= 0)
2003
+ continue;
2004
+ if (!best || value > best.value || value === best.value && offer.id === order.offerId)
2005
+ best = { offer, value };
2006
+ }
2007
+ return best == null ? void 0 : best.offer;
2008
+ }
2009
+ /**
2010
+ * What a cart offer would take off this cart, whether or not its condition is met yet.
2011
+ *
2012
+ * Computed on the same post-line-offer price `total` discounts, so "which offer do we attach" and
2013
+ * "what does the cart actually save" can never disagree. Ranking on the list price instead would
2014
+ * over-value an offer aimed at a line that already carries a steep offer of its own: 50% of a
2015
+ * line discounted to a tenth of its price is worth less than 10% of a full-price one.
2016
+ */
2017
+ static cartOfferValue(catalog, order, offer, currency, context) {
2018
+ const conditionContext = context != null ? context : _CartUtils.conditionContext(catalog, order, currency);
2019
+ return order.items.reduce((aggregate, item) => {
2020
+ if (!_CartUtils.cartOfferAppliesToLine(offer, item))
2021
+ return aggregate;
2022
+ const unitPrice = _CartUtils.unitPriceAfterItemOffer(catalog, item, currency, conditionContext);
2023
+ return aggregate + DiscountUtils.apply(offer.discount, { [currency]: unitPrice })[currency] * item.quantity;
2024
+ }, 0);
2025
+ }
2026
+ /**
2027
+ * The item-scope offer worth attaching to a single line, or `undefined` when none is.
2028
+ *
2029
+ * Ranked by what it would actually take off this line rather than by percentage, the same way
2030
+ * `bestCartOffer` ranks a cart: a flat amount off can beat a bigger-looking percentage on a cheap
2031
+ * line, and the other way round on an expensive one.
2032
+ *
2033
+ * `unavailableOfferIds` are offers the rest of the cart has already claimed. An offer is only ever
2034
+ * good for one line, since the cart treats an offer held by any line as in use. So a caller walking
2035
+ * the cart must reserve each one as it goes, or two lines would both count on the same offer and
2036
+ * the API would honour it once. `assignItemOffers` does that bookkeeping for a whole cart.
2037
+ */
2038
+ static bestItemOffer(catalog, item, currency, context, unavailableOfferIds) {
2039
+ let best;
2040
+ for (const candidate of _CartUtils.itemOfferCandidates(catalog, [item], currency, context, unavailableOfferIds)) {
2041
+ if (!best || candidate.value > best.value)
2042
+ best = candidate;
2043
+ }
2044
+ return best == null ? void 0 : best.offer;
2045
+ }
2046
+ /**
2047
+ * Which item-scope offer to attach to which line, for the lines not already carrying one.
2048
+ *
2049
+ * Chosen for what the pairings are worth together rather than by cart order. An offer is only good
2050
+ * for one line, so walking the cart and taking the best offer for each line in turn hands a
2051
+ * multi-target offer to whichever line the buyer happened to add first, which is arbitrary and can
2052
+ * be the cheapest one. A buyer claiming these from the upsell cards would have placed them where
2053
+ * they are worth most, so that is what this does.
2054
+ *
2055
+ * Lines already holding an offer are left alone and their offers reserved: an offer on a line is
2056
+ * intent, whether the buyer's from the upsell or the API's from a refreshed order, and this is not
2057
+ * the place to second-guess it.
2058
+ */
2059
+ static assignItemOffers(catalog, order, currency, context) {
2060
+ const conditionContext = context != null ? context : _CartUtils.conditionContext(catalog, order, currency);
2061
+ const claimed = new Set(order.items.map((i) => i.offerId).filter((id) => !!id));
2062
+ return _CartUtils.maximalItemOfferAssignment(
2063
+ _CartUtils.itemOfferCandidates(catalog, order.items.filter((i) => !i.offerId), currency, conditionContext, claimed)
2064
+ );
2065
+ }
2066
+ /**
2067
+ * The pairings worth the most together, given each offer can only be spent once.
2068
+ *
2069
+ * Taking the most valuable pairing first is not enough: with one offer worth 100 on line A and 99 on
2070
+ * line B, and a second offer worth 98 on line A alone, claiming the 100 strands the second offer and
2071
+ * saves 100, where putting the first offer on B and the second on A saves 197. The pairings have to
2072
+ * be chosen as a set.
2073
+ *
2074
+ * Walked line by line, memoised on which offers are still free. The candidates have already been
2075
+ * through targeting and eligibility, so in a real cart there are a handful; past `searchableOffers`
2076
+ * distinct offers it takes the most valuable pairing first instead, which is never worse than
2077
+ * applying nothing.
2078
+ */
2079
+ static maximalItemOfferAssignment(candidates) {
2080
+ var _a;
2081
+ const optionsByItem = /* @__PURE__ */ new Map();
2082
+ const offerBits = /* @__PURE__ */ new Map();
2083
+ for (const { item, offer, value } of candidates) {
2084
+ let bit = offerBits.get(offer.id);
2085
+ if (bit === void 0) {
2086
+ bit = offerBits.size;
2087
+ offerBits.set(offer.id, bit);
2088
+ }
2089
+ const options = (_a = optionsByItem.get(item.id)) != null ? _a : [];
2090
+ options.push({ offerId: offer.id, bit, value });
2091
+ optionsByItem.set(item.id, options);
2092
+ }
2093
+ if (offerBits.size > _CartUtils.searchableOffers)
2094
+ return _CartUtils.mostValuableItemOfferPairings(candidates);
2095
+ const itemIds = [...optionsByItem.keys()];
2096
+ const memo = /* @__PURE__ */ new Map();
2097
+ const from = (index, spent) => {
2098
+ var _a2;
2099
+ if (index >= itemIds.length)
2100
+ return { value: 0, assignments: [] };
2101
+ const key = `${index}:${spent}`;
2102
+ const memoised = memo.get(key);
2103
+ if (memoised)
2104
+ return memoised;
2105
+ let best = from(index + 1, spent);
2106
+ const itemId = itemIds[index];
2107
+ for (const option of (_a2 = optionsByItem.get(itemId)) != null ? _a2 : []) {
2108
+ if (spent & 1 << option.bit)
2109
+ continue;
2110
+ const rest = from(index + 1, spent | 1 << option.bit);
2111
+ const value = option.value + rest.value;
2112
+ if (value > best.value)
2113
+ best = { value, assignments: [{ itemId, offerId: option.offerId }, ...rest.assignments] };
2114
+ }
2115
+ memo.set(key, best);
2116
+ return best;
2117
+ };
2118
+ return from(0, 0).assignments;
2119
+ }
2120
+ // The fallback for a candidate set too large to search: repeatedly take the most valuable pairing
2121
+ // still available. Can strand a better combination, which is the whole reason it is only a fallback.
2122
+ static mostValuableItemOfferPairings(candidates) {
2123
+ const assignments = [];
2124
+ const assignedItemIds = /* @__PURE__ */ new Set();
2125
+ const spentOfferIds = /* @__PURE__ */ new Set();
2126
+ for (const { item, offer } of [...candidates].sort((a, b) => b.value - a.value)) {
2127
+ if (assignedItemIds.has(item.id) || spentOfferIds.has(offer.id))
2128
+ continue;
2129
+ assignedItemIds.add(item.id);
2130
+ spentOfferIds.add(offer.id);
2131
+ assignments.push({ itemId: item.id, offerId: offer.id });
2132
+ }
2133
+ return assignments;
2134
+ }
2135
+ // Every line-and-offer pairing the cart could make, and what each would save. Enumerated in cart
2136
+ // order and then catalog order, which `sort` preserves for equal savings, so an unchanged cart
2137
+ // always resolves the same way.
2138
+ static itemOfferCandidates(catalog, items, currency, context, unavailableOfferIds) {
2139
+ var _a;
2140
+ const candidates = [];
2141
+ for (const item of items) {
2142
+ const unitPrice = _CartUtils.unitPrice(catalog, item, currency);
2143
+ if (unitPrice === void 0)
2144
+ continue;
2145
+ for (const offer of (_a = catalog.offers) != null ? _a : []) {
2146
+ if (offer.scope === "Cart" || (unavailableOfferIds == null ? void 0 : unavailableOfferIds.has(offer.id)))
2147
+ continue;
2148
+ if (!_CartUtils.offerTargetsLine(offer, item))
2149
+ continue;
2150
+ if (!OfferUtils.allTargetsInCart(offer, context.order.items))
2151
+ continue;
2152
+ if (!OfferUtils.eligible(offer, context))
2153
+ continue;
2154
+ const value = _CartUtils.itemOfferUnitDiscount(offer, unitPrice, currency) * item.quantity;
2155
+ if (value <= 0)
2156
+ continue;
2157
+ candidates.push({ item, offer, value });
2158
+ }
2159
+ }
2160
+ return candidates;
2161
+ }
2162
+ // What an item offer takes off one unit of a line. Measured off the post-product-discount price,
2163
+ // which is what an item offer comes off in the API's order: product discount -> line offer -> cart
2164
+ // offer -> coupons.
2165
+ static itemOfferUnitDiscount(offer, unitPrice, currency) {
2166
+ return DiscountUtils.apply(offer.discount, { [currency]: unitPrice })[currency];
2167
+ }
2168
+ // A line's unit price once its own offer has come off, which is what a cart offer is applied to:
2169
+ // the API discounts in the order product discount -> line offer -> cart offer -> coupons. An
2170
+ // unresolvable price counts as zero, matching `total`'s "still render something" stance rather
2171
+ // than `measure`'s "refuse to guess".
2172
+ static unitPriceAfterItemOffer(catalog, item, currency, context) {
2173
+ var _a, _b;
2174
+ const unitPrice = (_a = _CartUtils.unitPrice(catalog, item, currency)) != null ? _a : 0;
2175
+ const itemOffer = item.offerId ? (_b = catalog.offers) == null ? void 0 : _b.find((o) => o.id === item.offerId) : void 0;
2176
+ if (!itemOffer || !OfferUtils.eligible(itemOffer, context))
2177
+ return unitPrice;
2178
+ return unitPrice - _CartUtils.itemOfferUnitDiscount(itemOffer, unitPrice, currency);
2179
+ }
2180
+ };
2181
+ // How many distinct offers can compete for the same cart before the exact search is given up on.
2182
+ // 12 leaves 4096 states per line, which is nothing, and no real storefront has a dozen item offers
2183
+ // eligible against one cart at once.
2184
+ _CartUtils.searchableOffers = 12;
2185
+ var CartUtils = _CartUtils;
2186
+
2187
+ // src/utils/image.ts
2188
+ var imageWidths = [16, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048];
2189
+ var maxImageDimension = imageWidths[imageWidths.length - 1];
2190
+ var transformPathSegment = "_i";
2191
+ var maxCoverAspectRatio = 4;
2192
+ function isAssetUrl(url) {
2193
+ let parsed;
2194
+ try {
2195
+ parsed = new URL(url);
2196
+ } catch (e) {
2197
+ return false;
2198
+ }
2199
+ return parsed.hostname === "assets.moonbase.sh" || parsed.hostname.startsWith("assets.") && parsed.hostname.endsWith(".moonbase.sh");
2200
+ }
2201
+ function isRendition(url) {
2202
+ try {
2203
+ return new URL(url).pathname.startsWith(`/${transformPathSegment}/`);
2204
+ } catch (e) {
2205
+ return false;
2206
+ }
2207
+ }
2208
+ function isTransformableImageUrl(url) {
2209
+ return isAssetUrl(url) && !isRendition(url);
2210
+ }
2211
+ var warned = /* @__PURE__ */ new Set();
2212
+ function warnOnce(reason, url, message) {
2213
+ const key = `${reason}:${url}`;
2214
+ if (warned.has(key))
2215
+ return;
2216
+ warned.add(key);
2217
+ console.warn(message, url);
2218
+ }
2219
+ function snap(value) {
2220
+ var _a;
2221
+ if (value === void 0 || !Number.isFinite(value) || value <= 0)
2222
+ return void 0;
2223
+ return (_a = imageWidths.find((width) => value <= width)) != null ? _a : maxImageDimension;
2224
+ }
2225
+ var ImageUtils = class _ImageUtils {
2226
+ /**
2227
+ * Rewrites an asset URL to ask the CDN for a rendition at the given size.
2228
+ *
2229
+ * Returns the URL untouched — never a broken one — when there is nothing to do: a URL that
2230
+ * isn't a Moonbase asset, one that is already a rendition, an empty transform, or a `cover`
2231
+ * crop too extreme for the CDN to serve. Callers can apply it unconditionally.
2232
+ */
2233
+ static transform(url, transform) {
2234
+ if (!url)
2235
+ return void 0;
2236
+ if (!isAssetUrl(url)) {
2237
+ warnOnce(
2238
+ "host",
2239
+ url,
2240
+ "Only images hosted by Moonbase can be resized on the fly. Serving this one at its original size; host it in Moonbase, or size it yourself before uploading:"
2241
+ );
2242
+ return url;
2243
+ }
2244
+ if (isRendition(url))
2245
+ return url;
2246
+ const query = _ImageUtils.toQuery(transform);
2247
+ if (query === null) {
2248
+ warnOnce(
2249
+ "aspect-ratio",
2250
+ url,
2251
+ `A 'cover' crop more lopsided than ${maxCoverAspectRatio}:1 is not served. Serving this image at its original size instead:`
2252
+ );
2253
+ return url;
2254
+ }
2255
+ if (query.length === 0)
2256
+ return url;
2257
+ const parsed = new URL(url);
2258
+ const separator = parsed.search ? "&" : "?";
2259
+ return `${parsed.origin}/${transformPathSegment}${parsed.pathname}${parsed.search}${separator}${query.map(([key, value]) => `${key}=${value}`).join("&")}`;
2260
+ }
2261
+ /**
2262
+ * A `srcset` covering the given pixel densities, so a retina screen gets the sharper rendition
2263
+ * without every screen paying for it. Pair it with a `src` built from the same transform.
2264
+ */
2265
+ static srcSet(url, transform, ...scales) {
2266
+ if (!url || scales.length === 0)
2267
+ return void 0;
2268
+ return scales.map((scale) => `${_ImageUtils.transform(url, _ImageUtils.atScale(transform, scale))} ${scale}x`).join(", ");
2269
+ }
2270
+ /** The same transform at a different pixel density. */
2271
+ static atScale(transform, scale) {
2272
+ return {
2273
+ ...transform,
2274
+ width: transform.width === void 0 ? void 0 : transform.width * scale,
2275
+ height: transform.height === void 0 ? void 0 : transform.height * scale
2276
+ };
2277
+ }
2278
+ /**
2279
+ * The query parameters for a transform, in the CDN's own order, with defaults left out so two
2280
+ * equivalent transforms always produce the same URL — and therefore the same cache entry.
2281
+ * `null` when the CDN would refuse the transform outright.
2282
+ */
2283
+ static toQuery(transform) {
2284
+ var _a, _b;
2285
+ const fit = (_a = transform.fit) != null ? _a : "contain";
2286
+ const width = snap(transform.width);
2287
+ const height = fit === "cover" ? (_b = snap(transform.height)) != null ? _b : width : snap(transform.height);
2288
+ if (fit === "cover" && width && height) {
2289
+ const ratio = width > height ? width / height : height / width;
2290
+ if (ratio > maxCoverAspectRatio)
2291
+ return null;
2292
+ }
2293
+ const query = [];
2294
+ if (width)
2295
+ query.push(["w", String(width)]);
2296
+ if (height && (fit === "cover" || transform.height !== void 0))
2297
+ query.push(["h", String(height)]);
2298
+ if (fit !== "contain")
2299
+ query.push(["fit", fit]);
2300
+ if (transform.format)
2301
+ query.push(["f", transform.format]);
2302
+ return query;
2303
+ }
2304
+ };
2305
+
1704
2306
  // src/utils/utm.ts
1705
2307
  function parseOffSiteReferrer(rawReferrer, currentOrigin) {
1706
2308
  if (!rawReferrer)
@@ -1747,10 +2349,12 @@ export {
1747
2349
  ActivationRequestStatus,
1748
2350
  ActivationStatus,
1749
2351
  Architecture,
2352
+ CartUtils,
1750
2353
  ConnectableAccountProvider,
1751
2354
  ConsoleLogger,
1752
2355
  CycleLength,
1753
2356
  DiscountUtils,
2357
+ ImageUtils,
1754
2358
  InMemoryStore,
1755
2359
  LicenseStatus,
1756
2360
  LocalStorageStore,
@@ -1768,9 +2372,14 @@ export {
1768
2372
  Platform,
1769
2373
  SubscriptionStatus,
1770
2374
  TokenStore,
2375
+ imageWidths,
2376
+ isPromotionDisplay,
2377
+ isTransformableImageUrl,
2378
+ maxImageDimension,
1771
2379
  objectToQuery,
1772
2380
  parseOffSiteReferrer,
1773
2381
  problemDetailsSchema,
2382
+ promotionDisplays,
1774
2383
  resolveUtm,
1775
2384
  schemas_exports12 as schemas,
1776
2385
  utmToObject