@moonbase.sh/storefront-api 2.4.0 → 3.0.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
  ])
@@ -180,7 +192,7 @@ var downloadSchema = z2.object({
180
192
  var productSummarySchema = z2.object({
181
193
  id: z2.string(),
182
194
  name: z2.string(),
183
- tagline: z2.string(),
195
+ tagline: z2.string().nullish(),
184
196
  description: z2.string().nullish(),
185
197
  website: z2.string().optional(),
186
198
  iconUrl: z2.string().optional(),
@@ -203,8 +215,8 @@ var productSummarySchema = z2.object({
203
215
  var storefrontProductSchema = z3.object({
204
216
  id: z3.string(),
205
217
  name: z3.string(),
206
- tagline: z3.string(),
207
- description: z3.string(),
218
+ tagline: z3.string().nullish(),
219
+ description: z3.string().nullish(),
208
220
  iconUrl: z3.string().nullable(),
209
221
  website: z3.string().nullish(),
210
222
  owned: z3.boolean(),
@@ -218,8 +230,8 @@ var storefrontProductSchema = z3.object({
218
230
  var storefrontBundleSchema = z3.object({
219
231
  id: z3.string(),
220
232
  name: z3.string(),
221
- tagline: z3.string(),
222
- description: z3.string(),
233
+ tagline: z3.string().nullish(),
234
+ description: z3.string().nullish(),
223
235
  iconUrl: z3.string().nullable(),
224
236
  owned: z3.boolean(),
225
237
  partial: z3.boolean(),
@@ -236,7 +248,22 @@ var cartContainsItemsConditionSchema = z3.object({
236
248
  minimumItems: z3.number(),
237
249
  relevantItemVariations: z3.record(z3.string(), z3.string().array())
238
250
  });
239
- var offerConditionSchema = z3.discriminatedUnion("type", [cartContainsItemsConditionSchema]);
251
+ var cartTotalConditionSchema = z3.object({
252
+ type: z3.literal("CartTotal"),
253
+ // Both bounds are optional, but never both absent. They serialize as an explicit `null` when
254
+ // unset, hence `.nullish()` rather than `.optional()`. The API converts them into every enabled
255
+ // currency up front, precisely so we can tell the buyer how much further they have to go without
256
+ // doing any currency conversion ourselves.
257
+ minimumTotal: priceCollectionSchema.nullish(),
258
+ maximumTotal: priceCollectionSchema.nullish()
259
+ });
260
+ var knownOfferConditionSchema = z3.discriminatedUnion("type", [
261
+ cartContainsItemsConditionSchema,
262
+ cartTotalConditionSchema
263
+ ]);
264
+ var unsupportedOfferConditionSchema = z3.object({ type: z3.string() }).passthrough().transform(({ type }) => ({ type: "Unsupported", unsupportedType: type }));
265
+ var offerConditionSchema = z3.union([knownOfferConditionSchema, unsupportedOfferConditionSchema]);
266
+ var offerScopeSchema = z3.enum(["Item", "Cart"]).catch("Item").optional().default("Item");
240
267
  var offerTargetItemSchema = z3.union([storefrontProductSchema, storefrontBundleSchema]);
241
268
  var storefrontOfferTargetSchema = z3.object({
242
269
  // The product or bundle the offer applies to.
@@ -252,8 +279,10 @@ function legacyOfferTargets(target, targetVariations) {
252
279
  }
253
280
  var storefrontOfferSchema = z3.object({
254
281
  id: z3.string(),
282
+ scope: offerScopeSchema,
255
283
  // 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.
284
+ // list means all variations). This is the model to consume. A cart offer may have no targets at
285
+ // all, which means it discounts every line item.
257
286
  targets: z3.array(storefrontOfferTargetSchema).optional(),
258
287
  // Legacy fields, kept optional only so old cached storefront responses still parse. The API now
259
288
  // exposes the unified `targets` model; we derive `targets` from these when a response predates it.
@@ -267,6 +296,57 @@ var storefrontOfferSchema = z3.object({
267
296
  ...rest,
268
297
  targets: targets != null ? targets : legacyOfferTargets(target, targetVariations)
269
298
  }));
299
+ var storefrontPromotionTargetSchema = z3.object({
300
+ // A prefixed reference to the item the sale covers, `Product/<id>` or `Bundle/<id>`. The item
301
+ // itself is already serialized in `products`/`bundles`; embedding it again would duplicate the
302
+ // bulk of the payload for a sale spanning dozens of products.
303
+ referenceId: z3.string(),
304
+ // The pricing variation IDs the sale is restricted to; an empty list means all variations.
305
+ variations: z3.string().array()
306
+ });
307
+ var storefrontPromotionCtaSchema = z3.object({
308
+ url: z3.string(),
309
+ label: z3.string()
310
+ });
311
+ var storefrontPromotionSchema = z3.object({
312
+ id: z3.string(),
313
+ name: z3.string(),
314
+ description: z3.string().nullish(),
315
+ imageUrl: z3.string().nullish(),
316
+ // Absent when the merchant has not set one, in which case the storefront decides whether the
317
+ // sale leads anywhere.
318
+ cta: storefrontPromotionCtaSchema.nullish(),
319
+ // How the merchant wants the sale surfaced, e.g. `['Banner', 'Popup']`; an empty list means no
320
+ // preference. Kept as free-form strings and narrowed with `isPromotionDisplay`, so a display
321
+ // mode added server-side later is ignored rather than failing the whole storefront.
322
+ display: z3.string().array(),
323
+ // The promotion's *intended* scope, not what is actually showing the sale price: each variation
324
+ // displays only its single best discount, so a targeted item may be showing a better one. The
325
+ // authoritative signal is `promotionId` on a variation's own discount.
326
+ targets: storefrontPromotionTargetSchema.array(),
327
+ // The promotion's own discount, for banner copy ("20% off"). Per-item prices are already
328
+ // discounted on each variation.
329
+ discount: discountSchema,
330
+ validFrom: z3.string().nullish(),
331
+ validUntil: z3.string().nullish(),
332
+ properties: z3.record(rawPropertyValueSchema).nullish()
333
+ });
334
+ var storefrontOffersSchema = z3.array(z3.unknown()).transform((offers) => offers.flatMap((offer) => {
335
+ const parsed = storefrontOfferSchema.safeParse(offer);
336
+ if (!parsed.success) {
337
+ console.warn("Ignoring storefront offer that could not be parsed:", parsed.error.issues);
338
+ return [];
339
+ }
340
+ return [parsed.data];
341
+ }));
342
+ var storefrontPromotionsSchema = z3.array(z3.unknown()).transform((promotions) => promotions.flatMap((promotion) => {
343
+ const parsed = storefrontPromotionSchema.safeParse(promotion);
344
+ if (!parsed.success) {
345
+ console.warn("Ignoring storefront promotion that could not be parsed:", parsed.error.issues);
346
+ return [];
347
+ }
348
+ return [parsed.data];
349
+ }));
270
350
  var taxEstimateSchema = z3.object({
271
351
  rate: z3.number(),
272
352
  mode: z3.enum(["Exclusive", "Inclusive"]),
@@ -280,7 +360,9 @@ var storefrontSchema = z3.object({
280
360
  products: storefrontProductSchema.array(),
281
361
  bundles: storefrontBundleSchema.array(),
282
362
  // Offers need to be optional since we may still have old, cached representations in browsers
283
- offers: storefrontOfferSchema.array().optional(),
363
+ offers: storefrontOffersSchema.optional(),
364
+ // Promotions need to be optional since we may still have old, cached representations in browsers
365
+ promotions: storefrontPromotionsSchema.optional(),
284
366
  // Depending on circumstances, we may have a tax estimate available
285
367
  estimatedTax: taxEstimateSchema.optional()
286
368
  });
@@ -1026,6 +1108,7 @@ import { z as z13 } from "zod";
1026
1108
  var schemas_exports7 = {};
1027
1109
  __export(schemas_exports7, {
1028
1110
  completedOrderSchema: () => completedOrderSchema,
1111
+ lineItemTotalSchema: () => lineItemTotalSchema,
1029
1112
  openBundleLineItem: () => openBundleLineItem,
1030
1113
  openOrderLineItem: () => openOrderLineItem,
1031
1114
  openOrderSchema: () => openOrderSchema,
@@ -1054,14 +1137,17 @@ var couponSchema = z12.object({
1054
1137
  var lineItemProductSchema = z12.object({
1055
1138
  id: z12.string(),
1056
1139
  name: z12.string(),
1057
- tagline: z12.string(),
1140
+ tagline: z12.string().nullish(),
1058
1141
  iconUrl: z12.string().nullable()
1059
1142
  });
1060
1143
  var lineItemTotalSchema = z12.object({
1061
1144
  original: moneySchema,
1062
1145
  discount: moneySchema,
1063
1146
  subtotal: moneySchema,
1064
- due: moneySchema
1147
+ due: moneySchema,
1148
+ // This line's per-unit share of the order's cart offer, kept apart from the offer the buyer picked
1149
+ // for this item. Both are part of `discount`. Absent on orders priced before cart offers existed.
1150
+ cartOfferDiscount: moneySchema.optional()
1065
1151
  });
1066
1152
  var openProductLineItem = z12.object({
1067
1153
  id: z12.string(),
@@ -1081,7 +1167,7 @@ var openProductLineItem = z12.object({
1081
1167
  var lineItemBundleSchema = z12.object({
1082
1168
  id: z12.string(),
1083
1169
  name: z12.string(),
1084
- tagline: z12.string(),
1170
+ tagline: z12.string().nullish(),
1085
1171
  iconUrl: z12.string().nullable(),
1086
1172
  products: lineItemProductSchema.and(z12.object({
1087
1173
  included: z12.boolean().optional()
@@ -1113,7 +1199,10 @@ var orderTotalSchema = z12.object({
1113
1199
  discount: moneySchema,
1114
1200
  subtotal: moneySchema,
1115
1201
  taxes: moneySchema,
1116
- due: moneySchema
1202
+ due: moneySchema,
1203
+ // How much of `discount` came from the cart offer, so it can be shown as one order-wide saving
1204
+ // rather than a share on every line. Absent when no cart offer applied.
1205
+ cartOfferDiscount: moneySchema.optional()
1117
1206
  });
1118
1207
  var openOrderSchema = z12.object({
1119
1208
  id: z12.string(),
@@ -1121,6 +1210,12 @@ var openOrderSchema = z12.object({
1121
1210
  total: orderTotalSchema.nullish(),
1122
1211
  items: openOrderLineItem.array(),
1123
1212
  couponsApplied: couponSchema.array(),
1213
+ // The cart offer on the order as a whole: the buyer's intent. It is part of the cart payload, so
1214
+ // a content push that leaves it out clears it. The API echoes it back even while the cart sits
1215
+ // below the offer's threshold.
1216
+ offerId: z12.string().optional(),
1217
+ // The cart offer as actually applied: the result. Absent while the offer's condition doesn't hold.
1218
+ appliedOffer: discountSchema.optional(),
1124
1219
  checkoutUrl: z12.string().optional(),
1125
1220
  hostedCheckoutUrl: z12.string().optional(),
1126
1221
  embeddedCheckoutUrl: z12.string().optional()
@@ -1140,7 +1235,8 @@ var completedOrderSchema = z12.object({
1140
1235
  customer: customerSnapshotSchema,
1141
1236
  total: orderTotalSchema,
1142
1237
  items: openOrderLineItem.array(),
1143
- couponsApplied: couponSchema.array()
1238
+ couponsApplied: couponSchema.array(),
1239
+ appliedOffer: discountSchema.optional()
1144
1240
  });
1145
1241
  var orderSchema = z12.discriminatedUnion("status", [
1146
1242
  openOrderSchema.extend({
@@ -1298,6 +1394,7 @@ var OrderEndpoints = class {
1298
1394
  method: "PATCH",
1299
1395
  body: {
1300
1396
  currency: order.currency || void 0,
1397
+ offerId: order.offerId,
1301
1398
  items: order.items
1302
1399
  }
1303
1400
  }
@@ -1312,6 +1409,12 @@ var OrderEndpoints = class {
1312
1409
  const response = await this.api.fetch(`/api/customer/orders/${orderId}/billing-details`, openOrderSchema, { method: "DELETE" });
1313
1410
  return response.data;
1314
1411
  }
1412
+ // Takes the cart offer off the order without re-sending the cart. Offers picked for a single item
1413
+ // come off by pushing that item without its `offerId` instead.
1414
+ async removeOffer(orderId) {
1415
+ const response = await this.api.fetch(`/api/customer/orders/${orderId}/offer`, openOrderSchema, { method: "DELETE" });
1416
+ return response.data;
1417
+ }
1315
1418
  async addCouponCode(orderId, code) {
1316
1419
  const response = await this.api.fetch(`/api/customer/orders/${orderId}/coupon?code=${encodeURIComponent(code)}`, openOrderSchema, { method: "PATCH" });
1317
1420
  return response.data;
@@ -1616,6 +1719,12 @@ __export(schemas_exports11, {
1616
1719
  subscriptions: () => schemas_exports8
1617
1720
  });
1618
1721
 
1722
+ // src/storefront/models.ts
1723
+ var promotionDisplays = ["Banner", "Popup"];
1724
+ function isPromotionDisplay(value) {
1725
+ return promotionDisplays.includes(value);
1726
+ }
1727
+
1619
1728
  // src/utils/discount.ts
1620
1729
  var DiscountUtils = class {
1621
1730
  static apply(discount, price) {
@@ -1642,25 +1751,68 @@ var MoneyCollectionUtils = class {
1642
1751
  };
1643
1752
 
1644
1753
  // src/utils/offer.ts
1754
+ function evaluated(unit, currency, current, target, eligible) {
1755
+ return {
1756
+ eligible,
1757
+ progress: {
1758
+ unit,
1759
+ currency,
1760
+ current,
1761
+ target,
1762
+ remaining: Math.max(0, target - current),
1763
+ fraction: target <= 0 ? 1 : Math.min(1, Math.max(0, current / target)),
1764
+ eligible
1765
+ }
1766
+ };
1767
+ }
1645
1768
  var OfferUtils = class _OfferUtils {
1646
- static eligible(offer, order) {
1647
- switch (offer.condition.type) {
1769
+ // Callers that only have an order keep working and simply have no cart total, so a monetary
1770
+ // condition fails closed for them. Pass the full context to have `CartTotal` actually evaluate.
1771
+ static eligible(offer, context) {
1772
+ return _OfferUtils.evaluate(offer, context).eligible;
1773
+ }
1774
+ // How close the cart is to satisfying the offer, or `null` when there is nothing to show progress
1775
+ // toward: an unsupported condition, an unknown cart total, or a condition with only an upper
1776
+ // bound (which the buyer starts out satisfying rather than working toward).
1777
+ static progress(offer, context) {
1778
+ return _OfferUtils.evaluate(offer, context).progress;
1779
+ }
1780
+ // The single evaluator behind both faces, so the "spend €12 more" nudge and the gate that unlocks
1781
+ // the discount can never disagree about the same cart.
1782
+ static evaluate(offer, context) {
1783
+ var _a, _b;
1784
+ const { order, cartTotal } = "items" in context ? { order: context, cartTotal: void 0 } : context;
1785
+ const condition = offer.condition;
1786
+ switch (condition.type) {
1648
1787
  case "CartContainsItems": {
1649
1788
  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)
1789
+ (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
1790
  );
1652
1791
  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 : [] : [];
1792
+ var _a2;
1793
+ return offer.id === i.offerId && i.type === "Bundle" && _OfferUtils.targetContainsBundle(offer, i.id) ? (_a2 = i.replaced) != null ? _a2 : [] : [];
1655
1794
  }).filter((ref) => {
1656
1795
  const [productId, variationId] = ref.split("/");
1657
- return offer.condition.relevantItemVariations[`Product/${productId}`] && offer.condition.relevantItemVariations[`Product/${productId}`].includes(variationId);
1796
+ return condition.relevantItemVariations[`Product/${productId}`] && condition.relevantItemVariations[`Product/${productId}`].includes(variationId);
1658
1797
  });
1659
- return relevantItems.length + relevantReplacedItems.length >= offer.condition.minimumItems;
1798
+ const current = relevantItems.length + relevantReplacedItems.length;
1799
+ return evaluated("Items", void 0, current, condition.minimumItems, current >= condition.minimumItems);
1800
+ }
1801
+ case "CartTotal": {
1802
+ if (!cartTotal)
1803
+ return { eligible: false, progress: null };
1804
+ const minimum = (_a = condition.minimumTotal) == null ? void 0 : _a[cartTotal.currency];
1805
+ const maximum = (_b = condition.maximumTotal) == null ? void 0 : _b[cartTotal.currency];
1806
+ if (minimum === void 0 && maximum === void 0)
1807
+ return { eligible: false, progress: null };
1808
+ const eligible = (minimum === void 0 || cartTotal.amount >= minimum) && (maximum === void 0 || cartTotal.amount <= maximum);
1809
+ if (minimum === void 0)
1810
+ return { eligible, progress: null };
1811
+ return evaluated("Money", cartTotal.currency, cartTotal.amount, minimum, eligible);
1660
1812
  }
1661
1813
  }
1662
- console.warn("Unsupported offer condition found:", offer.condition);
1663
- return false;
1814
+ console.warn("Unsupported offer condition found:", condition);
1815
+ return { eligible: false, progress: null };
1664
1816
  }
1665
1817
  // The relevant targets of an offer with more than a single target needs to:
1666
1818
  // a. Not be owned, or all targets need to be owned
@@ -1701,6 +1853,276 @@ var OfferUtils = class _OfferUtils {
1701
1853
  }
1702
1854
  };
1703
1855
 
1856
+ // src/utils/cart.ts
1857
+ var CartUtils = class _CartUtils {
1858
+ // The pricing variation a line item points at, falling back to the item's default variation when
1859
+ // the variation id doesn't resolve, which is what the cart has always displayed.
1860
+ static resolveVariation(catalog, item) {
1861
+ var _a, _b;
1862
+ const source = item.type === "Product" ? catalog.products.find((p) => p.id === item.productId) : catalog.bundles.find((b) => b.id === item.bundleId);
1863
+ 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;
1864
+ }
1865
+ // A line's unit price after its product discount and any pricing tier, and *before* any offer.
1866
+ // This is the basis the API measures cart total conditions on, which is why it reads the catalog
1867
+ // rather than the line item's own `price`: `enrichLineItems` rewrites that to the offer-discounted
1868
+ // price, and reading it here would take the offer off twice.
1869
+ static unitPrice(catalog, item, currency) {
1870
+ var _a;
1871
+ return (_a = _CartUtils.unitPricing(catalog, item, currency)) == null ? void 0 : _a.price;
1872
+ }
1873
+ // A line's list and post-product-discount unit prices, from whichever pricing tier applies. Both
1874
+ // come from the same tier so they can be subtracted from one another: taking the list price off one
1875
+ // tier and the paid price off another would invent a discount neither of them offers.
1876
+ static unitPricing(catalog, item, currency) {
1877
+ var _a, _b, _c, _d, _e, _f;
1878
+ const variation = _CartUtils.resolveVariation(catalog, item);
1879
+ const price = variation == null ? void 0 : variation.price[currency];
1880
+ if (price === void 0 || variation === void 0)
1881
+ return void 0;
1882
+ const eligiblePricingTiers = (_b = (_a = variation.pricingTiers) == null ? void 0 : _a.filter((tier) => item.quantity >= tier.minQuantity)) != null ? _b : [];
1883
+ const matchingPricingTier = eligiblePricingTiers.length > 0 ? eligiblePricingTiers[eligiblePricingTiers.length - 1] : null;
1884
+ const tierPrice = matchingPricingTier == null ? void 0 : matchingPricingTier.price[currency];
1885
+ if (tierPrice !== void 0 && tierPrice < price) {
1886
+ return {
1887
+ price: tierPrice,
1888
+ originalPrice: (_d = (_c = matchingPricingTier == null ? void 0 : matchingPricingTier.originalPrice) == null ? void 0 : _c[currency]) != null ? _d : tierPrice
1889
+ };
1890
+ }
1891
+ return { price, originalPrice: (_f = (_e = variation.originalPrice) == null ? void 0 : _e[currency]) != null ? _f : price };
1892
+ }
1893
+ // The number the API measures cart conditions against: each line's price after its product
1894
+ // discount, times quantity, before offers and coupons. `undefined` when any line's price can't be
1895
+ // resolved in `currency`. One unreadable line makes the whole measurement a guess, and a guess
1896
+ // must not unlock an offer.
1897
+ static measure(catalog, items, currency) {
1898
+ if (!currency)
1899
+ return void 0;
1900
+ let amount = 0;
1901
+ for (const item of items) {
1902
+ const unitPrice = _CartUtils.unitPrice(catalog, item, currency);
1903
+ if (unitPrice === void 0)
1904
+ return void 0;
1905
+ amount += unitPrice * item.quantity;
1906
+ }
1907
+ return { currency, amount };
1908
+ }
1909
+ static conditionContext(catalog, order, currency) {
1910
+ return { order, cartTotal: _CartUtils.measure(catalog, order.items, currency) };
1911
+ }
1912
+ // Which lines a cart offer discounts. No targets means the whole cart, a reading that only holds
1913
+ // for cart-scope offers, where an empty target list is the API's way of saying "every line".
1914
+ static cartOfferAppliesToLine(offer, item) {
1915
+ if (offer.targets.length === 0)
1916
+ return true;
1917
+ 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)));
1918
+ }
1919
+ // The cart offer actually discounting this cart, as opposed to the one attached to the order. An
1920
+ // offer whose targets name nothing in the cart discounts nothing however healthy the total looks,
1921
+ // so it has not really applied.
1922
+ static appliedCartOffer(catalog, order, context) {
1923
+ var _a;
1924
+ if (!order.offerId)
1925
+ return void 0;
1926
+ const offer = (_a = catalog.offers) == null ? void 0 : _a.find((o) => o.id === order.offerId);
1927
+ if (!offer || offer.scope !== "Cart" || !OfferUtils.eligible(offer, context))
1928
+ return void 0;
1929
+ return order.items.some((item) => _CartUtils.cartOfferAppliesToLine(offer, item)) ? offer : void 0;
1930
+ }
1931
+ // What the cart is worth to the buyer, broken down so the cart can show the saving on its own row.
1932
+ // Unlike `measure`, an unresolvable price counts as zero here: this number fills a label, so a
1933
+ // half-loaded catalog should still render a total rather than nothing.
1934
+ static total(catalog, order, currency) {
1935
+ var _a, _b;
1936
+ const context = _CartUtils.conditionContext(catalog, order, currency);
1937
+ const cartOffer = _CartUtils.appliedCartOffer(catalog, order, context);
1938
+ let original = 0;
1939
+ let subtotal = 0;
1940
+ let cartOfferDiscount = 0;
1941
+ for (const item of order.items) {
1942
+ const unitPrice = _CartUtils.unitPriceAfterItemOffer(catalog, item, currency, context);
1943
+ original += ((_b = (_a = _CartUtils.unitPricing(catalog, item, currency)) == null ? void 0 : _a.originalPrice) != null ? _b : unitPrice) * item.quantity;
1944
+ subtotal += unitPrice * item.quantity;
1945
+ if (cartOffer && _CartUtils.cartOfferAppliesToLine(cartOffer, item))
1946
+ cartOfferDiscount += DiscountUtils.apply(cartOffer.discount, { [currency]: unitPrice })[currency] * item.quantity;
1947
+ }
1948
+ const amount = subtotal - cartOfferDiscount;
1949
+ return {
1950
+ currency,
1951
+ original,
1952
+ subtotal,
1953
+ cartOffer: cartOffer ? { offer: cartOffer, discount: cartOfferDiscount } : null,
1954
+ discount: original - amount,
1955
+ amount
1956
+ };
1957
+ }
1958
+ // The cart offer worth attaching to the order. Cart offers are always percentages, but 10% off
1959
+ // everything can still beat 50% off one cheap line, so they are ranked by what they would actually
1960
+ // take off this cart rather than by percentage.
1961
+ static bestCartOffer(catalog, order, currency) {
1962
+ var _a;
1963
+ const context = _CartUtils.conditionContext(catalog, order, currency);
1964
+ let best;
1965
+ for (const offer of (_a = catalog.offers) != null ? _a : []) {
1966
+ if (offer.scope !== "Cart" || !OfferUtils.eligible(offer, context))
1967
+ continue;
1968
+ const value = _CartUtils.cartOfferValue(catalog, order, offer, currency, context);
1969
+ if (value <= 0)
1970
+ continue;
1971
+ if (!best || value > best.value || value === best.value && offer.id === order.offerId)
1972
+ best = { offer, value };
1973
+ }
1974
+ return best == null ? void 0 : best.offer;
1975
+ }
1976
+ /**
1977
+ * What a cart offer would take off this cart, whether or not its condition is met yet.
1978
+ *
1979
+ * Computed on the same post-line-offer price `total` discounts, so "which offer do we attach" and
1980
+ * "what does the cart actually save" can never disagree. Ranking on the list price instead would
1981
+ * over-value an offer aimed at a line that already carries a steep offer of its own: 50% of a
1982
+ * line discounted to a tenth of its price is worth less than 10% of a full-price one.
1983
+ */
1984
+ static cartOfferValue(catalog, order, offer, currency, context) {
1985
+ const conditionContext = context != null ? context : _CartUtils.conditionContext(catalog, order, currency);
1986
+ return order.items.reduce((aggregate, item) => {
1987
+ if (!_CartUtils.cartOfferAppliesToLine(offer, item))
1988
+ return aggregate;
1989
+ const unitPrice = _CartUtils.unitPriceAfterItemOffer(catalog, item, currency, conditionContext);
1990
+ return aggregate + DiscountUtils.apply(offer.discount, { [currency]: unitPrice })[currency] * item.quantity;
1991
+ }, 0);
1992
+ }
1993
+ // A line's unit price once its own offer has come off, which is what a cart offer is applied to:
1994
+ // the API discounts in the order product discount -> line offer -> cart offer -> coupons. An
1995
+ // unresolvable price counts as zero, matching `total`'s "still render something" stance rather
1996
+ // than `measure`'s "refuse to guess".
1997
+ static unitPriceAfterItemOffer(catalog, item, currency, context) {
1998
+ var _a, _b;
1999
+ const unitPrice = (_a = _CartUtils.unitPrice(catalog, item, currency)) != null ? _a : 0;
2000
+ const itemOffer = item.offerId ? (_b = catalog.offers) == null ? void 0 : _b.find((o) => o.id === item.offerId) : void 0;
2001
+ if (!itemOffer || !OfferUtils.eligible(itemOffer, context))
2002
+ return unitPrice;
2003
+ return unitPrice - DiscountUtils.apply(itemOffer.discount, { [currency]: unitPrice })[currency];
2004
+ }
2005
+ };
2006
+
2007
+ // src/utils/image.ts
2008
+ var imageWidths = [16, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048];
2009
+ var maxImageDimension = imageWidths[imageWidths.length - 1];
2010
+ var transformPathSegment = "_i";
2011
+ var maxCoverAspectRatio = 4;
2012
+ function isAssetUrl(url) {
2013
+ let parsed;
2014
+ try {
2015
+ parsed = new URL(url);
2016
+ } catch (e) {
2017
+ return false;
2018
+ }
2019
+ return parsed.hostname === "assets.moonbase.sh" || parsed.hostname.startsWith("assets.") && parsed.hostname.endsWith(".moonbase.sh");
2020
+ }
2021
+ function isRendition(url) {
2022
+ try {
2023
+ return new URL(url).pathname.startsWith(`/${transformPathSegment}/`);
2024
+ } catch (e) {
2025
+ return false;
2026
+ }
2027
+ }
2028
+ function isTransformableImageUrl(url) {
2029
+ return isAssetUrl(url) && !isRendition(url);
2030
+ }
2031
+ var warned = /* @__PURE__ */ new Set();
2032
+ function warnOnce(reason, url, message) {
2033
+ const key = `${reason}:${url}`;
2034
+ if (warned.has(key))
2035
+ return;
2036
+ warned.add(key);
2037
+ console.warn(message, url);
2038
+ }
2039
+ function snap(value) {
2040
+ var _a;
2041
+ if (value === void 0 || !Number.isFinite(value) || value <= 0)
2042
+ return void 0;
2043
+ return (_a = imageWidths.find((width) => value <= width)) != null ? _a : maxImageDimension;
2044
+ }
2045
+ var ImageUtils = class _ImageUtils {
2046
+ /**
2047
+ * Rewrites an asset URL to ask the CDN for a rendition at the given size.
2048
+ *
2049
+ * Returns the URL untouched — never a broken one — when there is nothing to do: a URL that
2050
+ * isn't a Moonbase asset, one that is already a rendition, an empty transform, or a `cover`
2051
+ * crop too extreme for the CDN to serve. Callers can apply it unconditionally.
2052
+ */
2053
+ static transform(url, transform) {
2054
+ if (!url)
2055
+ return void 0;
2056
+ if (!isAssetUrl(url)) {
2057
+ warnOnce(
2058
+ "host",
2059
+ url,
2060
+ "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:"
2061
+ );
2062
+ return url;
2063
+ }
2064
+ if (isRendition(url))
2065
+ return url;
2066
+ const query = _ImageUtils.toQuery(transform);
2067
+ if (query === null) {
2068
+ warnOnce(
2069
+ "aspect-ratio",
2070
+ url,
2071
+ `A 'cover' crop more lopsided than ${maxCoverAspectRatio}:1 is not served. Serving this image at its original size instead:`
2072
+ );
2073
+ return url;
2074
+ }
2075
+ if (query.length === 0)
2076
+ return url;
2077
+ const parsed = new URL(url);
2078
+ const separator = parsed.search ? "&" : "?";
2079
+ return `${parsed.origin}/${transformPathSegment}${parsed.pathname}${parsed.search}${separator}${query.map(([key, value]) => `${key}=${value}`).join("&")}`;
2080
+ }
2081
+ /**
2082
+ * A `srcset` covering the given pixel densities, so a retina screen gets the sharper rendition
2083
+ * without every screen paying for it. Pair it with a `src` built from the same transform.
2084
+ */
2085
+ static srcSet(url, transform, ...scales) {
2086
+ if (!url || scales.length === 0)
2087
+ return void 0;
2088
+ return scales.map((scale) => `${_ImageUtils.transform(url, _ImageUtils.atScale(transform, scale))} ${scale}x`).join(", ");
2089
+ }
2090
+ /** The same transform at a different pixel density. */
2091
+ static atScale(transform, scale) {
2092
+ return {
2093
+ ...transform,
2094
+ width: transform.width === void 0 ? void 0 : transform.width * scale,
2095
+ height: transform.height === void 0 ? void 0 : transform.height * scale
2096
+ };
2097
+ }
2098
+ /**
2099
+ * The query parameters for a transform, in the CDN's own order, with defaults left out so two
2100
+ * equivalent transforms always produce the same URL — and therefore the same cache entry.
2101
+ * `null` when the CDN would refuse the transform outright.
2102
+ */
2103
+ static toQuery(transform) {
2104
+ var _a, _b;
2105
+ const fit = (_a = transform.fit) != null ? _a : "contain";
2106
+ const width = snap(transform.width);
2107
+ const height = fit === "cover" ? (_b = snap(transform.height)) != null ? _b : width : snap(transform.height);
2108
+ if (fit === "cover" && width && height) {
2109
+ const ratio = width > height ? width / height : height / width;
2110
+ if (ratio > maxCoverAspectRatio)
2111
+ return null;
2112
+ }
2113
+ const query = [];
2114
+ if (width)
2115
+ query.push(["w", String(width)]);
2116
+ if (height && (fit === "cover" || transform.height !== void 0))
2117
+ query.push(["h", String(height)]);
2118
+ if (fit !== "contain")
2119
+ query.push(["fit", fit]);
2120
+ if (transform.format)
2121
+ query.push(["f", transform.format]);
2122
+ return query;
2123
+ }
2124
+ };
2125
+
1704
2126
  // src/utils/utm.ts
1705
2127
  function parseOffSiteReferrer(rawReferrer, currentOrigin) {
1706
2128
  if (!rawReferrer)
@@ -1747,10 +2169,12 @@ export {
1747
2169
  ActivationRequestStatus,
1748
2170
  ActivationStatus,
1749
2171
  Architecture,
2172
+ CartUtils,
1750
2173
  ConnectableAccountProvider,
1751
2174
  ConsoleLogger,
1752
2175
  CycleLength,
1753
2176
  DiscountUtils,
2177
+ ImageUtils,
1754
2178
  InMemoryStore,
1755
2179
  LicenseStatus,
1756
2180
  LocalStorageStore,
@@ -1768,9 +2192,14 @@ export {
1768
2192
  Platform,
1769
2193
  SubscriptionStatus,
1770
2194
  TokenStore,
2195
+ imageWidths,
2196
+ isPromotionDisplay,
2197
+ isTransformableImageUrl,
2198
+ maxImageDimension,
1771
2199
  objectToQuery,
1772
2200
  parseOffSiteReferrer,
1773
2201
  problemDetailsSchema,
2202
+ promotionDisplays,
1774
2203
  resolveUtm,
1775
2204
  schemas_exports12 as schemas,
1776
2205
  utmToObject
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@moonbase.sh/storefront-api",
3
3
  "type": "module",
4
- "version": "2.4.0",
4
+ "version": "3.0.0",
5
5
  "description": "Package to let you build storefronts with Moonbase.sh as payment and delivery provider",
6
6
  "author": "Tobias Lønnerød Madsen <m@dsen.tv>",
7
7
  "license": "MIT",