@misiki/gocommerce-connector 0.10.0 → 0.12.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.cjs CHANGED
@@ -765,7 +765,12 @@ function toCartLine(l, extra = {}) {
765
765
  variantTitle: l.variant_label ?? "",
766
766
  qty: l.quantity,
767
767
  price: toMajor(l.unit_price),
768
- mrp: toMajor(l.unit_price),
768
+ // The variant's compare-at price, where the shop set one above what the
769
+ // line costs: what the basket's "you save" is measured from.
770
+ mrp: l.compare_at_price && l.compare_at_price.amount_minor > l.unit_price.amount_minor ? toMajor(l.compare_at_price) : toMajor(l.unit_price),
771
+ /** The add-on this line was bought as (D133), or null on an ordinary line. */
772
+ addOnId: l.add_on_id ? str(l.add_on_id) : null,
773
+ addOnFor: l.add_on_for ? str(l.add_on_for) : null,
769
774
  subtotal: toMajor(l.total),
770
775
  total: toMajor(l.total),
771
776
  inStock: l.in_stock,
@@ -999,6 +1004,45 @@ function toUser(c) {
999
1004
  };
1000
1005
  }
1001
1006
  var priceOf = (m) => m ? toMajor(m) : 0;
1007
+ function toAddOn(a) {
1008
+ return {
1009
+ id: str(a.id),
1010
+ productId: str(a.item.product_id),
1011
+ variantId: str(a.variant_id),
1012
+ slug: a.item.slug,
1013
+ title: a.item.title,
1014
+ variantTitle: a.item.variant_label ?? "",
1015
+ thumbnail: mediaUrl(a.item.image_url ?? null),
1016
+ price: toMajor(a.offer_price ?? a.price),
1017
+ regularPrice: toMajor(a.price),
1018
+ hasOffer: !!a.offer_price && a.offer_price.amount_minor < a.price.amount_minor,
1019
+ inStock: a.item.in_stock
1020
+ };
1021
+ }
1022
+ function toCartOffer(o, currency) {
1023
+ const isPercent = o.kind === "percentage";
1024
+ return {
1025
+ code: o.code,
1026
+ title: o.title,
1027
+ isPercent,
1028
+ freeShipping: o.kind === "free_shipping",
1029
+ /** The percentage for a percentage code, the amount for a fixed one. */
1030
+ value: isPercent ? (o.value_bp ?? 0) / 100 : minorToMajor(o.value_minor ?? 0, currency),
1031
+ minAmount: o.min_subtotal_minor ? minorToMajor(o.min_subtotal_minor, currency) : null,
1032
+ applies: o.applies,
1033
+ amount: o.amount ? toMajor(o.amount) : null,
1034
+ remaining: o.remaining ? toMajor(o.remaining) : null,
1035
+ reason: o.reason ?? ""
1036
+ };
1037
+ }
1038
+ function toFreeShipping(f) {
1039
+ return {
1040
+ qualifies: f.qualifies,
1041
+ threshold: f.threshold ? toMajor(f.threshold) : null,
1042
+ remaining: f.remaining ? toMajor(f.remaining) : null,
1043
+ subtotal: toMajor(f.subtotal)
1044
+ };
1045
+ }
1002
1046
 
1003
1047
  // src/services/facts.ts
