@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.
@@ -733,7 +733,12 @@ function toCartLine(l, extra = {}) {
733
733
  variantTitle: l.variant_label ?? "",
734
734
  qty: l.quantity,
735
735
  price: toMajor(l.unit_price),
736
- mrp: toMajor(l.unit_price),
736
+ // The variant's compare-at price, where the shop set one above what the
737
+ // line costs: what the basket's "you save" is measured from.
738
+ 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),
739
+ /** The add-on this line was bought as (D133), or null on an ordinary line. */
740
+ addOnId: l.add_on_id ? str(l.add_on_id) : null,
741
+ addOnFor: l.add_on_for ? str(l.add_on_for) : null,
737
742
  subtotal: toMajor(l.total),
738
743
  total: toMajor(l.total),
739
744
  inStock: l.in_stock,
@@ -966,6 +971,45 @@ function toUser(c) {
966
971
  updatedAt: c.updated_at
967
972
  };
968
973
  }
974
+ function toAddOn(a) {
975
+ return {
976
+ id: str(a.id),
977
+ productId: str(a.item.product_id),
978
+ variantId: str(a.variant_id),
979
+ slug: a.item.slug,
980
+ title: a.item.title,
981
+ variantTitle: a.item.variant_label ?? "",
982
+ thumbnail: mediaUrl(a.item.image_url ?? null),
983
+ price: toMajor(a.offer_price ?? a.price),
984
+ regularPrice: toMajor(a.price),
985
+ hasOffer: !!a.offer_price && a.offer_price.amount_minor < a.price.amount_minor,
986
+ inStock: a.item.in_stock
987
+ };
988
+ }
989
+ function toCartOffer(o, currency) {
990
+ const isPercent = o.kind === "percentage";
991
+ return {
992
+ code: o.code,
993
+ title: o.title,
994
+ isPercent,
995
+ freeShipping: o.kind === "free_shipping",
996
+ /** The percentage for a percentage code, the amount for a fixed one. */
997
+ value: isPercent ? (o.value_bp ?? 0) / 100 : minorToMajor(o.value_minor ?? 0, currency),
998
+ minAmount: o.min_subtotal_minor ? minorToMajor(o.min_subtotal_minor, currency) : null,
999
+ applies: o.applies,
1000
+ amount: o.amount ? toMajor(o.amount) : null,
1001
+ remaining: o.remaining ? toMajor(o.remaining) : null,
1002
+ reason: o.reason ?? ""
1003
+ };
1004
+ }
1005
+ function toFreeShipping(f) {
1006
+ return {
1007
+ qualifies: f.qualifies,
1008
+ threshold: f.threshold ? toMajor(f.threshold) : null,
1009
+ remaining: f.remaining ? toMajor(f.remaining) : null,
1010
+ subtotal: toMajor(f.subtotal)
1011
+ };
1012
+ }
969
1013
 
970
1014
  // src/cache.ts
971
1015
  var TTL_MS = 6e4;
@@ -1290,6 +1334,36 @@ var ProductService = class extends CatalogService {
1290
1334
  if (!category) return this.list({ page, limit });
1291
1335
  return this.list({ page, limit, categories: category.slug });
1292
1336
  }
1337
+ /**
1338
+ * What the shop offers with a product, ticked in its buy box (D133): each
1339
+ * add-on at the price ticking it adds. Only add-ons that can be bought
1340
+ * arrive; an engine without the route offers none.
1341
+ */
1342
+ async listAddOns({ product }) {
1343
+ const p = await this.findProduct(String(product));
1344
+ try {
1345
+ return (await this.data("/api/add-ons", { query: { product_id: p.id } }) ?? []).map(toAddOn);
1346
+ } catch (err) {
1347
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1348
+ throw err;
1349
+ }
1350
+ }
1351
+ /**
1352
+ * "You may also like": the products the shop chose for this one, at their
1353
+ * own prices (D133). Curated only, and empty when none were chosen: the
1354
+ * related products already fill in from the category, and the page should
1355
+ * not draw the same fill twice.
1356
+ */
1357
+ async listCrossSells({ product, limit }) {
1358
+ const p = await this.findProduct(String(product));
1359
+ try {
1360
+ const rows = await this.data("/api/cross-sells", { query: { product_id: p.id, limit } }) ?? [];
1361
+ return rows.map((r) => toProduct(r));
1362
+ } catch (err) {
1363
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1364
+ throw err;
1365
+ }
1366
+ }
1293
1367
  async relatedTo(slug, limit) {
1294
1368
  const size = Math.min(100, Math.max(1, Number(limit) || PAGE_SIZE));
1295
1369
  const p = await this.findProduct(slug);
@@ -1629,6 +1703,88 @@ var CartService = class extends BaseService {
1629
1703
  throw err;
1630
1704
  }
1631
1705
  }
