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.mjs CHANGED
@@ -212,6 +212,14 @@ var _BrainerceClient = class _BrainerceClient {
212
212
  * This is needed because Stripe redirects lose in-memory state.
213
213
  */
214
214
  this.ACTIVE_CHECKOUT_KEY = "brainerce_active_checkout";
215
+ /**
216
+ * Merge the resolved GA4 stitch ids onto a request body — only for fields
217
+ * the caller didn't already set explicitly (explicit values always win).
218
+ * No-op (returns `dto` unchanged) if `loadGoogleAnalytics()` was never
219
+ * called, or if it hasn't resolved any ids by the time this is awaited.
220
+ */
221
+ /** localStorage key holding the last non-direct-touch attribution blob. */
222
+ this.TRAFFIC_ATTR_KEY = "brainerce_attr";
215
223
  // -------------------- Contact Forms (schema) --------------------
216
224
  /**
217
225
  * List active contact forms configured for the store.
@@ -617,6 +625,7 @@ var _BrainerceClient = class _BrainerceClient {
617
625
  this.onCartReset = options.onCartReset;
618
626
  this.hydrateSessionCart();
619
627
  this.detectRecoverCartFromUrl();
628
+ this.captureTrafficAttribution();
620
629
  }
621
630
  // -------------------- Locale --------------------
622
631
  /**
@@ -1439,11 +1448,82 @@ var _BrainerceClient = class _BrainerceClient {
1439
1448
  }
1440
1449
  }
1441
1450
  /**
1442
- * Merge the resolved GA4 stitch ids onto a request body — only for fields
1443
- * the caller didn't already set explicitly (explicit values always win).
1444
- * No-op (returns `dto` unchanged) if `loadGoogleAnalytics()` was never
1445
- * called, or if it hasn't resolved any ids by the time this is awaited.
1451
+ * Record the visit's traffic origin LAST NON-DIRECT TOUCH semantics: an
1452
+ * external referrer or any utm_source overwrites the stored blob; a direct
1453
+ * or internal navigation keeps the previous touch. Runs once per client
1454
+ * construction (i.e. per page load in browser storefronts) and must never
1455
+ * throw — attribution is telemetry, the storefront always wins.
1456
+ */
1457
+ captureTrafficAttribution() {
1458
+ try {
1459
+ if (typeof window === "undefined" || !window.localStorage) return;
1460
+ const params = new URLSearchParams(window.location.search);
1461
+ const utmSource = params.get("utm_source") || void 0;
1462
+ const utmMedium = params.get("utm_medium") || void 0;
1463
+ const utmCampaign = params.get("utm_campaign") || void 0;
1464
+ let referrerHost;
1465
+ if (document.referrer) {
1466
+ try {
1467
+ const ref = new URL(document.referrer);
1468
+ if (ref.hostname && ref.hostname !== window.location.hostname) {
1469
+ referrerHost = ref.hostname.toLowerCase().replace(/^www\./, "");
1470
+ }
1471
+ } catch {
1472
+ }
1473
+ }
1474
+ if (!referrerHost && !utmSource) return;
1475
+ window.localStorage.setItem(
1476
+ this.TRAFFIC_ATTR_KEY,
1477
+ JSON.stringify({ referrerHost, utmSource, utmMedium, utmCampaign, at: Date.now() })
1478
+ );
1479
+ } catch {
1480
+ }
1481
+ }
1482
+ /** The stored attribution as request-body fields, or null when absent/stale. */
1483
+ getTrafficAttribution() {
1484
+ try {
1485
+ if (typeof window === "undefined" || !window.localStorage) return null;
1486
+ const raw = window.localStorage.getItem(this.TRAFFIC_ATTR_KEY);
1487
+ if (!raw) return null;
1488
+ const blob = JSON.parse(raw);
1489
+ if (!blob || typeof blob !== "object") return null;
1490
+ if (typeof blob.at !== "number" || Date.now() - blob.at > _BrainerceClient.TRAFFIC_ATTR_MAX_AGE_MS) {
1491
+ return null;
1492
+ }
1493
+ const out = {};
1494
+ if (typeof blob.referrerHost === "string" && blob.referrerHost) {
1495
+ out.trafficReferrerHost = blob.referrerHost.slice(0, 253);
1496
+ }
1497
+ if (typeof blob.utmSource === "string" && blob.utmSource) {
1498
+ out.trafficUtmSource = blob.utmSource.slice(0, 150);
1499
+ }
1500
+ if (typeof blob.utmMedium === "string" && blob.utmMedium) {
1501
+ out.trafficUtmMedium = blob.utmMedium.slice(0, 150);
1502
+ }
1503
+ if (typeof blob.utmCampaign === "string" && blob.utmCampaign) {
1504
+ out.trafficUtmCampaign = blob.utmCampaign.slice(0, 150);
1505
+ }
1506
+ return Object.keys(out).length > 0 ? out : null;
1507
+ } catch {
1508
+ return null;
1509
+ }
1510
+ }
1511
+ /**
1512
+ * Merge the stored traffic attribution onto a request body — only for
1513
+ * fields the caller didn't set explicitly (explicit values always win),
1514
+ * mirroring `withAnalyticsStitchIds`. No-op outside the browser.
1446
1515
  */
1516
+ withTrafficAttribution(dto) {
1517
+ const attr = this.getTrafficAttribution();
1518
+ if (!attr) return dto;
1519
+ return {
1520
+ ...dto ?? {},
1521
+ trafficReferrerHost: dto?.trafficReferrerHost ?? attr.trafficReferrerHost,
1522
+ trafficUtmSource: dto?.trafficUtmSource ?? attr.trafficUtmSource,
1523
+ trafficUtmMedium: dto?.trafficUtmMedium ?? attr.trafficUtmMedium,
1524
+ trafficUtmCampaign: dto?.trafficUtmCampaign ?? attr.trafficUtmCampaign
1525
+ };
1526
+ }
1447
1527
  async withAnalyticsStitchIds(dto) {
1448
1528
  if (!this._ga4StitchPromise) return dto;
1449
1529
  try {
@@ -1567,6 +1647,50 @@ var _BrainerceClient = class _BrainerceClient {
1567
1647
  queryParamsWithRegion
1568
1648
  );
1569
1649
  }
1650
+ /**
1651
+ * Lightweight product rows for sitemap generation: `slug`, `updatedAt`, and
1652
+ * per-locale `localeSlugs` only — up to `limit` (max 5000) in ONE request,
1653
+ * with none of the 100-per-page clamp the full listing applies.
1654
+ *
1655
+ * Sales-channel (`salesChannelId`) mode only; other modes throw so
1656
+ * {@link getProductSitemapEntries} (sitemap.ts) can catch and fall back to
1657
+ * paginating `getProducts`. Prefer that helper over calling this directly.
1658
+ */
1659
+ async getSitemapProducts(limit = 5e3) {
1660
+ if (!this.isVibeCodedMode()) {
1661
+ throw new Error("getSitemapProducts is available in salesChannelId mode only");
1662
+ }
1663
+ const res = await this.vibeCodedRequest("GET", "/sitemap-products", void 0, { limit: Math.min(limit, 5e3) });
1664
+ return res.data;
1665
+ }
1666
+ /**
1667
+ * Resolve an old (renamed) slug to the entity's CURRENT slug, so the
1668
+ * storefront can issue a permanent (301/308) redirect instead of a 404.
1669
+ *
1670
+ * Call this in the catch/not-found path of a product or blog page:
1671
+ * the platform records every slug rename, so a URL that stopped matching
1672
+ * usually has a redirect. Returns `null` when there is no redirect (real
1673
+ * 404) or outside sales-channel mode — always fall through to notFound().
1674
+ *
1675
+ * @example
1676
+ * ```typescript
1677
+ * // app/products/[slug]/page.tsx — in the catch path:
1678
+ * const redirect = await client.resolveSlugRedirect('product', slug);
1679
+ * if (redirect) permanentRedirect(`/products/${redirect.currentSlug}`);
1680
+ * notFound();
1681
+ * ```
1682
+ */
1683
+ async resolveSlugRedirect(entityType, slug) {
1684
+ if (!this.isVibeCodedMode()) return null;
1685
+ try {
1686
+ return await this.vibeCodedRequest(
1687
+ "GET",
1688
+ `/slug-redirects/${entityType}/${encodePathSegment(slug)}`
1689
+ );
1690
+ } catch {
1691
+ return null;
1692
+ }
1693
+ }
1570
1694
  /**
1571
1695
  * Get a single product by ID
1572
1696
  * Works in vibe-coded, storefront (public), and admin mode
@@ -5256,7 +5380,7 @@ var _BrainerceClient = class _BrainerceClient {
5256
5380
  * ```
5257
5381
  */
5258
5382
  async setCheckoutCustomer(checkoutId, data) {
5259
- const body = await this.withAnalyticsStitchIds(data);
5383
+ const body = this.withTrafficAttribution(await this.withAnalyticsStitchIds(data));
5260
5384
  if (this.isVibeCodedMode()) {
5261
5385
  return this.vibeCodedRequest(
5262
5386
  "PATCH",
@@ -5358,7 +5482,9 @@ var _BrainerceClient = class _BrainerceClient {
5358
5482
  * ```
5359
5483
  */
5360
5484
  async setShippingAddress(checkoutId, address) {
5361
- const body = await this.withAnalyticsStitchIds(this.stripResolvedOnlyAddressFields(address));
5485
+ const body = this.withTrafficAttribution(
5486
+ await this.withAnalyticsStitchIds(this.stripResolvedOnlyAddressFields(address))
5487
+ );
5362
5488
  if (this.isVibeCodedMode()) {
5363
5489
  return this.vibeCodedRequest(
5364
5490
  "PATCH",
@@ -8859,6 +8985,41 @@ var _BrainerceClient = class _BrainerceClient {
8859
8985
  { salesChannelId }
8860
8986
  );
8861
8987
  }
8988
+ /**
8989
+ * Attach a customer to a sales channel (admin mode) — marks them as active in
8990
+ * that storefront. Accepts the sales-channel record ID or its public `vc_*`
8991
+ * connection ID.
8992
+ *
8993
+ * Rarely needed: the platform records a channel by itself whenever the
8994
+ * customer registers, signs in or checks out on it. Use this for migrations
8995
+ * from another system and for fixing up records you created yourself. A
8996
+ * customer belongs to one store but can be active in any number of its
8997
+ * channels, so calling this for several channels is normal and expected.
8998
+ *
8999
+ * This does not change where the customer CAME FROM — for that, pass
9000
+ * `acquisitionSalesChannelId` to {@link updateCustomer}.
9001
+ */
9002
+ async publishCustomerToSalesChannel(customerId, salesChannelId) {
9003
+ return this.adminRequest(
9004
+ "POST",
9005
+ `/api/v1/customers/${encodePathSegment(customerId)}/publish-sales-channel`,
9006
+ { salesChannelId }
9007
+ );
9008
+ }
9009
+ /**
9010
+ * Detach a customer from a sales channel (admin mode).
9011
+ *
9012
+ * A correction, NOT a block — it does not stop that person from buying on
9013
+ * that storefront, and the channel is recorded again the next time they sign
9014
+ * in or order there. There is no way to bar a customer from a channel.
9015
+ */
9016
+ async unpublishCustomerFromSalesChannel(customerId, salesChannelId) {
9017
+ return this.adminRequest(
9018
+ "POST",
9019
+ `/api/v1/customers/${encodePathSegment(customerId)}/unpublish-sales-channel`,
9020
+ { salesChannelId }
9021
+ );
9022
+ }
8862
9023
  /**
8863
9024
  * Publish a coupon to a sales channel (admin mode) — makes it redeemable on
8864
9025
  * that vibe-coded storefront. Accepts the sales-channel record ID or its
@@ -9550,6 +9711,8 @@ _BrainerceClient.RESOLVED_ONLY_ADDRESS_FIELDS = [
9550
9711
  "lng",
9551
9712
  "formattedAddress"
9552
9713
  ];
9714
+ /** Attribution older than this is stale and never forwarded (classic 30-day window). */
9715
+ _BrainerceClient.TRAFFIC_ATTR_MAX_AGE_MS = 30 * 24 * 3600 * 1e3;
9553
9716
  var BrainerceClient = _BrainerceClient;
9554
9717
  var BrainerceError = class extends Error {
9555
9718
  constructor(message, statusCode, details) {
@@ -10050,7 +10213,8 @@ function buildProductJsonLd(product, opts) {
10050
10213
  const brand = opts.brandName ?? product.brands?.[0]?.name;
10051
10214
  const description = stripHtml(product.description).slice(0, 5e3);
10052
10215
  const effectivePrice = product.salePrice ?? product.basePrice;
10053
- const inStock = product.inventory ? (product.inventory.available ?? 0) > 0 : true;
10216
+ const inv = product.inventory;
10217
+ 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";
10054
10218
  const isVariable = product.type === "VARIABLE" && product.priceMin && product.priceMax;
10055
10219
  const itemCondition = "https://schema.org/NewCondition";
10056
10220
  const shippingDetails = (opts.shipping ?? []).filter((z) => z.amount !== null).map((z) => ({
@@ -10081,8 +10245,9 @@ function buildProductJsonLd(product, opts) {
10081
10245
  "@type": "AggregateOffer",
10082
10246
  lowPrice: product.priceMin,
10083
10247
  highPrice: product.priceMax,
10248
+ ...product.variants?.length ? { offerCount: product.variants.length } : {},
10084
10249
  priceCurrency: opts.currency,
10085
- availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
10250
+ availability,
10086
10251
  itemCondition,
10087
10252
  ...shippingDetails.length > 0 ? { shippingDetails } : {},
10088
10253
  ...url ? { url } : {}
@@ -10090,7 +10255,7 @@ function buildProductJsonLd(product, opts) {
10090
10255
  "@type": "Offer",
10091
10256
  price: effectivePrice,
10092
10257
  priceCurrency: opts.currency,
10093
- availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
10258
+ availability,
10094
10259
  itemCondition,
10095
10260
  // Only meaningful for an active sale price with a known end date —
10096
10261
  // a regular (non-sale) price has no expiry to declare.
@@ -10105,19 +10270,25 @@ function buildProductJsonLd(product, opts) {
10105
10270
  ...description ? { description } : {},
10106
10271
  ...images.length > 0 ? { image: images } : {},
10107
10272
  ...url ? { url } : {},
10108
- sku: product.sku,
10273
+ // Fall back to the product id so the offer always carries a stable SKU —
10274
+ // Merchant Center matching and review aggregation both key on it.
10275
+ sku: product.sku || product.id,
10109
10276
  // Identifiers Google uses to match a product to its catalog — key for
10110
10277
  // free merchant-listing eligibility without a Merchant Center feed.
10111
10278
  ...product.gtin ? { gtin: product.gtin } : {},
10112
10279
  ...product.mpn ? { mpn: product.mpn } : {},
10113
10280
  ...brand ? { brand: { "@type": "Brand", name: brand } } : {},
10114
10281
  offers: offer,
10115
- // Google policy: never emit an empty/zero rating block.
10282
+ // Google policy: never emit an empty/zero rating block. bestRating /
10283
+ // worstRating make the 1-5 scale explicit so aggregators can't misread
10284
+ // a 4.8 on an assumed 0-10 scale.
10116
10285
  ...product.reviewCount && product.reviewCount > 0 && product.avgRating ? {
10117
10286
  aggregateRating: {
10118
10287
  "@type": "AggregateRating",
10119
10288
  ratingValue: product.avgRating,
10120
- reviewCount: product.reviewCount
10289
+ reviewCount: product.reviewCount,
10290
+ bestRating: 5,
10291
+ worstRating: 1
10121
10292
  }
10122
10293
  } : {}
10123
10294
  };
@@ -10228,6 +10399,54 @@ async function getBlogSitemapEntries(client, opts) {
10228
10399
  }
10229
10400
  return entries;
10230
10401
  }
10402
+ async function getProductSitemapEntries(client, opts) {
10403
+ const base = opts.siteUrl.replace(/\/+$/, "");
10404
+ const basePath = opts.basePath ?? "/products";
10405
+ const pageSize = Math.min(opts.pageSize ?? 100, 100);
10406
+ const maxEntries = opts.maxEntries ?? 5e3;
10407
+ let rows = [];
10408
+ try {
10409
+ rows = await client.getSitemapProducts(maxEntries);
10410
+ } catch {
10411
+ let page = 1;
10412
+ for (; ; ) {
10413
+ const res = await client.getProducts({ page, limit: pageSize });
10414
+ rows.push(
10415
+ ...res.data.map((p) => ({
10416
+ slug: p.slug ?? null,
10417
+ id: p.id,
10418
+ updatedAt: p.updatedAt,
10419
+ localeSlugs: p.localeSlugs ?? null
10420
+ }))
10421
+ );
10422
+ if (page >= res.meta.totalPages || rows.length >= maxEntries) break;
10423
+ page += 1;
10424
+ }
10425
+ }
10426
+ const nonDefaultLocales = opts.locales?.filter((locale) => locale !== opts.defaultLocale) ?? [];
10427
+ const entries = [];
10428
+ for (const row of rows.slice(0, maxEntries)) {
10429
+ const baseSlug = row.slug || row.id;
10430
+ if (!baseSlug) continue;
10431
+ const lastModified = row.updatedAt ? new Date(row.updatedAt) : void 0;
10432
+ const localeSlugs = row.localeSlugs ?? {};
10433
+ entries.push({
10434
+ url: `${base}${basePath}/${baseSlug}`,
10435
+ ...lastModified ? { lastModified } : {},
10436
+ changeFrequency: "daily",
10437
+ priority: 0.8
10438
+ });
10439
+ for (const locale of nonDefaultLocales) {
10440
+ entries.push({
10441
+ url: `${base}/${locale}${basePath}/${localeSlugs[locale] || baseSlug}`,
10442
+ ...lastModified ? { lastModified } : {},
10443
+ changeFrequency: "daily",
10444
+ priority: 0.7
10445
+ });
10446
+ }
10447
+ }
10448
+ return entries;
10449
+ }
10231
10450
  async function getCategorySitemapEntries(client, opts) {
10232
10451
  const base = opts.siteUrl.replace(/\/+$/, "");
10233
10452
  const basePath = opts.basePath ?? "/category";
@@ -10532,6 +10751,7 @@ export {
10532
10751
  getProductMetafieldsByType,
10533
10752
  getProductPrice,
10534
10753
  getProductPriceInfo,
10754
+ getProductSitemapEntries,
10535
10755
  getProductSwatches,
10536
10756
  getStockStatus,
10537
10757
  getVariantOptions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainerce",
3
- "version": "1.55.0",
3
+ "version": "1.57.0",
4
4
  "description": "Official SDK for building e-commerce storefronts with Brainerce Platform. Perfect for vibe-coded sites, AI-built stores (Cursor, Lovable, v0), and custom storefronts.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",