1004
1048
  var StoreFacts = class extends BaseService {
@@ -1309,6 +1353,36 @@ var ProductService = class extends CatalogService {
1309
1353
  if (!category) return this.list({ page, limit });
1310
1354
  return this.list({ page, limit, categories: category.slug });
1311
1355
  }
1356
+ /**
1357
+ * What the shop offers with a product, ticked in its buy box (D133): each
1358
+ * add-on at the price ticking it adds. Only add-ons that can be bought
1359
+ * arrive; an engine without the route offers none.
1360
+ */
1361
+ async listAddOns({ product }) {
1362
+ const p = await this.findProduct(String(product));
1363
+ try {
1364
+ return (await this.data("/api/add-ons", { query: { product_id: p.id } }) ?? []).map(toAddOn);
1365
+ } catch (err) {
1366
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1367
+ throw err;
1368
+ }
1369
+ }
1370
+ /**
1371
+ * "You may also like": the products the shop chose for this one, at their
1372
+ * own prices (D133). Curated only, and empty when none were chosen: the
1373
+ * related products already fill in from the category, and the page should
1374
+ * not draw the same fill twice.
1375
+ */
1376
+ async listCrossSells({ product, limit }) {
1377
+ const p = await this.findProduct(String(product));
1378
+ try {
1379
+ const rows = await this.data("/api/cross-sells", { query: { product_id: p.id, limit } }) ?? [];
1380
+ return rows.map((r) => toProduct(r));
1381
+ } catch (err) {
1382
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1383
+ throw err;
1384
+ }
1385
+ }
1312
1386
  async relatedTo(slug, limit) {
1313
1387
  const size = Math.min(100, Math.max(1, Number(limit) || PAGE_SIZE));
1314
1388
  const p = await this.findProduct(slug);
@@ -1648,6 +1722,88 @@ var CartService = class extends BaseService {
1648
1722
  throw err;
1649
1723
  }
1650
1724
  }
1725
+ /**
1726
+ * Adds an add-on from a product's "Complete the set" (D133) at its offer
1727
+ * price. The product it is offered with has to be in the bag already, so
1728
+ * this is called after adding it; the engine refuses one too many with the
1729
+ * sentence the shopper is shown.
1730
+ */
1731
+ async addAddOn({ cartId, addOnId, qty = 1 }) {
1732
+ const id2 = clean(cartId) ?? storedCartId();
1733
+ if (!id2) throw new GoCommerceError("Add the product to your bag first.", 409);
1734
+ return this.present(
1735
+ await this.data(`/api/carts/${encodeURIComponent(id2)}/line-items`, {
1736
+ method: "POST",
1737
+ body: { add_on_id: Number(addOnId), quantity: Number(qty) }
1738
+ })
1739
+ );
1740
+ }
1741
+ /**
1742
+ * The codes the shop chose to show (D133), each with what it takes off this
1743
+ * bag or how much more the bag needs. None without a bag.
1744
+ */
1745
+ async getOffers({ cartId } = {}) {
1746
+ const id2 = clean(cartId) ?? storedCartId();
1747
+ if (!id2) return [];
1748
+ try {
1749
+ const rows = await this.data(`/api/carts/${encodeURIComponent(id2)}/offers`) ?? [];
1750
+ const currency = await this.currency();
1751
+ return rows.map((o) => toCartOffer(o, o.amount?.currency ?? o.remaining?.currency ?? currency));
1752
+ } catch (err) {
1753
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1754
+ throw err;
1755
+ }
1756
+ }
1757
+ /**
1758
+ * How far the bag is from free delivery (D133), read from the store's own
1759
+ * rates for the country (the store's own when none is given). Null when
1760
+ * the engine cannot say: a bar with a made-up figure is worse than none.
1761
+ */
1762
+ async getFreeShipping({ cartId, country, state } = {}) {
1763
+ const id2 = clean(cartId) ?? storedCartId();
1764
+ if (!id2) return null;
1765
+ const where = country ?? cartExtras(id2).shippingAddress?.country ?? void 0;
1766
+ try {
1767
+ return toFreeShipping(
1768
+ await this.data(`/api/carts/${encodeURIComponent(id2)}/free-shipping`, {
1769
+ query: { country: where || void 0, state: state || void 0 }
1770
+ })
1771
+ );
1772
+ } catch (err) {
1773
+ if (err instanceof GoCommerceError && err.status === 404) return null;
1774
+ throw err;
1775
+ }
1776
+ }
1777
+ /**
1778
+ * "You may be interested in" (D133): what the bag's products suggest, less
1779
+ * what it holds; with nothing curated, the first product's related
1780
+ * products, still less what it holds.
1781
+ */
1782
+ async listCrossSells({ cartId, limit = 12 } = {}) {
1783
+ const id2 = clean(cartId) ?? storedCartId();
1784
+ if (!id2) return [];
1785
+ let curated = [];
1786
+ try {
1787
+ curated = await this.data(`/api/carts/${encodeURIComponent(id2)}/cross-sells`, { query: { limit } }) ?? [];
1788
+ } catch (err) {
1789
+ if (!(err instanceof GoCommerceError) || err.status !== 404) throw err;
1790
+ }
1791
+ if (curated.length) return curated.map((p) => toProduct(p));
1792
+ let cart;
1793
+ try {
1794
+ cart = await this.data(`/api/carts/${encodeURIComponent(id2)}`);
1795
+ } catch {
1796
+ return [];
1797
+ }
1798
+ const held = new Set((cart.line_items ?? []).map((l) => String(l.product_id)));
1799
+ const first = cart.line_items?.[0];
1800
+ if (!first) return [];
1801
+ const related = await new ProductService(this._fetch).listRelatedProducts({
1802
+ product: String(first.product_id),
1803
+ limit: limit + held.size
1804
+ });
1805
+ return related.data.filter((p) => !held.has(String(p.id))).slice(0, limit);
1806
+ }
1651
1807
  /** `qty` is the new total; 0 takes the line out. */
1652
1808
  async updateCart({
1653
1809
  cartId,
@@ -2081,7 +2237,20 @@ var paymentMethodService = new PaymentMethodService();
2081
2237
  // src/services/coupon-service.ts
2082
2238
  var CouponService = class extends BaseService {
2083
2239
  async listCoupons(_args = {}) {
2084
- return emptyPage();
2240
+ const offers = await new CartService(this._fetch).getOffers().catch(() => []);
2241
+ if (!offers.length) return emptyPage();
2242
+ const data = offers.map((o) => ({
2243
+ id: o.code,
2244
+ code: o.code,
2245
+ title: o.title,
2246
+ isPercent: o.isPercent,
2247
+ amount: o.value,
2248
+ minAmount: o.minAmount,
2249
+ description: o.applies ? o.title : o.remaining !== null ? `${o.title}. Add ${o.remaining} more to use this.` : o.reason || o.title,
2250
+ applies: o.applies,
2251
+ remaining: o.remaining
2252
+ }));
2253
+ return { data, count: data.length, pageSize: data.length, noOfPage: 1, page: 1 };
2085
2254
  }
2086
2255
  };
2087
2256
  var couponService = new CouponService();
@@ -2679,21 +2848,38 @@ var MenuService = class extends BaseService {
2679
2848
  const m = await this.data(`/x/navigation/menus/${encodeURIComponent(handle)}`);
2680
2849
  return { menuId: m.handle, name: m.title, items: (m.items ?? []).map(toItem) };
2681
2850
  }
2682
- /** The header and footer menus, or `null` when the module is not there. Cached: a menu is store-level. */
2851
+ /**
2852
+ * The header and footer menus, or `null` when the module is not there. Cached: a menu is
2853
+ * store-level. Each is the menu the shop chose for it in the theme editor's header or footer
2854
+ * (GoCommerce D131), still under the `header` / `footer` id every storefront looks for, so the
2855
+ * choice reaches every place that draws one; the menus named so are the default.
2856
+ */
2683
2857
  async engineMenus() {
2684
2858
  if (!await this.facts.pluginOn("navigation")) return null;
2685
2859
  return remember(`${this.engineBase()}|${this.store ?? ""}|menus`, async () => {
2686
2860
  const out = [];
2687
- for (const handle of ["header", "footer"]) {
2861
+ for (const part of ["header", "footer"]) {
2688
2862
  try {
2689
- const m = await this.data(`/x/navigation/menus/${handle}`);
2690
- out.push({ menuId: handle, name: m.title, items: (m.items ?? []).map(toItem) });
2863
+ const handle = await this.chosenMenu(part);
2864
+ const m = await this.data(`/x/navigation/menus/${encodeURIComponent(handle)}`);
2865
+ out.push({ menuId: part, name: m.title, items: (m.items ?? []).map(toItem) });
2691
2866
  } catch {
2692
2867
  }
2693
2868
  }
2694
2869
  return out;
2695
2870
  }).catch(() => null);
2696
2871
  }
2872
+ /** The handle of the menu the published header or footer names, else the part's own name. */
2873
+ async chosenMenu(part) {
2874
+ if (!await this.facts.pluginOn("layouts")) return part;
2875
+ try {
2876
+ const layout = await this.data(`/x/layouts/${part}`);
2877
+ const menu = layout?.sections?.find((s) => s.type === part)?.settings?.menu;
2878
+ return typeof menu === "string" && menu ? menu : part;
2879
+ } catch {
2880
+ return part;
2881
+ }
2882
+ }
2697
2883
  };
2698
2884
  var menuService = new MenuService();
2699
2885
 
@@ -2808,7 +2994,13 @@ var StoreService = class extends BaseService {
2808
2994
  isProductReviewsAndRatings: {
2809
2995
  ...basePlugins.isProductReviewsAndRatings,
2810
2996
  ...flag(plugins.has("product-reviews"))
2811
- }
2997
+ },
2998
+ // The footer's sign-up box posts to ext/newsletter, and the theme editor's footer
2999
+ // switch (D131) can only show it while that is on.
3000
+ newsletter: { ...basePlugins.newsletter, ...flag(plugins.has("newsletter")) },
3001
+ // Discount codes are core: every GoCommerce store takes them, so
3002
+ // the code box is shown whatever the static record says.
3003
+ isDiscountCoupons: { ...basePlugins.isDiscountCoupons, active: true }
2812
3004
  },
2813
3005
  ...menus?.length ? { menu: menus } : {},
2814
3006
  ...deliverable(base, ships),
@@ -2923,6 +3115,35 @@ var LayoutService = class extends BaseService {
2923
3115
  return null;
2924
3116
  }
2925
3117
  }
3118
+ /**
3119
+ * The header the shop set in the theme editor (D131), drawn on every
3120
+ * page: the published one, or a preview key's draft. Null — the
3121
+ * storefront's own header — when none is published, the editor is off, the
3122
+ * engine is older, or anything fails: a header is never worth an error page.
3123
+ */
3124
+ async getHeader({ preview } = {}) {
3125
+ return this.chrome("header", preview);
3126
+ }
3127
+ /** The footer the shop set in the theme editor (D131); null for the storefront's own, as getHeader. */
3128
+ async getFooter({ preview } = {}) {
3129
+ return this.chrome("footer", preview);
3130
+ }
3131
+ async chrome(page, preview) {
3132
+ let layout;
3133
+ try {
3134
+ layout = await this.getPageLayout(page, { preview });
3135
+ } catch {
3136
+ return null;
3137
+ }
3138
+ const section = layout?.sections.find((s) => s.type === page);
3139
+ if (!layout || !section) return null;
3140
+ const settings = { ...section.settings };
3141
+ let menu = null;
3142
+ if (typeof settings.menu === "string" && settings.menu) {
3143
+ menu = await new MenuService(this._fetch).getMenu(settings.menu).catch(() => null);
3144
+ }
3145
+ return { revision: layout.revision, preview: layout.preview, settings, menu };
3146
+ }
2926
3147
  };
2927
3148
  function tokens(v) {
2928
3149
  return v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length ? { ...v } : null;
@@ -3607,12 +3828,15 @@ exports.stateService = stateService;
3607
3828
  exports.staticStore = staticStore;
3608
3829
  exports.storeForHost = storeForHost;
3609
3830
  exports.storeService = storeService;
3831
+ exports.toAddOn = toAddOn;
3610
3832
  exports.toCart = toCart;
3611
3833
  exports.toCartLine = toCartLine;
3834
+ exports.toCartOffer = toCartOffer;
3612
3835
  exports.toCategory = toCategory;
3613
3836
  exports.toCmsPage = toCmsPage;
3614
3837
  exports.toCollection = toCollection;
3615
3838
  exports.toEngineAddress = toEngineAddress;
3839
+ exports.toFreeShipping = toFreeShipping;
3616
3840
  exports.toIdentityAddress = toIdentityAddress;
3617
3841
  exports.toMajor = toMajor;
3618
3842
  exports.toMinor = toMinor;