1706
+ /**
1707
+ * Adds an add-on from a product's "Complete the set" (D133) at its offer
1708
+ * price. The product it is offered with has to be in the bag already, so
1709
+ * this is called after adding it; the engine refuses one too many with the
1710
+ * sentence the shopper is shown.
1711
+ */
1712
+ async addAddOn({ cartId, addOnId, qty = 1 }) {
1713
+ const id2 = clean(cartId) ?? storedCartId();
1714
+ if (!id2) throw new GoCommerceError("Add the product to your bag first.", 409);
1715
+ return this.present(
1716
+ await this.data(`/api/carts/${encodeURIComponent(id2)}/line-items`, {
1717
+ method: "POST",
1718
+ body: { add_on_id: Number(addOnId), quantity: Number(qty) }
1719
+ })
1720
+ );
1721
+ }
1722
+ /**
1723
+ * The codes the shop chose to show (D133), each with what it takes off this
1724
+ * bag or how much more the bag needs. None without a bag.
1725
+ */
1726
+ async getOffers({ cartId } = {}) {
1727
+ const id2 = clean(cartId) ?? storedCartId();
1728
+ if (!id2) return [];
1729
+ try {
1730
+ const rows = await this.data(`/api/carts/${encodeURIComponent(id2)}/offers`) ?? [];
1731
+ const currency = await this.currency();
1732
+ return rows.map((o) => toCartOffer(o, o.amount?.currency ?? o.remaining?.currency ?? currency));
1733
+ } catch (err) {
1734
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1735
+ throw err;
1736
+ }
1737
+ }
1738
+ /**
1739
+ * How far the bag is from free delivery (D133), read from the store's own
1740
+ * rates for the country (the store's own when none is given). Null when
1741
+ * the engine cannot say: a bar with a made-up figure is worse than none.
1742
+ */
1743
+ async getFreeShipping({ cartId, country, state } = {}) {
1744
+ const id2 = clean(cartId) ?? storedCartId();
1745
+ if (!id2) return null;
1746
+ const where = country ?? cartExtras(id2).shippingAddress?.country ?? void 0;
1747
+ try {
1748
+ return toFreeShipping(
1749
+ await this.data(`/api/carts/${encodeURIComponent(id2)}/free-shipping`, {
1750
+ query: { country: where || void 0, state: state || void 0 }
1751
+ })
1752
+ );
1753
+ } catch (err) {
1754
+ if (err instanceof GoCommerceError && err.status === 404) return null;
1755
+ throw err;
1756
+ }
1757
+ }
1758
+ /**
1759
+ * "You may be interested in" (D133): what the bag's products suggest, less
1760
+ * what it holds; with nothing curated, the first product's related
1761
+ * products, still less what it holds.
1762
+ */
1763
+ async listCrossSells({ cartId, limit = 12 } = {}) {
1764
+ const id2 = clean(cartId) ?? storedCartId();
1765
+ if (!id2) return [];
1766
+ let curated = [];
1767
+ try {
1768
+ curated = await this.data(`/api/carts/${encodeURIComponent(id2)}/cross-sells`, { query: { limit } }) ?? [];
1769
+ } catch (err) {
1770
+ if (!(err instanceof GoCommerceError) || err.status !== 404) throw err;
1771
+ }
1772
+ if (curated.length) return curated.map((p) => toProduct(p));
1773
+ let cart;
1774
+ try {
1775
+ cart = await this.data(`/api/carts/${encodeURIComponent(id2)}`);
1776
+ } catch {
1777
+ return [];
1778
+ }
1779
+ const held = new Set((cart.line_items ?? []).map((l) => String(l.product_id)));
1780
+ const first = cart.line_items?.[0];
1781
+ if (!first) return [];
1782
+ const related = await new ProductService(this._fetch).listRelatedProducts({
1783
+ product: String(first.product_id),
1784
+ limit: limit + held.size
1785
+ });
1786
+ return related.data.filter((p) => !held.has(String(p.id))).slice(0, limit);
1787
+ }
1632
1788
  /** `qty` is the new total; 0 takes the line out. */
