@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.
@@ -466,9 +466,27 @@ function productImages(p) {
466
466
  if (url && !out.includes(url)) out.push(url);
467
467
  };
468
468
  push(mediaUrl(p.image_url));
469
+ for (const m of arr(p.media)) if (m.kind === "image") push(mediaUrl(m.url));
469
470
  for (const v of arr(p.variants)) for (const url of variantImages(v)) push(url);
470
471
  return out;
471
472
  }
473
+ function productVideos(p) {
474
+ const out = [];
475
+ for (const m of arr(p.media)) {
476
+ const url = m.kind === "video" ? mediaUrl(m.url) : null;
477
+ if (url && !out.includes(url)) out.push(url);
478
+ }
479
+ return out;
480
+ }
481
+ function highlightsOf(p) {
482
+ const raw = p.metadata?.highlights;
483
+ if (!Array.isArray(raw)) return [];
484
+ return raw.filter((s) => typeof s === "string").map((s) => s.trim()).filter(Boolean);
485
+ }
486
+ function freeGiftOf(p) {
487
+ const raw = p.metadata?.free_gift;
488
+ return typeof raw === "string" && raw.trim() ? raw.trim() : null;
489
+ }
472
490
  function toVariant(v, p, optionless) {
473
491
  const images = variantImages(v);
474
492
  const options = arr(p.options);
@@ -516,6 +534,7 @@ function toProduct(p, extra = {}) {
516
534
  const lead = live[0] ?? arr(p.variants)[0];
517
535
  const optionless = arr(p.options).length === 0;
518
536
  const images = productImages(p);
537
+ const videos = productVideos(p);
519
538
  const prices = live.map((v) => toMajor(v.price));
520
539
  const tracked = live.filter((v) => v.track_inventory);
521
540
  const category = p.category;
@@ -542,8 +561,14 @@ function toProduct(p, extra = {}) {
542
561
  originCountry: lead?.origin_country ?? null,
543
562
  thumbnail: images[0] ?? null,
544
563
  image_url: images[0] ?? null,
545
- images: images.join(","),
564
+ images: [...images, ...videos].join(","),
546
565
  imageList: images,
566
+ videos,
567
+ // What the buy box says beside the price (D134): plain text, drawn as text.
568
+ highlights: highlightsOf(p),
569
+ freeGift: freeGiftOf(p),
570
+ // Counted by the engine, rounded down, and absent rather than zero.
571
+ soldAtLeast: typeof p.sold_at_least === "number" && p.sold_at_least > 0 ? p.sold_at_least : null,
547
572
  // Product-level stock is what a card shows before a variant is picked:
548
573
  // buyable when any live variant is, counted only where counting means
549
574
  // something.
@@ -716,7 +741,9 @@ function toIdentityAddress(a) {
716
741
  state: e.state ?? "",
717
742
  postal_code: e.postal_code,
718
743
  country: e.country,
719
- ...a.label ? { label: String(a.label) } : {},
744
+ // Sent whenever the form has it, empty included: the engine keeps a label it is not sent, so
745
+ // leaving out an emptied one would keep the old Home or Work (D134).
746
+ ...typeof a.label === "string" ? { label: a.label } : {},
720
747
  ...a.isPrimary ? { is_default: true } : {}
721
748
  };
722
749
  }
@@ -733,7 +760,12 @@ function toCartLine(l, extra = {}) {
733
760
  variantTitle: l.variant_label ?? "",
734
761
  qty: l.quantity,
735
762
  price: toMajor(l.unit_price),
736
- mrp: toMajor(l.unit_price),
763
+ // The variant's compare-at price, where the shop set one above what the
764
+ // line costs: what the basket's "you save" is measured from.
765
+ 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),
766
+ /** The add-on this line was bought as (D133), or null on an ordinary line. */
767
+ addOnId: l.add_on_id ? str(l.add_on_id) : null,
768
+ addOnFor: l.add_on_for ? str(l.add_on_for) : null,
737
769
  subtotal: toMajor(l.total),
738
770
  total: toMajor(l.total),
739
771
  inStock: l.in_stock,
@@ -966,6 +998,45 @@ function toUser(c) {
966
998
  updatedAt: c.updated_at
967
999
  };
968
1000
  }
1001
+ function toAddOn(a) {
1002
+ return {
1003
+ id: str(a.id),
1004
+ productId: str(a.item.product_id),
1005
+ variantId: str(a.variant_id),
1006
+ slug: a.item.slug,
1007
+ title: a.item.title,
1008
+ variantTitle: a.item.variant_label ?? "",
1009
+ thumbnail: mediaUrl(a.item.image_url ?? null),
1010
+ price: toMajor(a.offer_price ?? a.price),
1011
+ regularPrice: toMajor(a.price),
1012
+ hasOffer: !!a.offer_price && a.offer_price.amount_minor < a.price.amount_minor,
1013
+ inStock: a.item.in_stock
1014
+ };
1015
+ }
1016
+ function toCartOffer(o, currency) {
1017
+ const isPercent = o.kind === "percentage";
1018
+ return {
1019
+ code: o.code,
1020
+ title: o.title,
1021
+ isPercent,
1022
+ freeShipping: o.kind === "free_shipping",
1023
+ /** The percentage for a percentage code, the amount for a fixed one. */
1024
+ value: isPercent ? (o.value_bp ?? 0) / 100 : minorToMajor(o.value_minor ?? 0, currency),
1025
+ minAmount: o.min_subtotal_minor ? minorToMajor(o.min_subtotal_minor, currency) : null,
1026
+ applies: o.applies,
1027
+ amount: o.amount ? toMajor(o.amount) : null,
1028
+ remaining: o.remaining ? toMajor(o.remaining) : null,
1029
+ reason: o.reason ?? ""
1030
+ };
1031
+ }
1032
+ function toFreeShipping(f) {
1033
+ return {
1034
+ qualifies: f.qualifies,
1035
+ threshold: f.threshold ? toMajor(f.threshold) : null,
1036
+ remaining: f.remaining ? toMajor(f.remaining) : null,
1037
+ subtotal: toMajor(f.subtotal)
1038
+ };
1039
+ }
969
1040
 
970
1041
  // src/cache.ts
971
1042
  var TTL_MS = 6e4;
@@ -1290,6 +1361,36 @@ var ProductService = class extends CatalogService {
1290
1361
  if (!category) return this.list({ page, limit });
1291
1362
  return this.list({ page, limit, categories: category.slug });
1292
1363
  }
1364
+ /**
1365
+ * What the shop offers with a product, ticked in its buy box (D133): each
1366
+ * add-on at the price ticking it adds. Only add-ons that can be bought
1367
+ * arrive; an engine without the route offers none.
1368
+ */
1369
+ async listAddOns({ product }) {
1370
+ const p = await this.findProduct(String(product));
1371
+ try {
1372
+ return (await this.data("/api/add-ons", { query: { product_id: p.id } }) ?? []).map(toAddOn);
1373
+ } catch (err) {
1374
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1375
+ throw err;
1376
+ }
1377
+ }
1378
+ /**
1379
+ * "You may also like": the products the shop chose for this one, at their
1380
+ * own prices (D133). Curated only, and empty when none were chosen: the
1381
+ * related products already fill in from the category, and the page should
1382
+ * not draw the same fill twice.
1383
+ */
1384
+ async listCrossSells({ product, limit }) {
1385
+ const p = await this.findProduct(String(product));
1386
+ try {
1387
+ const rows = await this.data("/api/cross-sells", { query: { product_id: p.id, limit } }) ?? [];
1388
+ return rows.map((r) => toProduct(r));
1389
+ } catch (err) {
1390
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1391
+ throw err;
1392
+ }
1393
+ }
1293
1394
  async relatedTo(slug, limit) {
1294
1395
  const size = Math.min(100, Math.max(1, Number(limit) || PAGE_SIZE));
1295
1396
  const p = await this.findProduct(slug);
@@ -1629,6 +1730,88 @@ var CartService = class extends BaseService {
1629
1730
  throw err;
1630
1731
  }
1631
1732
  }
1733
+ /**
1734
+ * Adds an add-on from a product's "Complete the set" (D133) at its offer
1735
+ * price. The product it is offered with has to be in the bag already, so
1736
+ * this is called after adding it; the engine refuses one too many with the
1737
+ * sentence the shopper is shown.
1738
+ */
1739
+ async addAddOn({ cartId, addOnId, qty = 1 }) {
1740
+ const id2 = clean(cartId) ?? storedCartId();
1741
+ if (!id2) throw new GoCommerceError("Add the product to your bag first.", 409);
1742
+ return this.present(
1743
+ await this.data(`/api/carts/${encodeURIComponent(id2)}/line-items`, {
1744
+ method: "POST",
1745
+ body: { add_on_id: Number(addOnId), quantity: Number(qty) }
1746
+ })
1747
+ );
1748
+ }
1749
+ /**
1750
+ * The codes the shop chose to show (D133), each with what it takes off this
1751
+ * bag or how much more the bag needs. None without a bag.
1752
+ */
1753
+ async getOffers({ cartId } = {}) {
1754
+ const id2 = clean(cartId) ?? storedCartId();
1755
+ if (!id2) return [];
1756
+ try {
1757
+ const rows = await this.data(`/api/carts/${encodeURIComponent(id2)}/offers`) ?? [];
1758
+ const currency = await this.currency();
1759
+ return rows.map((o) => toCartOffer(o, o.amount?.currency ?? o.remaining?.currency ?? currency));
1760
+ } catch (err) {
1761
+ if (err instanceof GoCommerceError && err.status === 404) return [];
1762
+ throw err;
1763
+ }
1764
+ }
1765
+ /**
1766
+ * How far the bag is from free delivery (D133), read from the store's own
1767
+ * rates for the country (the store's own when none is given). Null when
1768
+ * the engine cannot say: a bar with a made-up figure is worse than none.
1769
+ */
1770
+ async getFreeShipping({ cartId, country, state } = {}) {
1771
+ const id2 = clean(cartId) ?? storedCartId();
1772
+ if (!id2) return null;
1773
+ const where = country ?? cartExtras(id2).shippingAddress?.country ?? void 0;
1774
+ try {
1775
+ return toFreeShipping(
1776
+ await this.data(`/api/carts/${encodeURIComponent(id2)}/free-shipping`, {
1777
+ query: { country: where || void 0, state: state || void 0 }
1778
+ })
1779
+ );
1780
+ } catch (err) {
1781
+ if (err instanceof GoCommerceError && err.status === 404) return null;
1782
+ throw err;
1783
+ }
1784
+ }
1785
+ /**
1786
+ * "You may be interested in" (D133): what the bag's products suggest, less
1787
+ * what it holds; with nothing curated, the first product's related
1788
+ * products, still less what it holds.
1789
+ */
1790
+ async listCrossSells({ cartId, limit = 12 } = {}) {
1791
+ const id2 = clean(cartId) ?? storedCartId();
1792
+ if (!id2) return [];
1793
+ let curated = [];
1794
+ try {
1795
+ curated = await this.data(`/api/carts/${encodeURIComponent(id2)}/cross-sells`, { query: { limit } }) ?? [];
1796
+ } catch (err) {
1797
+ if (!(err instanceof GoCommerceError) || err.status !== 404) throw err;
1798
+ }
1799
+ if (curated.length) return curated.map((p) => toProduct(p));
1800
+ let cart;
1801
+ try {
1802
+ cart = await this.data(`/api/carts/${encodeURIComponent(id2)}`);
1803
+ } catch {
1804
+ return [];
1805
+ }
1806
+ const held = new Set((cart.line_items ?? []).map((l) => String(l.product_id)));
1807
+ const first = cart.line_items?.[0];
1808
+ if (!first) return [];
1809
+ const related = await new ProductService(this._fetch).listRelatedProducts({
1810
+ product: String(first.product_id),
1811
+ limit: limit + held.size
1812
+ });
1813
+ return related.data.filter((p) => !held.has(String(p.id))).slice(0, limit);
1814
+ }
1632
1815
  /** `qty` is the new total; 0 takes the line out. */
1633
1816
  async updateCart({
1634
1817
  cartId,
@@ -2062,7 +2245,20 @@ var paymentMethodService = new PaymentMethodService();
2062
2245
  // src/services/coupon-service.ts
2063
2246
  var CouponService = class extends BaseService {
2064
2247
  async listCoupons(_args = {}) {
2065
- return emptyPage();
2248
+ const offers = await new CartService(this._fetch).getOffers().catch(() => []);
2249
+ if (!offers.length) return emptyPage();
2250
+ const data = offers.map((o) => ({
2251
+ id: o.code,
2252
+ code: o.code,
2253
+ title: o.title,
2254
+ isPercent: o.isPercent,
2255
+ amount: o.value,
2256
+ minAmount: o.minAmount,
2257
+ description: o.applies ? o.title : o.remaining !== null ? `${o.title}. Add ${o.remaining} more to use this.` : o.reason || o.title,
2258
+ applies: o.applies,
2259
+ remaining: o.remaining
2260
+ }));
2261
+ return { data, count: data.length, pageSize: data.length, noOfPage: 1, page: 1 };
2066
2262
  }
2067
2263
  };
2068
2264
  var couponService = new CouponService();
@@ -2500,6 +2696,24 @@ var AddressService = class extends BaseService {
2500
2696
  });
2501
2697
  return fromIdentityAddress(a, currentMe()?.email);
2502
2698
  }
2699
+ /**
2700
+ * The city and state for a postcode (D134), for the address form to fill
2701
+ * in. India's six-digit PIN codes only, from the directory the engine was
2702
+ * given; null for anything it cannot answer, so the form simply leaves the
2703
+ * fields to the shopper.
2704
+ */
2705
+ async lookupPostcode({ country = "IN", postcode }) {
2706
+ const code = String(postcode ?? "").trim();
2707
+ const cc = String(country ?? "").trim().toUpperCase();
2708
+ if (cc !== "IN" || !/^[1-9][0-9]{5}$/.test(code)) return null;
2709
+ try {
2710
+ const p = await this.data(`/api/postcodes/${cc}/${code}`);
2711
+ return { country: p.country, postcode: p.postcode, city: p.city, state: p.state, stateCode: p.state_code };
2712
+ } catch (err) {
2713
+ if (err instanceof GoCommerceError && (err.status === 404 || err.status === 400)) return null;
2714
+ throw err;
2715
+ }
2716
+ }
2503
2717
  async deleteAddress(id2) {
2504
2718
  await this.engine(`/x/identity/me/addresses/${enc(id2)}`, { method: "DELETE", auth: true });
2505
2719
  return { id: String(id2), deleted: true };
@@ -2812,7 +3026,10 @@ var StoreService = class extends BaseService {
2812
3026
  },
2813
3027
  // The footer's sign-up box posts to ext/newsletter, and the theme editor's footer
2814
3028
  // switch (D131) can only show it while that is on.
2815
- newsletter: { ...basePlugins.newsletter, ...flag(plugins.has("newsletter")) }
3029
+ newsletter: { ...basePlugins.newsletter, ...flag(plugins.has("newsletter")) },
3030
+ // Discount codes are core: every GoCommerce store takes them, so
3031
+ // the code box is shown whatever the static record says.
3032
+ isDiscountCoupons: { ...basePlugins.isDiscountCoupons, active: true }
2816
3033
  },
2817
3034
  ...menus?.length ? { menu: menus } : {},
2818
3035
  ...deliverable(base, ships),