@misiki/gocommerce-connector 0.11.0 → 0.13.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
@@ -498,9 +498,27 @@ function productImages(p) {
498
498
  if (url && !out.includes(url)) out.push(url);
499
499
  };
500
500
  push(mediaUrl(p.image_url));
501
+ for (const m of arr(p.media)) if (m.kind === "image") push(mediaUrl(m.url));
501
502
  for (const v of arr(p.variants)) for (const url of variantImages(v)) push(url);
502
503
  return out;
503
504
  }
505
+ function productVideos(p) {
506
+ const out = [];
507
+ for (const m of arr(p.media)) {
508
+ const url = m.kind === "video" ? mediaUrl(m.url) : null;
509
+ if (url && !out.includes(url)) out.push(url);
510
+ }
511
+ return out;
512
+ }
513
+ function highlightsOf(p) {
514
+ const raw = p.metadata?.highlights;
515
+ if (!Array.isArray(raw)) return [];
516
+ return raw.filter((s) => typeof s === "string").map((s) => s.trim()).filter(Boolean);
517
+ }
518
+ function freeGiftOf(p) {
519
+ const raw = p.metadata?.free_gift;
520
+ return typeof raw === "string" && raw.trim() ? raw.trim() : null;
521
+ }
504
522
  function toVariant(v, p, optionless) {
505
523
  const images = variantImages(v);
506
524
  const options = arr(p.options);
@@ -548,6 +566,7 @@ function toProduct(p, extra = {}) {
548
566
  const lead = live[0] ?? arr(p.variants)[0];
549
567
  const optionless = arr(p.options).length === 0;
550
568
  const images = productImages(p);
569
+ const videos = productVideos(p);
551
570
  const prices = live.map((v) => toMajor(v.price));
552
571
  const tracked = live.filter((v) => v.track_inventory);
553
572
  const category = p.category;
@@ -574,8 +593,14 @@ function toProduct(p, extra = {}) {
574
593
  originCountry: lead?.origin_country ?? null,
575
594
  thumbnail: images[0] ?? null,
576
595
  image_url: images[0] ?? null,
577
- images: images.join(","),
596
+ images: [...images, ...videos].join(","),
578
597
  imageList: images,
598
+ videos,
599
+ // What the buy box says beside the price (D134): plain text, drawn as text.
600
+ highlights: highlightsOf(p),
601
+ freeGift: freeGiftOf(p),
602
+ // Counted by the engine, rounded down, and absent rather than zero.
603
+ soldAtLeast: typeof p.sold_at_least === "number" && p.sold_at_least > 0 ? p.sold_at_least : null,
579
604
  // Product-level stock is what a card shows before a variant is picked:
580
605
  // buyable when any live variant is, counted only where counting means
581
606
  // something.
@@ -748,7 +773,9 @@ function toIdentityAddress(a) {
748
773
  state: e.state ?? "",
749
774
  postal_code: e.postal_code,
750
775
  country: e.country,
751
- ...a.label ? { label: String(a.label) } : {},
776
+ // Sent whenever the form has it, empty included: the engine keeps a label it is not sent, so
777
+ // leaving out an emptied one would keep the old Home or Work (D134).
778
+ ...typeof a.label === "string" ? { label: a.label } : {},
752
779
  ...a.isPrimary ? { is_default: true } : {}
753
780
  };
754
781
  }
@@ -765,7 +792,12 @@ function toCartLine(l, extra = {}) {
765
792
  variantTitle: l.variant_label ?? "",
766
793
  qty: l.quantity,
767
794
  price: toMajor(l.unit_price),
768
- mrp: toMajor(l.unit_price),
795
+ // The variant's compare-at price, where the shop set one above what the
796
+ // line costs: what the basket's "you save" is measured from.
797
+ 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),
798
+ /** The add-on this line was bought as (D133), or null on an ordinary line. */
799
+ addOnId: l.add_on_id ? str(l.add_on_id) : null,
800
+ addOnFor: l.add_on_for ? str(l.add_on_for) : null,
769
801
  subtotal: toMajor(l.total),
770
802
  total: toMajor(l.total),
771
803
  inStock: l.in_stock,
@@ -999,6 +1031,45 @@ function toUser(c) {
999
1031
  };
1000
1032
  }
1001
1033
  var priceOf = (m) => m ? toMajor(m) : 0;
1034
+ function toAddOn(a) {
1035
+ return {
1036
+ id: str(a.id),
1037
+ productId: str(a.item.product_id),
1038
+ variantId: str(a.variant_id),
1039
+ slug: a.item.slug,
1040
+ title: a.item.title,
1041
+ variantTitle: a.item.variant_label ?? "",
1042
+ thumbnail: mediaUrl(a.item.image_url ?? null),
1043
+ price: toMajor(a.offer_price ?? a.price),
1044
+ regularPrice: toMajor(a.price),
1045
+ hasOffer: !!a.offer_price && a.offer_price.amount_minor < a.price.amount_minor,
1046
+ inStock: a.item.in_stock
1047
+ };
1048
+ }
1049
+ function toCartOffer(o, currency) {
1050
+ const isPercent = o.kind === "percentage";
1051
+ return {
1052
+ code: o.code,
1053
+ title: o.title,
1054
+ isPercent,
1055
+ freeShipping: o.kind === "free_shipping",
1056
+ /** The percentage for a percentage code, the amount for a fixed one. */
1057
+ value: isPercent ? (o.value_bp ?? 0) / 100 : minorToMajor(o.value_minor ?? 0, currency),
1058
+ minAmount: o.min_subtotal_minor ? minorToMajor(o.min_subtotal_minor, currency) : null,
1059
+ applies: o.applies,
1060
+ amount: o.amount ? toMajor(o.amount) : null,
1061
+ remaining: o.remaining ? toMajor(o.remaining) : null,
1062
+ reason: o.reason ?? ""
1063
+ };
1064
+ }
1065
+ function toFreeShipping(f) {
1066
+ return {
1067
+ qualifies: f.qualifies,
1068
+ threshold: f.threshold ? toMajor(f.threshold) : null,
1069
+ remaining: f.remaining ? toMajor(f.remaining) : null,
1070
+ subtotal: toMajor(f.subtotal)
1071
+ };
1072
+ }
1002
1073
 
1003
1074
  // src/services/facts.ts
1004
1075
  var StoreFacts = class extends BaseService {
@@ -1309,6 +1380,36 @@ var ProductService = class extends CatalogService {
1309
1380
  if (!category) return this.list({ page, limit });
1310
1381
  return this.list({ page, limit, categories: category.slug });
1311
1382
  }
1383
+ /**
1384
+ * What the shop offers with a product, ticked in its buy box (D133): each
1385
+ * add-on at the price ticking it adds. Only add-ons that can be bought
1386
+ * arrive; an engine without the route offers none.
1387
+ */
1388
+ async listAddOns({ product }) {
1389
+ const p = await this.findProduct(String(product));
1390
+ try {
1391
+ return (await this.data("/api/add-ons", { query: { product_id: p.id } }) ?? []).map(toAddOn);
1392
+ } catch (err) {
1393
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1394
+ throw err;
1395
+ }
1396
+ }
1397
+ /**
1398
+ * "You may also like": the products the shop chose for this one, at their
1399
+ * own prices (D133). Curated only, and empty when none were chosen: the
1400
+ * related products already fill in from the category, and the page should
1401
+ * not draw the same fill twice.
1402
+ */
1403
+ async listCrossSells({ product, limit }) {
1404
+ const p = await this.findProduct(String(product));
1405
+ try {
1406
+ const rows = await this.data("/api/cross-sells", { query: { product_id: p.id, limit } }) ?? [];
1407
+ return rows.map((r) => toProduct(r));
1408
+ } catch (err) {
1409
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1410
+ throw err;
1411
+ }
1412
+ }
1312
1413
  async relatedTo(slug, limit) {
1313
1414
  const size = Math.min(100, Math.max(1, Number(limit) || PAGE_SIZE));
1314
1415
  const p = await this.findProduct(slug);
@@ -1648,6 +1749,88 @@ var CartService = class extends BaseService {
1648
1749
  throw err;
1649
1750
  }
1650
1751
  }
1752
+ /**
1753
+ * Adds an add-on from a product's "Complete the set" (D133) at its offer
1754
+ * price. The product it is offered with has to be in the bag already, so
1755
+ * this is called after adding it; the engine refuses one too many with the
1756
+ * sentence the shopper is shown.
1757
+ */
1758
+ async addAddOn({ cartId, addOnId, qty = 1 }) {
1759
+ const id2 = clean(cartId) ?? storedCartId();
1760
+ if (!id2) throw new GoCommerceError("Add the product to your bag first.", 409);
1761
+ return this.present(
1762
+ await this.data(`/api/carts/${encodeURIComponent(id2)}/line-items`, {
1763
+ method: "POST",
1764
+ body: { add_on_id: Number(addOnId), quantity: Number(qty) }
1765
+ })
1766
+ );
1767
+ }
1768
+ /**
1769
+ * The codes the shop chose to show (D133), each with what it takes off this
1770
+ * bag or how much more the bag needs. None without a bag.
1771
+ */
1772
+ async getOffers({ cartId } = {}) {
1773
+ const id2 = clean(cartId) ?? storedCartId();
1774
+ if (!id2) return [];
1775
+ try {
1776
+ const rows = await this.data(`/api/carts/${encodeURIComponent(id2)}/offers`) ?? [];
1777
+ const currency = await this.currency();
1778
+ return rows.map((o) => toCartOffer(o, o.amount?.currency ?? o.remaining?.currency ?? currency));
1779
+ } catch (err) {
1780
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1781
+ throw err;
1782
+ }
1783
+ }
1784
+ /**
1785
+ * How far the bag is from free delivery (D133), read from the store's own
1786
+ * rates for the country (the store's own when none is given). Null when
1787
+ * the engine cannot say: a bar with a made-up figure is worse than none.
1788
+ */
1789
+ async getFreeShipping({ cartId, country, state } = {}) {
1790
+ const id2 = clean(cartId) ?? storedCartId();
1791
+ if (!id2) return null;
1792
+ const where = country ?? cartExtras(id2).shippingAddress?.country ?? void 0;
1793
+ try {
1794
+ return toFreeShipping(
1795
+ await this.data(`/api/carts/${encodeURIComponent(id2)}/free-shipping`, {
1796
+ query: { country: where || void 0, state: state || void 0 }
1797
+ })
1798
+ );
1799
+ } catch (err) {
1800
+ if (err instanceof GoCommerceError && err.status === 404) return null;
1801
+ throw err;
1802
+ }
1803
+ }
1804
+ /**
1805
+ * "You may be interested in" (D133): what the bag's products suggest, less
1806
+ * what it holds; with nothing curated, the first product's related
1807
+ * products, still less what it holds.
1808
+ */
1809
+ async listCrossSells({ cartId, limit = 12 } = {}) {
1810
+ const id2 = clean(cartId) ?? storedCartId();
1811
+ if (!id2) return [];
1812
+ let curated = [];
1813
+ try {
1814
+ curated = await this.data(`/api/carts/${encodeURIComponent(id2)}/cross-sells`, { query: { limit } }) ?? [];
1815
+ } catch (err) {
1816
+ if (!(err instanceof GoCommerceError) || err.status !== 404) throw err;
1817
+ }
1818
+ if (curated.length) return curated.map((p) => toProduct(p));
1819
+ let cart;
1820
+ try {
1821
+ cart = await this.data(`/api/carts/${encodeURIComponent(id2)}`);
1822
+ } catch {
1823
+ return [];
1824
+ }
1825
+ const held = new Set((cart.line_items ?? []).map((l) => String(l.product_id)));
1826
+ const first = cart.line_items?.[0];
1827
+ if (!first) return [];
1828
+ const related = await new ProductService(this._fetch).listRelatedProducts({
1829
+ product: String(first.product_id),
1830
+ limit: limit + held.size
1831
+ });
1832
+ return related.data.filter((p) => !held.has(String(p.id))).slice(0, limit);
1833
+ }
1651
1834
  /** `qty` is the new total; 0 takes the line out. */
1652
1835
  async updateCart({
1653
1836
  cartId,
@@ -2081,7 +2264,20 @@ var paymentMethodService = new PaymentMethodService();
2081
2264
  // src/services/coupon-service.ts
2082
2265
  var CouponService = class extends BaseService {
2083
2266
  async listCoupons(_args = {}) {
2084
- return emptyPage();
2267
+ const offers = await new CartService(this._fetch).getOffers().catch(() => []);
2268
+ if (!offers.length) return emptyPage();
2269
+ const data = offers.map((o) => ({
2270
+ id: o.code,
2271
+ code: o.code,
2272
+ title: o.title,
2273
+ isPercent: o.isPercent,
2274
+ amount: o.value,
2275
+ minAmount: o.minAmount,
2276
+ description: o.applies ? o.title : o.remaining !== null ? `${o.title}. Add ${o.remaining} more to use this.` : o.reason || o.title,
2277
+ applies: o.applies,
2278
+ remaining: o.remaining
2279
+ }));
2280
+ return { data, count: data.length, pageSize: data.length, noOfPage: 1, page: 1 };
2085
2281
  }
2086
2282
  };
2087
2283
  var couponService = new CouponService();
@@ -2519,6 +2715,24 @@ var AddressService = class extends BaseService {
2519
2715
  });
2520
2716
  return fromIdentityAddress(a, currentMe()?.email);
2521
2717
  }
2718
+ /**
2719
+ * The city and state for a postcode (D134), for the address form to fill
2720
+ * in. India's six-digit PIN codes only, from the directory the engine was
2721
+ * given; null for anything it cannot answer, so the form simply leaves the
2722
+ * fields to the shopper.
2723
+ */
2724
+ async lookupPostcode({ country = "IN", postcode }) {
2725
+ const code = String(postcode ?? "").trim();
2726
+ const cc = String(country ?? "").trim().toUpperCase();
2727
+ if (cc !== "IN" || !/^[1-9][0-9]{5}$/.test(code)) return null;
2728
+ try {
2729
+ const p = await this.data(`/api/postcodes/${cc}/${code}`);
2730
+ return { country: p.country, postcode: p.postcode, city: p.city, state: p.state, stateCode: p.state_code };
2731
+ } catch (err) {
2732
+ if (err instanceof GoCommerceError && (err.status === 404 || err.status === 400)) return null;
2733
+ throw err;
2734
+ }
2735
+ }
2522
2736
  async deleteAddress(id2) {
2523
2737
  await this.engine(`/x/identity/me/addresses/${enc(id2)}`, { method: "DELETE", auth: true });
2524
2738
  return { id: String(id2), deleted: true };
@@ -2828,7 +3042,10 @@ var StoreService = class extends BaseService {
2828
3042
  },
2829
3043
  // The footer's sign-up box posts to ext/newsletter, and the theme editor's footer
2830
3044
  // switch (D131) can only show it while that is on.
2831
- newsletter: { ...basePlugins.newsletter, ...flag(plugins.has("newsletter")) }
3045
+ newsletter: { ...basePlugins.newsletter, ...flag(plugins.has("newsletter")) },
3046
+ // Discount codes are core: every GoCommerce store takes them, so
3047
+ // the code box is shown whatever the static record says.
3048
+ isDiscountCoupons: { ...basePlugins.isDiscountCoupons, active: true }
2832
3049
  },
2833
3050
  ...menus?.length ? { menu: menus } : {},
2834
3051
  ...deliverable(base, ships),
@@ -3640,6 +3857,7 @@ exports.popularityService = popularityService;
3640
3857
  exports.priceOf = priceOf;
3641
3858
  exports.productImages = productImages;
3642
3859
  exports.productService = productService;
3860
+ exports.productVideos = productVideos;
3643
3861
  exports.profileService = profileService;
3644
3862
  exports.qs = qs;
3645
3863
  exports.readStaticStore = readStaticStore;
@@ -3656,12 +3874,15 @@ exports.stateService = stateService;
3656
3874
  exports.staticStore = staticStore;
3657
3875
  exports.storeForHost = storeForHost;
3658
3876
  exports.storeService = storeService;
3877
+ exports.toAddOn = toAddOn;
3659
3878
  exports.toCart = toCart;
3660
3879
  exports.toCartLine = toCartLine;
3880
+ exports.toCartOffer = toCartOffer;
3661
3881
  exports.toCategory = toCategory;
3662
3882
  exports.toCmsPage = toCmsPage;
3663
3883
  exports.toCollection = toCollection;
3664
3884
  exports.toEngineAddress = toEngineAddress;
3885
+ exports.toFreeShipping = toFreeShipping;
3665
3886
  exports.toIdentityAddress = toIdentityAddress;
3666
3887
  exports.toMajor = toMajor;
3667
3888
  exports.toMinor = toMinor;