1633
1789
  async updateCart({
1634
1790
  cartId,
@@ -2062,7 +2218,20 @@ var paymentMethodService = new PaymentMethodService();
2062
2218
  // src/services/coupon-service.ts
2063
2219
  var CouponService = class extends BaseService {
2064
2220
  async listCoupons(_args = {}) {
2065
- return emptyPage();
2221
+ const offers = await new CartService(this._fetch).getOffers().catch(() => []);
2222
+ if (!offers.length) return emptyPage();
2223
+ const data = offers.map((o) => ({
2224
+ id: o.code,
2225
+ code: o.code,
2226
+ title: o.title,
2227
+ isPercent: o.isPercent,
2228
+ amount: o.value,
2229
+ minAmount: o.minAmount,
2230
+ description: o.applies ? o.title : o.remaining !== null ? `${o.title}. Add ${o.remaining} more to use this.` : o.reason || o.title,
2231
+ applies: o.applies,
2232
+ remaining: o.remaining
2233
+ }));
2234
+ return { data, count: data.length, pageSize: data.length, noOfPage: 1, page: 1 };
2066
2235
  }
2067
2236
  };
2068
2237
  var couponService = new CouponService();
@@ -2660,21 +2829,38 @@ var MenuService = class extends BaseService {
2660
2829
  const m = await this.data(`/x/navigation/menus/${encodeURIComponent(handle)}`);
2661
2830
  return { menuId: m.handle, name: m.title, items: (m.items ?? []).map(toItem) };
2662
2831
  }
2663
- /** The header and footer menus, or `null` when the module is not there. Cached: a menu is store-level. */
2832
+ /**
2833
+ * The header and footer menus, or `null` when the module is not there. Cached: a menu is
2834
+ * store-level. Each is the menu the shop chose for it in the theme editor's header or footer
2835
+ * (GoCommerce D131), still under the `header` / `footer` id every storefront looks for, so the
2836
+ * choice reaches every place that draws one; the menus named so are the default.
2837
+ */
2664
2838
  async engineMenus() {
2665
2839
  if (!await this.facts.pluginOn("navigation")) return null;
2666
2840
  return remember(`${this.engineBase()}|${this.store ?? ""}|menus`, async () => {
2667
2841
  const out = [];
2668
- for (const handle of ["header", "footer"]) {
2842
+ for (const part of ["header", "footer"]) {
2669
2843
  try {
2670
- const m = await this.data(`/x/navigation/menus/${handle}`);
2671
- out.push({ menuId: handle, name: m.title, items: (m.items ?? []).map(toItem) });
2844
+ const handle = await this.chosenMenu(part);
2845
+ const m = await this.data(`/x/navigation/menus/${encodeURIComponent(handle)}`);
2846
+ out.push({ menuId: part, name: m.title, items: (m.items ?? []).map(toItem) });
2672
2847
  } catch {
2673
2848
  }
2674
2849
  }
2675
2850
  return out;
2676
2851
  }).catch(() => null);
2677
2852
  }
2853
+ /** The handle of the menu the published header or footer names, else the part's own name. */
2854
+ async chosenMenu(part) {
2855
+ if (!await this.facts.pluginOn("layouts")) return part;
2856
+ try {
2857
+ const layout = await this.data(`/x/layouts/${part}`);
2858
+ const menu = layout?.sections?.find((s) => s.type === part)?.settings?.menu;
2859
+ return typeof menu === "string" && menu ? menu : part;
2860
+ } catch {
2861
+ return part;
2862
+ }
2863
+ }
2678
2864
  };
2679
2865
  var menuService = new MenuService();
2680
2866
  var readStaticStore = async () => {
@@ -2792,7 +2978,13 @@ var StoreService = class extends BaseService {
2792
2978
  isProductReviewsAndRatings: {
2793
2979
  ...basePlugins.isProductReviewsAndRatings,
2794
2980
  ...flag(plugins.has("product-reviews"))
2795
- }
2981
+ },
2982
+ // The footer's sign-up box posts to ext/newsletter, and the theme editor's footer
2983
+ // switch (D131) can only show it while that is on.
2984
+ newsletter: { ...basePlugins.newsletter, ...flag(plugins.has("newsletter")) },
2985
+ // Discount codes are core: every GoCommerce store takes them, so
2986
+ // the code box is shown whatever the static record says.
2987
+ isDiscountCoupons: { ...basePlugins.isDiscountCoupons, active: true }
2796
2988
  },
2797
2989
  ...menus?.length ? { menu: menus } : {},
2798
2990
  ...deliverable(base, ships),
@@ -2907,6 +3099,35 @@ var LayoutService = class extends BaseService {
2907
3099
  return null;
2908
3100
  }
2909
3101
  }
3102
+ /**
3103
+ * The header the shop set in the theme editor (D131), drawn on every
3104
+ * page: the published one, or a preview key's draft. Null — the
3105
+ * storefront's own header — when none is published, the editor is off, the
3106
+ * engine is older, or anything fails: a header is never worth an error page.
3107
+ */
3108
+ async getHeader({ preview } = {}) {
3109
+ return this.chrome("header", preview);
3110
+ }
3111
+ /** The footer the shop set in the theme editor (D131); null for the storefront's own, as getHeader. */
3112
+ async getFooter({ preview } = {}) {
3113
+ return this.chrome("footer", preview);
3114
+ }
3115
+ async chrome(page, preview) {
3116
+ let layout;
3117
+ try {
3118
+ layout = await this.getPageLayout(page, { preview });
3119
+ } catch {
3120
+ return null;
3121
+ }
3122
+ const section = layout?.sections.find((s) => s.type === page);
3123
+ if (!layout || !section) return null;
3124
+ const settings = { ...section.settings };
3125
+ let menu = null;
3126
+ if (typeof settings.menu === "string" && settings.menu) {
3127
+ menu = await new MenuService(this._fetch).getMenu(settings.menu).catch(() => null);
3128
+ }
3129
+ return { revision: layout.revision, preview: layout.preview, settings, menu };
3130
+ }
2910
3131
  };
2911
3132
  function tokens(v) {
2912
3133
  return v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length ? { ...v } : null;