brainerce 1.55.0 → 1.57.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
@@ -63,6 +63,7 @@ __export(index_exports, {
63
63
  getProductMetafieldsByType: () => getProductMetafieldsByType,
64
64
  getProductPrice: () => getProductPrice,
65
65
  getProductPriceInfo: () => getProductPriceInfo,
66
+ getProductSitemapEntries: () => getProductSitemapEntries,
66
67
  getProductSwatches: () => getProductSwatches,
67
68
  getStockStatus: () => getStockStatus,
68
69
  getVariantOptions: () => getVariantOptions,
@@ -298,6 +299,14 @@ var _BrainerceClient = class _BrainerceClient {
298
299
  * This is needed because Stripe redirects lose in-memory state.
299
300
  */
300
301
  this.ACTIVE_CHECKOUT_KEY = "brainerce_active_checkout";
302
+ /**
303
+ * Merge the resolved GA4 stitch ids onto a request body — only for fields
304
+ * the caller didn't already set explicitly (explicit values always win).
305
+ * No-op (returns `dto` unchanged) if `loadGoogleAnalytics()` was never
306
+ * called, or if it hasn't resolved any ids by the time this is awaited.
307
+ */
308
+ /** localStorage key holding the last non-direct-touch attribution blob. */
309
+ this.TRAFFIC_ATTR_KEY = "brainerce_attr";
301
310
  // -------------------- Contact Forms (schema) --------------------
302
311
  /**
303
312
  * List active contact forms configured for the store.
@@ -703,6 +712,7 @@ var _BrainerceClient = class _BrainerceClient {
703
712
  this.onCartReset = options.onCartReset;
704
713
  this.hydrateSessionCart();
705
714
  this.detectRecoverCartFromUrl();
715
+ this.captureTrafficAttribution();
706
716
  }
707
717
  // -------------------- Locale --------------------
708
718
  /**
@@ -1525,11 +1535,82 @@ var _BrainerceClient = class _BrainerceClient {
1525
1535
  }
1526
1536
  }
1527
1537
  /**
1528
- * Merge the resolved GA4 stitch ids onto a request body — only for fields
1529
- * the caller didn't already set explicitly (explicit values always win).
1530
- * No-op (returns `dto` unchanged) if `loadGoogleAnalytics()` was never
1531
- * called, or if it hasn't resolved any ids by the time this is awaited.
1538
+ * Record the visit's traffic origin LAST NON-DIRECT TOUCH semantics: an
1539
+ * external referrer or any utm_source overwrites the stored blob; a direct
1540
+ * or internal navigation keeps the previous touch. Runs once per client
1541
+ * construction (i.e. per page load in browser storefronts) and must never
1542
+ * throw — attribution is telemetry, the storefront always wins.
1543
+ */
1544
+ captureTrafficAttribution() {
1545
+ try {
1546
+ if (typeof window === "undefined" || !window.localStorage) return;
1547
+ const params = new URLSearchParams(window.location.search);
1548
+ const utmSource = params.get("utm_source") || void 0;
1549
+ const utmMedium = params.get("utm_medium") || void 0;
1550
+ const utmCampaign = params.get("utm_campaign") || void 0;
1551
+ let referrerHost;
1552
+ if (document.referrer) {
1553
+ try {
1554
+ const ref = new URL(document.referrer);
1555
+ if (ref.hostname && ref.hostname !== window.location.hostname) {
1556
+ referrerHost = ref.hostname.toLowerCase().replace(/^www\./, "");
1557
+ }
1558
+ } catch {
1559
+ }
1560
+ }
1561
+ if (!referrerHost && !utmSource) return;
1562
+ window.localStorage.setItem(
1563
+ this.TRAFFIC_ATTR_KEY,
1564
+ JSON.stringify({ referrerHost, utmSource, utmMedium, utmCampaign, at: Date.now() })
1565
+ );
1566
+ } catch {
1567
+ }
1568
+ }
1569
+ /** The stored attribution as request-body fields, or null when absent/stale. */
1570
+ getTrafficAttribution() {
1571
+ try {
1572
+ if (typeof window === "undefined" || !window.localStorage) return null;
1573
+ const raw = window.localStorage.getItem(this.TRAFFIC_ATTR_KEY);
1574
+ if (!raw) return null;
1575
+ const blob = JSON.parse(raw);
1576
+ if (!blob || typeof blob !== "object") return null;
1577
+ if (typeof blob.at !== "number" || Date.now() - blob.at > _BrainerceClient.TRAFFIC_ATTR_MAX_AGE_MS) {
1578
+ return null;
1579
+ }
1580
+ const out = {};
1581
+ if (typeof blob.referrerHost === "string" && blob.referrerHost) {
1582
+ out.trafficReferrerHost = blob.referrerHost.slice(0, 253);
1583
+ }
1584
+ if (typeof blob.utmSource === "string" && blob.utmSource) {
1585
+ out.trafficUtmSource = blob.utmSource.slice(0, 150);
1586
+ }
1587
+ if (typeof blob.utmMedium === "string" && blob.utmMedium) {
1588
+ out.trafficUtmMedium = blob.utmMedium.slice(0, 150);
1589
+ }
1590
+ if (typeof blob.utmCampaign === "string" && blob.utmCampaign) {
1591
+ out.trafficUtmCampaign = blob.utmCampaign.slice(0, 150);
1592
+ }
1593
+ return Object.keys(out).length > 0 ? out : null;
1594
+ } catch {
1595
+ return null;
1596
+ }
1597
+ }
1598
+ /**
1599
+ * Merge the stored traffic attribution onto a request body — only for
1600
+ * fields the caller didn't set explicitly (explicit values always win),
1601
+ * mirroring `withAnalyticsStitchIds`. No-op outside the browser.
1532
1602
  */
1603
+ withTrafficAttribution(dto) {
1604
+ const attr = this.getTrafficAttribution();
1605
+ if (!attr) return dto;
1606
+ return {
1607
+ ...dto ?? {},
1608
+ trafficReferrerHost: dto?.trafficReferrerHost ?? attr.trafficReferrerHost,
1609
+ trafficUtmSource: dto?.trafficUtmSource ?? attr.trafficUtmSource,
1610
+ trafficUtmMedium: dto?.trafficUtmMedium ?? attr.trafficUtmMedium,
1611
+ trafficUtmCampaign: dto?.trafficUtmCampaign ?? attr.trafficUtmCampaign
1612
+ };
1613
+ }
1533
1614
  async withAnalyticsStitchIds(dto) {
1534
1615
  if (!this._ga4StitchPromise) return dto;
1535
1616
  try {
@@ -1653,6 +1734,50 @@ var _BrainerceClient = class _BrainerceClient {
1653
1734
  queryParamsWithRegion
1654
1735
  );
1655
1736
  }
1737
+ /**
1738
+ * Lightweight product rows for sitemap generation: `slug`, `updatedAt`, and
1739
+ * per-locale `localeSlugs` only — up to `limit` (max 5000) in ONE request,
1740
+ * with none of the 100-per-page clamp the full listing applies.
1741
+ *
1742
+ * Sales-channel (`salesChannelId`) mode only; other modes throw so
1743
+ * {@link getProductSitemapEntries} (sitemap.ts) can catch and fall back to
1744
+ * paginating `getProducts`. Prefer that helper over calling this directly.
1745
+ */
1746
+ async getSitemapProducts(limit = 5e3) {
1747
+ if (!this.isVibeCodedMode()) {
1748
+ throw new Error("getSitemapProducts is available in salesChannelId mode only");
1749
+ }
1750
+ const res = await this.vibeCodedRequest("GET", "/sitemap-products", void 0, { limit: Math.min(limit, 5e3) });
1751
+ return res.data;
1752
+ }
1753
+ /**
1754
+ * Resolve an old (renamed) slug to the entity's CURRENT slug, so the
1755
+ * storefront can issue a permanent (301/308) redirect instead of a 404.
1756
+ *
1757
+ * Call this in the catch/not-found path of a product or blog page:
1758
+ * the platform records every slug rename, so a URL that stopped matching
1759
+ * usually has a redirect. Returns `null` when there is no redirect (real
1760
+ * 404) or outside sales-channel mode — always fall through to notFound().
1761
+ *
1762
+ * @example
1763
+ * ```typescript
1764
+ * // app/products/[slug]/page.tsx — in the catch path:
1765
+ * const redirect = await client.resolveSlugRedirect('product', slug);
1766
+ * if (redirect) permanentRedirect(`/products/${redirect.currentSlug}`);
1767
+ * notFound();
1768
+ * ```
1769
+ */
1770
+ async resolveSlugRedirect(entityType, slug) {
1771
+ if (!this.isVibeCodedMode()) return null;
1772
+ try {
1773
+ return await this.vibeCodedRequest(
1774
+ "GET",
1775
+ `/slug-redirects/${entityType}/${encodePathSegment(slug)}`
1776
+ );
1777
+ } catch {
1778
+ return null;
1779
+ }
1780
+ }
1656
1781
  /**
1657
1782
  * Get a single product by ID
1658
1783
  * Works in vibe-coded, storefront (public), and admin mode
@@ -5342,7 +5467,7 @@ var _BrainerceClient = class _BrainerceClient {
5342
5467
  * ```
5343
5468
  */
5344
5469
  async setCheckoutCustomer(checkoutId, data) {
5345
- const body = await this.withAnalyticsStitchIds(data);
5470
+ const body = this.withTrafficAttribution(await this.withAnalyticsStitchIds(data));
5346
5471
  if (this.isVibeCodedMode()) {
5347
5472
  return this.vibeCodedRequest(
5348
5473
  "PATCH",
@@ -5444,7 +5569,9 @@ var _BrainerceClient = class _BrainerceClient {
5444
5569
  * ```
5445
5570
  */
5446
5571
  async setShippingAddress(checkoutId, address) {
5447
- const body = await this.withAnalyticsStitchIds(this.stripResolvedOnlyAddressFields(address));
5572
+ const body = this.withTrafficAttribution(
5573
+ await this.withAnalyticsStitchIds(this.stripResolvedOnlyAddressFields(address))
5574
+ );
5448
5575
  if (this.isVibeCodedMode()) {
5449
5576
  return this.vibeCodedRequest(
5450
5577
  "PATCH",
@@ -8945,6 +9072,41 @@ var _BrainerceClient = class _BrainerceClient {
8945
9072
  { salesChannelId }
8946
9073
  );
8947
9074
  }
9075
+ /**
9076
+ * Attach a customer to a sales channel (admin mode) — marks them as active in
9077
+ * that storefront. Accepts the sales-channel record ID or its public `vc_*`
9078
+ * connection ID.
9079
+ *
9080
+ * Rarely needed: the platform records a channel by itself whenever the
9081
+ * customer registers, signs in or checks out on it. Use this for migrations
9082
+ * from another system and for fixing up records you created yourself. A
9083
+ * customer belongs to one store but can be active in any number of its
9084
+ * channels, so calling this for several channels is normal and expected.
9085
+ *
9086
+ * This does not change where the customer CAME FROM — for that, pass
9087
+ * `acquisitionSalesChannelId` to {@link updateCustomer}.
9088
+ */
9089
+ async publishCustomerToSalesChannel(customerId, salesChannelId) {
9090
+ return this.adminRequest(
9091
+ "POST",
9092
+ `/api/v1/customers/${encodePathSegment(customerId)}/publish-sales-channel`,
9093
+ { salesChannelId }
9094
+ );
9095
+ }
9096
+ /**
9097
+ * Detach a customer from a sales channel (admin mode).
9098
+ *
9099
+ * A correction, NOT a block — it does not stop that person from buying on
9100
+ * that storefront, and the channel is recorded again the next time they sign
9101
+ * in or order there. There is no way to bar a customer from a channel.
9102
+ */
9103
+ async unpublishCustomerFromSalesChannel(customerId, salesChannelId) {
9104
+ return this.adminRequest(
9105
+ "POST",
9106
+ `/api/v1/customers/${encodePathSegment(customerId)}/unpublish-sales-channel`,
9107
+ { salesChannelId }
9108
+ );
9109
+ }
8948
9110
  /**
8949
9111
  * Publish a coupon to a sales channel (admin mode) — makes it redeemable on
8950
9112
  * that vibe-coded storefront. Accepts the sales-channel record ID or its
@@ -9636,6 +9798,8 @@ _BrainerceClient.RESOLVED_ONLY_ADDRESS_FIELDS = [
9636
9798
  "lng",
9637
9799
  "formattedAddress"
9638
9800
  ];
9801
+ /** Attribution older than this is stale and never forwarded (classic 30-day window). */
9802
+ _BrainerceClient.TRAFFIC_ATTR_MAX_AGE_MS = 30 * 24 * 3600 * 1e3;
9639
9803
  var BrainerceClient = _BrainerceClient;
9640
9804
  var BrainerceError = class extends Error {
9641
9805
  constructor(message, statusCode, details) {
@@ -10136,7 +10300,8 @@ function buildProductJsonLd(product, opts) {
10136
10300
  const brand = opts.brandName ?? product.brands?.[0]?.name;
10137
10301
  const description = stripHtml(product.description).slice(0, 5e3);
10138
10302
  const effectivePrice = product.salePrice ?? product.basePrice;
10139
- const inStock = product.inventory ? (product.inventory.available ?? 0) > 0 : true;
10303
+ const inv = product.inventory;
10304
+ const availability = !inv ? "https://schema.org/InStock" : inv.inStock ?? (inv.available ?? 0) > 0 ? "https://schema.org/InStock" : inv.canPurchase ? "https://schema.org/BackOrder" : "https://schema.org/OutOfStock";
10140
10305
  const isVariable = product.type === "VARIABLE" && product.priceMin && product.priceMax;
10141
10306
  const itemCondition = "https://schema.org/NewCondition";
10142
10307
  const shippingDetails = (opts.shipping ?? []).filter((z) => z.amount !== null).map((z) => ({
@@ -10167,8 +10332,9 @@ function buildProductJsonLd(product, opts) {
10167
10332
  "@type": "AggregateOffer",
10168
10333
  lowPrice: product.priceMin,
10169
10334
  highPrice: product.priceMax,
10335
+ ...product.variants?.length ? { offerCount: product.variants.length } : {},
10170
10336
  priceCurrency: opts.currency,
10171
- availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
10337
+ availability,
10172
10338
  itemCondition,
10173
10339
  ...shippingDetails.length > 0 ? { shippingDetails } : {},
10174
10340
  ...url ? { url } : {}
@@ -10176,7 +10342,7 @@ function buildProductJsonLd(product, opts) {
10176
10342
  "@type": "Offer",
10177
10343
  price: effectivePrice,
10178
10344
  priceCurrency: opts.currency,
10179
- availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
10345
+ availability,
10180
10346
  itemCondition,
10181
10347
  // Only meaningful for an active sale price with a known end date —
10182
10348
  // a regular (non-sale) price has no expiry to declare.
@@ -10191,19 +10357,25 @@ function buildProductJsonLd(product, opts) {
10191
10357
  ...description ? { description } : {},
10192
10358
  ...images.length > 0 ? { image: images } : {},
10193
10359
  ...url ? { url } : {},
10194
- sku: product.sku,
10360
+ // Fall back to the product id so the offer always carries a stable SKU —
10361
+ // Merchant Center matching and review aggregation both key on it.
10362
+ sku: product.sku || product.id,
10195
10363
  // Identifiers Google uses to match a product to its catalog — key for
10196
10364
  // free merchant-listing eligibility without a Merchant Center feed.
10197
10365
  ...product.gtin ? { gtin: product.gtin } : {},
10198
10366
  ...product.mpn ? { mpn: product.mpn } : {},
10199
10367
  ...brand ? { brand: { "@type": "Brand", name: brand } } : {},
10200
10368
  offers: offer,
10201
- // Google policy: never emit an empty/zero rating block.
10369
+ // Google policy: never emit an empty/zero rating block. bestRating /
10370
+ // worstRating make the 1-5 scale explicit so aggregators can't misread
10371
+ // a 4.8 on an assumed 0-10 scale.
10202
10372
  ...product.reviewCount && product.reviewCount > 0 && product.avgRating ? {
10203
10373
  aggregateRating: {
10204
10374
  "@type": "AggregateRating",
10205
10375
  ratingValue: product.avgRating,
10206
- reviewCount: product.reviewCount
10376
+ reviewCount: product.reviewCount,
10377
+ bestRating: 5,
10378
+ worstRating: 1
10207
10379
  }
10208
10380
  } : {}
10209
10381
  };
@@ -10314,6 +10486,54 @@ async function getBlogSitemapEntries(client, opts) {
10314
10486
  }
10315
10487
  return entries;
10316
10488
  }
10489
+ async function getProductSitemapEntries(client, opts) {
10490
+ const base = opts.siteUrl.replace(/\/+$/, "");
10491
+ const basePath = opts.basePath ?? "/products";
10492
+ const pageSize = Math.min(opts.pageSize ?? 100, 100);
10493
+ const maxEntries = opts.maxEntries ?? 5e3;
10494
+ let rows = [];
10495
+ try {
10496
+ rows = await client.getSitemapProducts(maxEntries);
10497
+ } catch {
10498
+ let page = 1;
10499
+ for (; ; ) {
10500
+ const res = await client.getProducts({ page, limit: pageSize });
10501
+ rows.push(
10502
+ ...res.data.map((p) => ({
10503
+ slug: p.slug ?? null,
10504
+ id: p.id,
10505
+ updatedAt: p.updatedAt,
10506
+ localeSlugs: p.localeSlugs ?? null
10507
+ }))
10508
+ );
10509
+ if (page >= res.meta.totalPages || rows.length >= maxEntries) break;
10510
+ page += 1;
10511
+ }
10512
+ }
10513
+ const nonDefaultLocales = opts.locales?.filter((locale) => locale !== opts.defaultLocale) ?? [];
10514
+ const entries = [];
10515
+ for (const row of rows.slice(0, maxEntries)) {
10516
+ const baseSlug = row.slug || row.id;
10517
+ if (!baseSlug) continue;
10518
+ const lastModified = row.updatedAt ? new Date(row.updatedAt) : void 0;
10519
+ const localeSlugs = row.localeSlugs ?? {};
10520
+ entries.push({
10521
+ url: `${base}${basePath}/${baseSlug}`,
10522
+ ...lastModified ? { lastModified } : {},
10523
+ changeFrequency: "daily",
10524
+ priority: 0.8
10525
+ });
10526
+ for (const locale of nonDefaultLocales) {
10527
+ entries.push({
10528
+ url: `${base}/${locale}${basePath}/${localeSlugs[locale] || baseSlug}`,
10529
+ ...lastModified ? { lastModified } : {},
10530
+ changeFrequency: "daily",
10531
+ priority: 0.7
10532
+ });
10533
+ }
10534
+ }
10535
+ return entries;
10536
+ }
10317
10537
  async function getCategorySitemapEntries(client, opts) {
10318
10538
  const base = opts.siteUrl.replace(/\/+$/, "");
10319
10539
  const basePath = opts.basePath ?? "/category";
@@ -10619,6 +10839,7 @@ function isCouponApplicableToProduct(coupon, productId) {
10619
10839
  getProductMetafieldsByType,
10620
10840
  getProductPrice,
10621
10841
  getProductPriceInfo,
10842
+ getProductSitemapEntries,
10622
10843
  getProductSwatches,
10623
10844
  getStockStatus,
10624
10845
  getVariantOptions,