@asteby/storefront 0.6.0 → 0.7.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/README.md CHANGED
@@ -237,6 +237,38 @@ paymentErrorView(error) // network (re
237
237
  - `promoProgressView(cart.promotions, locale)` and `rewardLabel(reward, locale)` only format what OPS sends (`remaining`, `percent`, `reached`); never subtract or divide in the shop.
238
238
  - The mock serves `POST /checkouts/:id/payment` with outcomes by token (`tok_APRO` paid, `tok_FUND`/`tok_SECU`/`tok_EXPI`/`tok_FORM`/`tok_CALL`/`tok_OTHE` rejected, `tok_CONT` in review, `tok_3DS` challenge, `method: 'cash'` voucher), idempotency, `price_mismatch` 422, attempts cap 429 and `409` while another attempt is paid/pending; plus `PUT /carts/:id/note`, `GET /carts/:id/recommendations` and `GET /promotions`.
239
239
 
240
+ ## Domain and market (single-customer storefront)
241
+
242
+ A storefront that belongs to ONE organization sends its verified domain plus the
243
+ market it is serving. OPS resolves the organization from the domain and honors
244
+ the market when that organization has it active (otherwise it falls back to the
245
+ domain's own market).
246
+
247
+ ```ts
248
+ import { createStorefrontClient, storefrontDomainHeader } from '@asteby/storefront'
249
+
250
+ const client = createStorefrontClient({
251
+ baseUrl: 'https://ops.example.com/api/gw',
252
+ token: process.env.STOREFRONT_SK!,
253
+ domain: 'godsnutrition.com', // verified domain of the organization
254
+ market: 'mx', // -> X-Storefront-Domain: godsnutrition.com#mx
255
+ })
256
+
257
+ storefrontDomainHeader('godsnutrition.com', 'MX') // 'godsnutrition.com#mx'
258
+ storefrontDomainHeader('godsnutrition.com') // 'godsnutrition.com'
259
+ ```
260
+
261
+ Per-request market (one domain, path prefixes `/mx`, `/us`): create the client
262
+ per request with that request's `market`, or build the header yourself with
263
+ `storefrontDomainHeader` and pass it in `headers`.
264
+
265
+ ### Market detection and hreflang
266
+
267
+ Pure helpers, no DOM: `visitorCountry(headers)` (CDN country headers),
268
+ `preferredLanguages` / `negotiateLocale` (Accept-Language), `suggestMarket`
269
+ (suggests, never redirects; respects crawlers and a saved choice; country wins
270
+ over language) and `hreflangLinks` for per-market SEO.
271
+
240
272
  ## API
241
273
 
242
274
  `createStorefrontClient(options)`:
@@ -249,6 +281,8 @@ paymentErrorView(error) // network (re
249
281
  | `clientIp?` | Shopper IP for server-side calls → `X-Storefront-Client-IP` (see below) |
250
282
  | `fetch?` | Custom fetch (default `globalThis.fetch`) |
251
283
  | `locale?` | `Accept-Language` |
284
+ | `domain?` | Public host of the storefront → `X-Storefront-Domain` (see below) |
285
+ | `market?` | Market code (`mx`, `us`) → `X-Storefront-Domain: <domain>#<market>`; ignored without `domain` |
252
286
  | `headers?` | Extra headers on every request |
253
287
  | `timeoutMs?` | Abort after N ms (`StorefrontError` code `timeout`) |
254
288
  | `onRequest?(ctx)` | Runs before each request; return a `Response` to short-circuit (SSR cache hit) |
package/dist/index.cjs CHANGED
@@ -205,6 +205,7 @@ function createStorefrontClient(config) {
205
205
  headers.set("Accept", "application/json");
206
206
  headers.set("Authorization", `Bearer ${config.token}`);
207
207
  if (config.locale) headers.set("Accept-Language", config.locale);
208
+ if (config.domain) headers.set("X-Storefront-Domain", storefrontDomainHeader(config.domain, config.market));
208
209
  const customerToken = options.customerToken !== void 0 ? options.customerToken : config.customerToken;
209
210
  if (customerToken) headers.set("X-Customer-Token", customerToken);
210
211
  const clientIp = options.clientIp !== void 0 ? options.clientIp : config.clientIp;
@@ -407,6 +408,11 @@ function createStorefrontClient(config) {
407
408
  };
408
409
  return client;
409
410
  }
411
+ function storefrontDomainHeader(domain, market) {
412
+ const host = domain.trim();
413
+ const code = (market ?? "").trim().toLowerCase();
414
+ return code && /^[a-z0-9_-]{1,32}$/.test(code) ? `${host}#${code}` : host;
415
+ }
410
416
 
411
417
  // src/availability.ts
412
418
  var ES = {
@@ -1045,7 +1051,67 @@ function promoProgressView(promotions, locale, overrides) {
1045
1051
  return { discount, shipping };
1046
1052
  }
1047
1053
 
1054
+ // src/market.ts
1055
+ var COUNTRY_HEADERS = ["cf-ipcountry", "cloudfront-viewer-country", "x-vercel-ip-country", "x-appengine-country", "x-country-code"];
1056
+ var BOT = /bot|crawl|spider|slurp|bing|facebookexternalhit|embedly|preview|lighthouse|headless|curl|wget/i;
1057
+ function visitorCountry(headers, { trustProxy = true } = {}) {
1058
+ if (!trustProxy) return null;
1059
+ for (const name of COUNTRY_HEADERS) {
1060
+ const value = headers.get(name)?.trim().toUpperCase();
1061
+ if (value && /^[A-Z]{2}$/.test(value) && value !== "XX" && value !== "T1") return value;
1062
+ }
1063
+ return null;
1064
+ }
1065
+ function preferredLanguages(acceptLanguage) {
1066
+ if (!acceptLanguage) return [];
1067
+ return acceptLanguage.split(",").map((part, index) => {
1068
+ const [tag = "", ...params] = part.trim().split(";");
1069
+ const q = Number(params.find((p) => p.trim().startsWith("q="))?.split("=")[1] ?? 1);
1070
+ return { tag: tag.trim().toLowerCase(), q: Number.isFinite(q) ? q : 0, index };
1071
+ }).filter((l) => l.tag && l.tag !== "*" && l.q > 0).sort((a, b) => b.q - a.q || a.index - b.index).map((l) => l.tag);
1072
+ }
1073
+ function negotiateLocale(acceptLanguage, available, fallback) {
1074
+ const lowered = new Map(available.map((l) => [l.toLowerCase(), l]));
1075
+ for (const wanted of preferredLanguages(acceptLanguage)) {
1076
+ const exact = lowered.get(wanted);
1077
+ if (exact) return exact;
1078
+ const base = wanted.split("-")[0];
1079
+ const sameBase = available.find((l) => l.toLowerCase().split("-")[0] === base);
1080
+ if (sameBase) return sameBase;
1081
+ }
1082
+ return fallback;
1083
+ }
1084
+ function suggestMarket({
1085
+ headers,
1086
+ markets,
1087
+ current,
1088
+ chosen,
1089
+ trustProxy = true
1090
+ }) {
1091
+ if (chosen || markets.length < 2) return null;
1092
+ if (BOT.test(headers.get("user-agent") ?? "")) return null;
1093
+ const country = visitorCountry(headers, { trustProxy });
1094
+ if (country) {
1095
+ const target = markets.find((m) => m.countries.includes(country));
1096
+ return target && target.code !== current ? { market: target, reason: "country" } : null;
1097
+ }
1098
+ for (const tag of preferredLanguages(headers.get("accept-language"))) {
1099
+ const region = tag.split("-")[1]?.toUpperCase();
1100
+ if (!region) continue;
1101
+ const target = markets.find((m) => m.countries.includes(region));
1102
+ if (target) return target.code !== current ? { market: target, reason: "language" } : null;
1103
+ }
1104
+ return null;
1105
+ }
1106
+ function hreflangLinks(markets, path, defaultMarket, protocol = "https") {
1107
+ const links = markets.filter((m) => m.host).map((m) => ({ hrefLang: m.locale, href: `${protocol}://${m.host}${path}` }));
1108
+ const fallback = markets.find((m) => m.code === defaultMarket && m.host);
1109
+ if (fallback) links.push({ hrefLang: "x-default", href: `${protocol}://${fallback.host}${path}` });
1110
+ return links;
1111
+ }
1112
+
1048
1113
  exports.CLIENT_IP_HEADER = CLIENT_IP_HEADER;
1114
+ exports.COUNTRY_HEADERS = COUNTRY_HEADERS;
1049
1115
  exports.NOTE_MAX_LENGTH = NOTE_MAX_LENGTH;
1050
1116
  exports.StorefrontError = StorefrontError;
1051
1117
  exports.appendParams = appendParams;
@@ -1061,8 +1127,10 @@ exports.formatAvailabilityMessage = formatAvailabilityMessage;
1061
1127
  exports.formatMoney = formatMoney;
1062
1128
  exports.formatVoucherExpiry = formatVoucherExpiry;
1063
1129
  exports.generateIdempotencyKey = generateIdempotencyKey;
1130
+ exports.hreflangLinks = hreflangLinks;
1064
1131
  exports.isStorefrontError = isStorefrontError;
1065
1132
  exports.isTokenPublishable = isTokenPublishable;
1133
+ exports.negotiateLocale = negotiateLocale;
1066
1134
  exports.normalizeClientIp = normalizeClientIp;
1067
1135
  exports.normalizeNote = normalizeNote;
1068
1136
  exports.parseMoney = parseMoney;
@@ -1071,6 +1139,7 @@ exports.paymentErrorView = paymentErrorView;
1071
1139
  exports.paymentIdempotencyKey = paymentIdempotencyKey;
1072
1140
  exports.paymentMessages = paymentMessages;
1073
1141
  exports.paymentOutcome = paymentOutcome;
1142
+ exports.preferredLanguages = preferredLanguages;
1074
1143
  exports.productAvailability = productAvailability;
1075
1144
  exports.productListQuery = productListQuery;
1076
1145
  exports.promoProgressView = promoProgressView;
@@ -1079,6 +1148,9 @@ exports.resolveAvailability = resolveAvailability;
1079
1148
  exports.rewardLabel = rewardLabel;
1080
1149
  exports.safeHttpsUrl = safeHttpsUrl;
1081
1150
  exports.schemaAvailability = schemaAvailability;
1151
+ exports.storefrontDomainHeader = storefrontDomainHeader;
1152
+ exports.suggestMarket = suggestMarket;
1082
1153
  exports.toMoney = toMoney;
1154
+ exports.visitorCountry = visitorCountry;
1083
1155
  //# sourceMappingURL=index.cjs.map
1084
1156
  //# sourceMappingURL=index.cjs.map