@nuskin/react-search-sdk 1.0.1-code-cleanup.1 → 1.0.1

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
@@ -1,12 +1,12 @@
1
- # @nuskin/react-search-sdk
1
+ # @nuskin/react-search-sdk
2
2
 
3
- The search experience for the NuSkin storefront: typeahead and recent searches over the Equinox
3
+ The search experience for the Nu Skin storefront: typeahead and recent searches over the Equinox
4
4
  catalog, and the NUi agent when a signed-in shopper searches for something with no products.
5
5
 
6
6
  A host renders one component, `CommerceSearch`, and gives it the market, who the customer is, how to
7
7
  navigate, and its logo. The package works out the rest itself: the market's catalog lookup, the
8
8
  minimum search length, which NUi app to use, the panel's text and the analytics events. The panel UI
9
- comes from [`@nuskin/react-search-components`](../react-search-components) and the agent from
9
+ comes from `@nuskin/react-search-components` and the agent from
10
10
  `@nuskin/react-nui-chat-sdk`.
11
11
 
12
12
  ## Requirements
package/dist/index.d.mts CHANGED
@@ -16,8 +16,8 @@ type NavigationTarget = {
16
16
  /**
17
17
  * The account types the suggestions API distinguishes, sent as `X-Customer-Type`.
18
18
  *
19
- * TODO: this set is a best guess from the values the storefront sends. Confirm what the
20
- * suggestions API accepts, experimentally, once the search runs against it.
19
+ * TODO: PA-1254 validates this set, which is a best guess from the values the storefront sends,
20
+ * against the storefront's customer types and what the suggestions API accepts.
21
21
  */
22
22
  type CustomerAccountType = 'guest' | 'Brand Affiliate' | 'Retail Customer' | 'Preferred Customer/Member';
23
23
  /** Who is searching. */
@@ -58,6 +58,6 @@ interface CommerceSearchProps {
58
58
  * The package works out the rest itself: the market's catalog lookup, the minimum search length,
59
59
  * which NUi app to use, the panel's text and the analytics fields.
60
60
  */
61
- declare function CommerceSearch({ isOpen, onClose, navigate, customer, country, language, logo, }: Readonly<CommerceSearchProps>): ReactElement | null;
61
+ declare function CommerceSearch(props: Readonly<CommerceSearchProps>): ReactElement | null;
62
62
 
63
63
  export { CommerceSearch, type CommerceSearchCustomer, type CommerceSearchProps, type CustomerAccountType, type NavigationTarget };
package/dist/index.d.ts CHANGED
@@ -16,8 +16,8 @@ type NavigationTarget = {
16
16
  /**
17
17
  * The account types the suggestions API distinguishes, sent as `X-Customer-Type`.
18
18
  *
19
- * TODO: this set is a best guess from the values the storefront sends. Confirm what the
20
- * suggestions API accepts, experimentally, once the search runs against it.
19
+ * TODO: PA-1254 validates this set, which is a best guess from the values the storefront sends,
20
+ * against the storefront's customer types and what the suggestions API accepts.
21
21
  */
22
22
  type CustomerAccountType = 'guest' | 'Brand Affiliate' | 'Retail Customer' | 'Preferred Customer/Member';
23
23
  /** Who is searching. */
@@ -58,6 +58,6 @@ interface CommerceSearchProps {
58
58
  * The package works out the rest itself: the market's catalog lookup, the minimum search length,
59
59
  * which NUi app to use, the panel's text and the analytics fields.
60
60
  */
61
- declare function CommerceSearch({ isOpen, onClose, navigate, customer, country, language, logo, }: Readonly<CommerceSearchProps>): ReactElement | null;
61
+ declare function CommerceSearch(props: Readonly<CommerceSearchProps>): ReactElement | null;
62
62
 
63
63
  export { CommerceSearch, type CommerceSearchCustomer, type CommerceSearchProps, type CustomerAccountType, type NavigationTarget };
package/dist/index.js CHANGED
@@ -3,11 +3,11 @@
3
3
  var react = require('react');
4
4
  var configurationSdk = require('@nuskin/configuration-sdk');
5
5
  var reactSearchComponents = require('@nuskin/react-search-components');
6
+ var jsxRuntime = require('react/jsx-runtime');
6
7
  var reactNuiChatSdk = require('@nuskin/react-nui-chat-sdk');
7
8
  require('@nuskin/react-nui-chat-sdk/styles.css');
8
9
  var reactProductSdk = require('@nuskin/react-product-sdk');
9
10
  var styled = require('@emotion/styled');
10
- var jsxRuntime = require('react/jsx-runtime');
11
11
 
12
12
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
13
 
@@ -180,25 +180,38 @@ function useSearchPanelCopy({
180
180
  }
181
181
 
182
182
  // src/services/localeParts.ts
183
- function normalizeLocaleParts({
183
+ var TWO_LETTERS = /^[A-Za-z]{2}$/;
184
+ function marketProblem(country, language) {
185
+ const countryText = String(country ?? "").trim();
186
+ const languageText = String(language ?? "").trim();
187
+ if (TWO_LETTERS.test(countryText) && TWO_LETTERS.test(languageText)) {
188
+ return void 0;
189
+ }
190
+ return `the market is not valid (country ${JSON.stringify(countryText)}, language ${JSON.stringify(languageText)}): each must be two letters, for example US and en`;
191
+ }
192
+ function isValidMarket(country, language) {
193
+ return marketProblem(country, language) === void 0;
194
+ }
195
+ function createLocaleParts({
184
196
  country,
185
- language,
186
- locale
197
+ language
187
198
  }) {
188
- const normalizedCountry = String(country ?? "").trim().toUpperCase();
189
- const normalizedLanguage = String(language ?? "").trim().toLowerCase();
190
- const normalizedLocale = String(locale ?? "").trim() || "UNKNOWN";
199
+ const problem = marketProblem(country, language);
200
+ if (problem) throw new Error(problem);
201
+ const normalizedCountry = country.trim().toUpperCase();
202
+ const normalizedLanguage = language.trim().toLowerCase();
191
203
  return {
192
- country: normalizedCountry || "UNKNOWN",
193
- language: normalizedLanguage || "unknown",
194
- locale: normalizedLocale
204
+ country: normalizedCountry,
205
+ language: normalizedLanguage,
206
+ locale: `${normalizedCountry}-${normalizedLanguage}`
195
207
  };
196
208
  }
197
- function toCatalogLocale(localeParts) {
198
- const language = String(localeParts.language ?? "en").toLowerCase();
199
- const country = String(localeParts.country ?? "US").toUpperCase();
209
+ function toCatalogLocale({ country, language }) {
200
210
  return `${language}_${country}`;
201
211
  }
212
+ function toPathPrefix({ country, language }) {
213
+ return `/${country.toLowerCase()}/${language}`;
214
+ }
202
215
 
203
216
  // src/services/catalogRequest.ts
204
217
  function resolveApiGatewayPath(apiGatewayPath, apiName = STOREFRONT_SHOPPING_CONTEXT) {
@@ -241,12 +254,21 @@ async function resolveCatalogRequestConfig({
241
254
  getMarketConfig
242
255
  }) {
243
256
  const marketConfig = getMarketConfig ? await getMarketConfig({ locationLike, country }) ?? null : null;
244
- return {
245
- suggestionsUrl: buildCatalogSuggestionsUrl(
246
- resolveCatalogApiBaseUrl(marketConfig)
247
- ),
248
- storeId: resolveCatalogStoreId(marketConfig)
249
- };
257
+ const suggestionsUrl = buildCatalogSuggestionsUrl(
258
+ resolveCatalogApiBaseUrl(marketConfig)
259
+ );
260
+ if (!suggestionsUrl) {
261
+ throw new Error(
262
+ `no suggestions API for market ${country}: the market configuration has no gateway`
263
+ );
264
+ }
265
+ const storeId = resolveCatalogStoreId(marketConfig);
266
+ if (!storeId) {
267
+ throw new Error(
268
+ `no suggestions API for market ${country}: the market configuration has no store`
269
+ );
270
+ }
271
+ return { suggestionsUrl, storeId };
250
272
  }
251
273
  function resolveCustomerTypeHeaders(getCustomerType) {
252
274
  const customerType = getCustomerType?.()?.trim();
@@ -291,7 +313,7 @@ function getSessionStorage(providedStorage) {
291
313
  return null;
292
314
  }
293
315
  function getRecentHeaderSearchesStorageKey(localeParts) {
294
- const { country, language, locale } = normalizeLocaleParts(localeParts);
316
+ const { country, language, locale } = localeParts;
295
317
  return `${RECENT_SEARCHES_PREFIX}:${locale}:${country}:${language}`;
296
318
  }
297
319
  function getRecentHeaderSearches(localeParts, storage) {
@@ -354,13 +376,14 @@ function buildSearchResultsPath(query, localeParts) {
354
376
  /%2D/gi,
355
377
  "-"
356
378
  );
357
- const country = localeParts.country.toLowerCase();
358
- const language = localeParts.language.toLowerCase();
359
- return `/${country}/${language}${DEFAULT_SEARCH_PATH}/${encodedQuery}`;
379
+ return `${toPathPrefix(localeParts)}${DEFAULT_SEARCH_PATH}/${encodedQuery}`;
360
380
  }
381
+ var SCHEME = /^[a-z][a-z0-9+.-]*:/i;
361
382
  function normalizeOptionUrl(rawValue) {
362
383
  if (typeof rawValue !== "string") return "";
363
- return rawValue.trim();
384
+ const value = rawValue.trim();
385
+ if (!value || value.startsWith("/") || SCHEME.test(value)) return value;
386
+ return `/${value}`;
364
387
  }
365
388
  function safeDecodeURIComponent(value) {
366
389
  try {
@@ -390,16 +413,14 @@ function slugifyPathSegment(value) {
390
413
  function buildProductSlugPath(slugValue, localeParts) {
391
414
  const slug = slugifyPathSegment(slugValue);
392
415
  if (!slug) return "";
393
- const country = String(localeParts.country ?? "US").toLowerCase();
394
- const language = String(localeParts.language ?? "en").toLowerCase();
395
- return `/${country}/${language}/product/${slug}`;
416
+ return `${toPathPrefix(localeParts)}/product/${slug}`;
396
417
  }
397
418
  function isNavigableUrl(value) {
398
419
  if (!value) return false;
399
420
  return value.startsWith("/") || /^https?:\/\//i.test(value);
400
421
  }
401
422
  function stripLocalePrefix(path, localeParts) {
402
- const prefix = `/${localeParts.country.toLowerCase()}/${localeParts.language.toLowerCase()}`;
423
+ const prefix = toPathPrefix(localeParts);
403
424
  return path.startsWith(prefix) ? path.slice(prefix.length) || "/" : path;
404
425
  }
405
426
  function resolveOptionHref(option, localeParts) {
@@ -453,26 +474,20 @@ function getBrowserLocation(providedLocation) {
453
474
  return window.location;
454
475
  return null;
455
476
  }
456
- function resolveFetch(fetchImplOption) {
457
- if (fetchImplOption !== void 0) return fetchImplOption;
458
- return typeof fetch === "function" ? fetch.bind(globalThis) : null;
459
- }
460
- function createSearchService(options = {}) {
477
+ function createSearchService(options) {
461
478
  const {
462
479
  fetchImpl: fetchImplOption,
463
480
  location: providedLocation,
464
481
  storage: providedStorage,
465
482
  country,
466
483
  language,
467
- locale,
468
484
  getMarketConfig,
469
485
  getCustomerType,
470
486
  minimumSearchLength = MINIMUM_SEARCH_CHAR_LENGTH
471
487
  } = options;
472
- const fetchImpl = resolveFetch(fetchImplOption);
473
488
  const locationLike = getBrowserLocation(providedLocation);
474
489
  const storage = getSessionStorage(providedStorage);
475
- const localeParts = normalizeLocaleParts({ country, language, locale });
490
+ const localeParts = createLocaleParts({ country, language });
476
491
  let catalogRequestConfigPromise = null;
477
492
  function loadCatalogRequestConfig() {
478
493
  if (!catalogRequestConfigPromise) {
@@ -494,13 +509,12 @@ function createSearchService(options = {}) {
494
509
  const service = {
495
510
  async fetchSuggestions(searchTerm) {
496
511
  const trimmedSearchTerm = String(searchTerm ?? "").trim();
497
- if (!trimmedSearchTerm || trimmedSearchTerm.length < minimumSearchLength || !fetchImpl) {
512
+ if (!trimmedSearchTerm || trimmedSearchTerm.length < minimumSearchLength) {
498
513
  return emptySuggestions();
499
514
  }
500
515
  const { suggestionsUrl, storeId } = await loadCatalogRequestConfig();
501
- if (!suggestionsUrl || !storeId) return emptySuggestions();
502
516
  const responsePayload = await fetchJson(
503
- fetchImpl,
517
+ fetchImplOption ?? fetch.bind(globalThis),
504
518
  buildSuggestionsRequestUrl({
505
519
  suggestionsUrl,
506
520
  searchTerm: trimmedSearchTerm,
@@ -554,7 +568,7 @@ function createSearchService(options = {}) {
554
568
 
555
569
  // src/services/marketConfig.ts
556
570
  var fetchMarketConfig = async ({ country }) => {
557
- const countryCode = String(country ?? "").trim().toUpperCase() || "US";
571
+ const countryCode = country.trim().toUpperCase();
558
572
  const configuration = await getConfiguration([EQUINOX_MARKETS_MAP], {
559
573
  country: countryCode,
560
574
  ...getEnvironmentCode() ? { environment: getEnvironmentCode() } : {},
@@ -577,14 +591,25 @@ var SEARCH_GTM_USER_STATUS = {
577
591
  var GTM_FALLBACK_NA = "NA";
578
592
 
579
593
  // src/utils/navigationTarget.ts
594
+ var EXTERNAL_URL = /^https?:\/\//i;
595
+ var INTERNAL_PATH = /^\/(?![/\\])[^\t\n\r]*$/;
580
596
  function toNavigationTarget(path) {
581
- return /^https?:\/\//i.test(path) ? { type: "external", url: path } : { type: "internal", path };
597
+ if (EXTERNAL_URL.test(path)) return { type: "external", url: path };
598
+ if (INTERNAL_PATH.test(path)) return { type: "internal", path };
599
+ return void 0;
582
600
  }
583
601
 
584
602
  // src/utils/productEnvironment.ts
585
- var PRODUCT_ENVIRONMENTS = ["dev", "test", "prod"];
603
+ var PRODUCT_ENVIRONMENTS = {
604
+ dev: true,
605
+ test: true,
606
+ prod: true
607
+ };
608
+ function isProductEnvironment(code) {
609
+ return Object.keys(PRODUCT_ENVIRONMENTS).includes(code);
610
+ }
586
611
  function toProductEnvironment(environmentCode) {
587
- return environmentCode && PRODUCT_ENVIRONMENTS.includes(environmentCode) ? environmentCode : void 0;
612
+ return environmentCode !== void 0 && isProductEnvironment(environmentCode) ? environmentCode : void 0;
588
613
  }
589
614
  function getProductEnvironment() {
590
615
  try {
@@ -593,6 +618,47 @@ function getProductEnvironment() {
593
618
  return void 0;
594
619
  }
595
620
  }
621
+ var NOTHING = () => void 0;
622
+ function CommerceSearchUnavailable({
623
+ isOpen,
624
+ onClose,
625
+ country,
626
+ language,
627
+ logo
628
+ }) {
629
+ react.useEffect(() => {
630
+ console.error(
631
+ `[search] Search is unavailable: ${marketProblem(country, language)}`
632
+ );
633
+ }, [country, language]);
634
+ return /* @__PURE__ */ jsxRuntime.jsx(
635
+ reactSearchComponents.SearchPanel,
636
+ {
637
+ isOpen,
638
+ onClose,
639
+ copy: SEARCH_PANEL_COPY_EN,
640
+ logo,
641
+ input: { value: "", onChange: NOTHING, onSubmit: NOTHING },
642
+ suggestions: {
643
+ items: [],
644
+ loading: false,
645
+ showNoResults: false,
646
+ showDidYouMean: false,
647
+ hasMore: false,
648
+ query: "",
649
+ onSelect: NOTHING,
650
+ onRunAction: NOTHING,
651
+ onSeeMore: NOTHING
652
+ },
653
+ recentSearches: {
654
+ items: [],
655
+ onSelect: NOTHING,
656
+ onClear: NOTHING
657
+ },
658
+ agentPanel: /* @__PURE__ */ jsxRuntime.jsx("p", { role: "alert", children: "Search is unavailable right now." })
659
+ }
660
+ );
661
+ }
596
662
 
597
663
  // src/utils/nuiSearchGate.ts
598
664
  var NUI_SEARCH_FLAG = "enableNuiSearch";
@@ -1076,6 +1142,7 @@ function useSearchSuggestions({
1076
1142
  [enabled, service, resetSuggestionsState, settle]
1077
1143
  );
1078
1144
  react.useEffect(() => {
1145
+ latestRequestId.current += 1;
1079
1146
  if (!enabled) {
1080
1147
  resetSuggestionsState();
1081
1148
  return void 0;
@@ -1083,7 +1150,6 @@ function useSearchSuggestions({
1083
1150
  if (fetchDebounced.current) {
1084
1151
  clearTimeout(fetchDebounced.current);
1085
1152
  }
1086
- latestRequestId.current += 1;
1087
1153
  fetchDebounced.current = setTimeout(() => {
1088
1154
  void runFetch(query);
1089
1155
  }, DEBOUNCE_MS);
@@ -1365,11 +1431,11 @@ function useHandoff({
1365
1431
  onUnavailableRef.current = onUnavailable;
1366
1432
  });
1367
1433
  react.useEffect(() => {
1368
- let cancelled = false;
1434
+ let canceled = false;
1369
1435
  let timer;
1370
1436
  const startedAt = Date.now();
1371
1437
  const tryOpen = () => {
1372
- if (cancelled) return;
1438
+ if (canceled) return;
1373
1439
  const handle = getSalesAIHandle();
1374
1440
  if (handle) {
1375
1441
  handle.open({
@@ -1389,7 +1455,7 @@ function useHandoff({
1389
1455
  };
1390
1456
  tryOpen();
1391
1457
  return () => {
1392
- cancelled = true;
1458
+ canceled = true;
1393
1459
  if (timer !== void 0) clearTimeout(timer);
1394
1460
  };
1395
1461
  }, [query]);
@@ -1617,7 +1683,22 @@ function CommerceSearchView({
1617
1683
  );
1618
1684
  }
1619
1685
  var GUEST = "guest";
1620
- function CommerceSearch({
1686
+ function CommerceSearch(props) {
1687
+ if (!isValidMarket(props.country, props.language)) {
1688
+ return /* @__PURE__ */ jsxRuntime.jsx(
1689
+ CommerceSearchUnavailable,
1690
+ {
1691
+ isOpen: props.isOpen,
1692
+ onClose: props.onClose,
1693
+ country: props.country,
1694
+ language: props.language,
1695
+ logo: props.logo
1696
+ }
1697
+ );
1698
+ }
1699
+ return /* @__PURE__ */ jsxRuntime.jsx(ValidMarketSearch, { ...props });
1700
+ }
1701
+ function ValidMarketSearch({
1621
1702
  isOpen,
1622
1703
  onClose,
1623
1704
  navigate,
@@ -1645,7 +1726,6 @@ function CommerceSearch({
1645
1726
  () => createSearchService({
1646
1727
  country,
1647
1728
  language,
1648
- locale: `${country.toUpperCase()}-${language.toLowerCase()}`,
1649
1729
  getMarketConfig: fetchMarketConfig,
1650
1730
  minimumSearchLength,
1651
1731
  getCustomerType: () => customerTypeRef.current
@@ -1653,7 +1733,17 @@ function CommerceSearch({
1653
1733
  [country, language, minimumSearchLength]
1654
1734
  );
1655
1735
  const navigateToPath = react.useCallback(
1656
- (path) => navigate(toNavigationTarget(path)),
1736
+ (path) => {
1737
+ const target = toNavigationTarget(path);
1738
+ if (target) {
1739
+ navigate(target);
1740
+ } else {
1741
+ console.warn(
1742
+ "[search] Not navigating to a path the SDK does not recognize:",
1743
+ path
1744
+ );
1745
+ }
1746
+ },
1657
1747
  [navigate]
1658
1748
  );
1659
1749
  const { isAuthenticated, accountType, userId } = customer;