@nuskin/react-search-sdk 1.0.1-code-cleanup.2 → 1.1.0-pa-1255.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/dist/index.d.mts CHANGED
@@ -46,17 +46,24 @@ interface CommerceSearchProps {
46
46
  country: string;
47
47
  /** ISO 639-1 language, for example `en`. */
48
48
  language: string;
49
+ /**
50
+ * The client id the configuration service knows the host by. Without one the Configuration
51
+ * SDK uses its own (the run config's), and the search's plain suggestions fail where there is
52
+ * none, so a host that cannot rely on the run config should pass it.
53
+ */
54
+ clientId?: string;
49
55
  /** Shown at the top of the panel, for example the brand logo. */
50
56
  logo?: ReactNode;
51
57
  }
52
58
  /**
53
59
  * The commerce website's single search entry point: typeahead and recent searches over the
54
- * product catalog today, with the NUi agent taking over in the same panel when a signed-in
55
- * shopper (guests too, where the configuration allows it) searches for something with no products. What it can answer is not limited to
56
- * products — it is the commerce site's search, not a product-only search box.
60
+ * product catalog today, with the NUi agent taking over in the same panel when a shopper the
61
+ * configuration enables it for searches for something with no products. What it can answer is not
62
+ * limited to products — it is the commerce site's search, not a product-only search box.
57
63
  *
58
- * The package works out the rest itself: the market's catalog lookup, the minimum search length,
59
- * which NUi app to use, the panel's text and the analytics fields.
64
+ * The package works out the rest itself: the market's catalog lookup, the minimum search length
65
+ * and the number of suggestions, which NUi app to use and for whom, the panel's text and the
66
+ * analytics fields.
60
67
  */
61
68
  declare function CommerceSearch(props: Readonly<CommerceSearchProps>): ReactElement | null;
62
69
 
package/dist/index.d.ts CHANGED
@@ -46,17 +46,24 @@ interface CommerceSearchProps {
46
46
  country: string;
47
47
  /** ISO 639-1 language, for example `en`. */
48
48
  language: string;
49
+ /**
50
+ * The client id the configuration service knows the host by. Without one the Configuration
51
+ * SDK uses its own (the run config's), and the search's plain suggestions fail where there is
52
+ * none, so a host that cannot rely on the run config should pass it.
53
+ */
54
+ clientId?: string;
49
55
  /** Shown at the top of the panel, for example the brand logo. */
50
56
  logo?: ReactNode;
51
57
  }
52
58
  /**
53
59
  * The commerce website's single search entry point: typeahead and recent searches over the
54
- * product catalog today, with the NUi agent taking over in the same panel when a signed-in
55
- * shopper (guests too, where the configuration allows it) searches for something with no products. What it can answer is not limited to
56
- * products — it is the commerce site's search, not a product-only search box.
60
+ * product catalog today, with the NUi agent taking over in the same panel when a shopper the
61
+ * configuration enables it for searches for something with no products. What it can answer is not
62
+ * limited to products — it is the commerce site's search, not a product-only search box.
57
63
  *
58
- * The package works out the rest itself: the market's catalog lookup, the minimum search length,
59
- * which NUi app to use, the panel's text and the analytics fields.
64
+ * The package works out the rest itself: the market's catalog lookup, the minimum search length
65
+ * and the number of suggestions, which NUi app to use and for whom, the panel's text and the
66
+ * analytics fields.
60
67
  */
61
68
  declare function CommerceSearch(props: Readonly<CommerceSearchProps>): ReactElement | null;
62
69
 
package/dist/index.js CHANGED
@@ -23,36 +23,53 @@ var CATALOG_SUGGESTIONS_PATH = "/storefront/catalogs/search/suggestions";
23
23
  var RECENT_SEARCHES_PREFIX = "nuskin:header:recentSearches";
24
24
  var RECENT_SEARCHES_DEFAULT_LIMIT = 10;
25
25
  var DEFAULT_SUGGESTION_PAGE = 1;
26
- var DEFAULT_SUGGESTION_SIZE = 6;
27
26
  var DEFAULT_SEARCH_PATH = "/search";
28
27
  var DEBOUNCE_MS = 220;
29
28
  var MINIMUM_SEARCH_CHAR_LENGTH = 2;
30
- var MAX_VISIBLE_SUGGESTIONS = 6;
29
+ var DEFAULT_MAX_SUGGESTIONS = 6;
30
+ var MAX_SUGGESTIONS_CEILING = 20;
31
31
  var EQUINOX_MARKETS_MAP = "Equinox_Markets";
32
- var DEFAULT_EQUINOX_CLIENT_ID = "63056848d7532f9923f3d08cb48c423b";
32
+ var COMMERCE_SEARCH_MAP = "Commerce_Search";
33
33
 
34
34
  // src/config/commerceSearchConfig.ts
35
35
  var COMMERCE_SEARCH_CONFIG_DEFAULTS = Object.freeze({
36
- minimumSearchLength: MINIMUM_SEARCH_CHAR_LENGTH
36
+ minimumSearchLength: MINIMUM_SEARCH_CHAR_LENGTH,
37
+ maxSuggestions: DEFAULT_MAX_SUGGESTIONS,
38
+ agentEnabledFor: Object.freeze({
39
+ guest: false,
40
+ brandAffiliate: false,
41
+ preferredCustomer: false,
42
+ retailCustomer: false
43
+ })
37
44
  });
38
- var TEST_ENVIRONMENT_VALUES = {
39
- agent: {
40
- appKey: "pk_commerce_product_advisor_624aea4305b09240e8c8f41d0f6527b2",
41
- apiBaseUrl: "https://nui.tst.nuskin.ai/"
42
- },
43
- // Open to visitors who are not signed in, so the handoff can be tried where signing in is not
44
- // possible (for example local development). Sign-in stays required everywhere else.
45
- allowGuestAgent: true
45
+ var FIELDS = {
46
+ minimumLength: "commerceSearchMinimumLength",
47
+ maxSuggestions: "commerceSearchMaxSuggestions",
48
+ agentAppKey: "commerceSearchAgentAppKey",
49
+ agentApiBaseUrl: "commerceSearchAgentApiBaseUrl",
50
+ enabledGuest: "commerceSearchAgentEnabledGuest",
51
+ enabledBrandAffiliate: "commerceSearchAgentEnabledBrandAffiliate",
52
+ enabledPreferredCustomer: "commerceSearchAgentEnabledPreferredCustomer",
53
+ enabledRetailCustomer: "commerceSearchAgentEnabledRetailCustomer"
46
54
  };
47
55
  function nonEmptyString(value) {
48
56
  return typeof value === "string" ? value.trim() : "";
49
57
  }
58
+ function wholeNumber(value) {
59
+ const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
60
+ return typeof number === "number" && Number.isInteger(number) ? number : void 0;
61
+ }
62
+ function positiveWholeNumber(value) {
63
+ const number = wholeNumber(value);
64
+ return number !== void 0 && number >= 1 ? number : void 0;
65
+ }
50
66
  function normalizeCommerceSearchConfig(raw, { language } = {}) {
51
- const minimumSearchLength = Number(raw?.minimumSearchLength);
52
- const appKey = nonEmptyString(raw?.agent?.appKey);
53
- const apiBaseUrl = nonEmptyString(raw?.agent?.apiBaseUrl);
67
+ const appKey = nonEmptyString(raw?.[FIELDS.agentAppKey]);
68
+ const apiBaseUrl = nonEmptyString(raw?.[FIELDS.agentApiBaseUrl]);
69
+ const maxSuggestions = positiveWholeNumber(raw?.[FIELDS.maxSuggestions]);
54
70
  return {
55
- minimumSearchLength: Number.isInteger(minimumSearchLength) && minimumSearchLength >= 1 ? minimumSearchLength : COMMERCE_SEARCH_CONFIG_DEFAULTS.minimumSearchLength,
71
+ minimumSearchLength: positiveWholeNumber(raw?.[FIELDS.minimumLength]) ?? COMMERCE_SEARCH_CONFIG_DEFAULTS.minimumSearchLength,
72
+ maxSuggestions: maxSuggestions === void 0 ? COMMERCE_SEARCH_CONFIG_DEFAULTS.maxSuggestions : Math.min(maxSuggestions, MAX_SUGGESTIONS_CEILING),
56
73
  ...appKey && apiBaseUrl ? {
57
74
  agent: {
58
75
  appKey,
@@ -60,13 +77,36 @@ function normalizeCommerceSearchConfig(raw, { language } = {}) {
60
77
  ...language ? { language } : {}
61
78
  }
62
79
  } : {},
63
- ...raw?.allowGuestAgent === true ? { allowGuestAgent: true } : {}
80
+ agentEnabledFor: {
81
+ guest: raw?.[FIELDS.enabledGuest] === true,
82
+ brandAffiliate: raw?.[FIELDS.enabledBrandAffiliate] === true,
83
+ preferredCustomer: raw?.[FIELDS.enabledPreferredCustomer] === true,
84
+ retailCustomer: raw?.[FIELDS.enabledRetailCustomer] === true
85
+ }
64
86
  };
65
87
  }
66
88
  async function fetchCommerceSearchConfig({
67
- language
68
- } = {}) {
69
- const raw = getEnvironmentCode() === "test" ? TEST_ENVIRONMENT_VALUES : {};
89
+ country,
90
+ language,
91
+ clientId
92
+ }) {
93
+ const configuration = await getConfiguration([COMMERCE_SEARCH_MAP], {
94
+ country: country.trim().toUpperCase(),
95
+ ...clientId ? { clientId } : {}
96
+ });
97
+ const raw = configuration?.[COMMERCE_SEARCH_MAP];
98
+ if (!raw || Object.keys(raw).length === 0) {
99
+ throw new Error(
100
+ `the configuration service returned no ${COMMERCE_SEARCH_MAP} for market ${country}`
101
+ );
102
+ }
103
+ const hasKey = nonEmptyString(raw[FIELDS.agentAppKey]) !== "";
104
+ const hasUrl = nonEmptyString(raw[FIELDS.agentApiBaseUrl]) !== "";
105
+ if (hasKey !== hasUrl) {
106
+ console.warn(
107
+ `[search] The agent is half configured: ${FIELDS.agentAppKey} and ${FIELDS.agentApiBaseUrl} must both be set, or both empty. Using plain search.`
108
+ );
109
+ }
70
110
  return normalizeCommerceSearchConfig(raw, language ? { language } : {});
71
111
  }
72
112
 
@@ -74,30 +114,39 @@ async function fetchCommerceSearchConfig({
74
114
  function useCommerceSearchConfig({
75
115
  country,
76
116
  language,
77
- enabled
117
+ clientId,
118
+ isOpen
78
119
  }) {
79
- const [config, setConfig] = react.useState(
80
- COMMERCE_SEARCH_CONFIG_DEFAULTS
81
- );
120
+ const market = `${country}|${language}|${clientId ?? ""}`;
121
+ const [loaded, setLoaded] = react.useState(null);
122
+ const currentMarket = react.useRef(market);
82
123
  react.useEffect(() => {
83
- if (!enabled) return void 0;
84
- let isStale = false;
124
+ currentMarket.current = market;
125
+ }, [market]);
126
+ const reading = react.useRef(null);
127
+ react.useEffect(() => {
128
+ if (!isOpen || loaded?.market === market || reading.current === market) {
129
+ return;
130
+ }
131
+ reading.current = market;
85
132
  fetchCommerceSearchConfig({
86
- ...country ? { country } : {},
87
- ...language ? { language } : {}
88
- }).then((next) => {
89
- if (!isStale) setConfig(next);
133
+ country,
134
+ language,
135
+ ...clientId ? { clientId } : {}
136
+ }).then((config) => {
137
+ if (currentMarket.current === market) {
138
+ setLoaded({ market, config });
139
+ }
90
140
  }).catch((error) => {
91
141
  console.warn(
92
142
  "[search] Could not load the product search configuration:",
93
143
  error
94
144
  );
145
+ }).finally(() => {
146
+ if (reading.current === market) reading.current = null;
95
147
  });
96
- return () => {
97
- isStale = true;
98
- };
99
- }, [enabled, country, language]);
100
- return config;
148
+ }, [isOpen, market, loaded, country, language, clientId]);
149
+ return loaded?.market === market ? loaded.config : COMMERCE_SEARCH_CONFIG_DEFAULTS;
101
150
  }
102
151
 
103
152
  // src/copy/searchPanelCopy.ts
@@ -236,13 +285,14 @@ function buildCatalogSuggestionsUrl(apiBaseUrl) {
236
285
  function buildSuggestionsRequestUrl({
237
286
  suggestionsUrl,
238
287
  searchTerm,
288
+ size,
239
289
  storeId,
240
290
  localeParts
241
291
  }) {
242
292
  const queryParams = new URLSearchParams({
243
293
  searchTerm,
244
294
  page: String(DEFAULT_SUGGESTION_PAGE),
245
- size: String(DEFAULT_SUGGESTION_SIZE),
295
+ size: String(size),
246
296
  locale: toCatalogLocale(localeParts),
247
297
  storeId
248
298
  });
@@ -483,7 +533,8 @@ function createSearchService(options) {
483
533
  language,
484
534
  getMarketConfig,
485
535
  getCustomerType,
486
- minimumSearchLength = MINIMUM_SEARCH_CHAR_LENGTH
536
+ minimumSearchLength = MINIMUM_SEARCH_CHAR_LENGTH,
537
+ maxSuggestions = DEFAULT_MAX_SUGGESTIONS
487
538
  } = options;
488
539
  const locationLike = getBrowserLocation(providedLocation);
489
540
  const storage = getSessionStorage(providedStorage);
@@ -518,6 +569,7 @@ function createSearchService(options) {
518
569
  buildSuggestionsRequestUrl({
519
570
  suggestionsUrl,
520
571
  searchTerm: trimmedSearchTerm,
572
+ size: maxSuggestions,
521
573
  storeId,
522
574
  localeParts
523
575
  }),
@@ -567,12 +619,15 @@ function createSearchService(options) {
567
619
  }
568
620
 
569
621
  // src/services/marketConfig.ts
570
- var fetchMarketConfig = async ({ country }) => {
622
+ var fetchMarketConfig = async ({
623
+ country,
624
+ clientId
625
+ }) => {
571
626
  const countryCode = country.trim().toUpperCase();
572
627
  const configuration = await getConfiguration([EQUINOX_MARKETS_MAP], {
573
628
  country: countryCode,
574
629
  ...getEnvironmentCode() ? { environment: getEnvironmentCode() } : {},
575
- clientId: DEFAULT_EQUINOX_CLIENT_ID,
630
+ ...clientId ? { clientId } : {},
576
631
  shoppingContext: STOREFRONT_SHOPPING_CONTEXT
577
632
  });
578
633
  return configuration?.[EQUINOX_MARKETS_MAP] ?? null;
@@ -690,29 +745,23 @@ function resolveNuiSearchFlag(env = {}) {
690
745
  return parseFlagValue(new URLSearchParams(search).get(NUI_SEARCH_FLAG)) ?? readCookieFlag(cookie);
691
746
  }
692
747
  function isNuiSearchEnabled({
693
- isAuthenticated,
694
748
  flag,
695
- allowGuests = false
749
+ enabledForCustomer
696
750
  }) {
697
- return flag === true && (isAuthenticated === true || allowGuests);
751
+ return flag === true && enabledForCustomer;
698
752
  }
699
753
 
700
754
  // src/hooks/useNuiAgentGate.ts
701
755
  function useNuiAgentGate({
702
756
  hasAgent,
703
757
  isOpen,
704
- isAuthenticated,
705
- allowGuestAgent
758
+ enabledForCustomer
706
759
  }) {
707
760
  const [flag, setFlag] = react.useState(void 0);
708
761
  react.useEffect(() => {
709
762
  if (isOpen) setFlag(resolveNuiSearchFlag());
710
763
  }, [isOpen]);
711
- return hasAgent && isNuiSearchEnabled({
712
- isAuthenticated,
713
- flag,
714
- allowGuests: allowGuestAgent
715
- });
764
+ return hasAgent && isNuiSearchEnabled({ flag, enabledForCustomer });
716
765
  }
717
766
  function uniqueRecentSearches(queries) {
718
767
  const seen = /* @__PURE__ */ new Set();
@@ -1046,7 +1095,7 @@ var FETCH_FAILED = {
1046
1095
  emptyFetched: true,
1047
1096
  failed: true
1048
1097
  };
1049
- function toSettledResults(response) {
1098
+ function toSettledResults(response, maxSuggestions) {
1050
1099
  const { suggestions: allSuggestions, didYouMean } = normalizeSuggestionsResponse(response);
1051
1100
  if (didYouMean.length > 0) {
1052
1101
  return {
@@ -1061,18 +1110,19 @@ function toSettledResults(response) {
1061
1110
  resolveTotalResultsCount2(response, allSuggestions.length),
1062
1111
  allSuggestions.length
1063
1112
  );
1064
- const suggestions = allSuggestions.slice(0, MAX_VISIBLE_SUGGESTIONS);
1113
+ const suggestions = allSuggestions.slice(0, maxSuggestions);
1065
1114
  return {
1066
1115
  suggestions,
1067
1116
  didYouMean,
1068
- hasMoreResults: total > MAX_VISIBLE_SUGGESTIONS,
1117
+ hasMoreResults: total > maxSuggestions,
1069
1118
  emptyFetched: suggestions.length === 0,
1070
1119
  failed: false
1071
1120
  };
1072
1121
  }
1073
1122
  function useSearchSuggestions({
1074
1123
  service,
1075
- enabled = true
1124
+ enabled = true,
1125
+ maxSuggestions = DEFAULT_MAX_SUGGESTIONS
1076
1126
  }) {
1077
1127
  const [query, setQuery] = react.useState("");
1078
1128
  const [suggestions, setSuggestions] = react.useState([]);
@@ -1129,7 +1179,8 @@ function useSearchSuggestions({
1129
1179
  let results;
1130
1180
  try {
1131
1181
  results = toSettledResults(
1132
- await service.fetchSuggestions(trimmedQuery)
1182
+ await service.fetchSuggestions(trimmedQuery),
1183
+ maxSuggestions
1133
1184
  );
1134
1185
  } catch (error) {
1135
1186
  console.warn("[search] Could not load suggestions:", error);
@@ -1139,7 +1190,7 @@ function useSearchSuggestions({
1139
1190
  settle(results, trimmedQuery);
1140
1191
  setLoading(false);
1141
1192
  },
1142
- [enabled, service, resetSuggestionsState, settle]
1193
+ [enabled, maxSuggestions, service, resetSuggestionsState, settle]
1143
1194
  );
1144
1195
  react.useEffect(() => {
1145
1196
  latestRequestId.current += 1;
@@ -1496,12 +1547,12 @@ function CommerceSearchView({
1496
1547
  onClose,
1497
1548
  service,
1498
1549
  navigate,
1499
- isAuthenticated,
1500
1550
  agent,
1501
- allowGuestAgent = false,
1551
+ agentEnabledForCustomer,
1502
1552
  agentProducts,
1503
1553
  logo,
1504
1554
  minimumSearchLength = MINIMUM_SEARCH_CHAR_LENGTH,
1555
+ maxSuggestions = DEFAULT_MAX_SUGGESTIONS,
1505
1556
  gtmContext
1506
1557
  }) {
1507
1558
  const [handoff, setHandoff] = react.useState(null);
@@ -1509,6 +1560,7 @@ function CommerceSearchView({
1509
1560
  const [pendingTerm, setPendingTerm] = react.useState(null);
1510
1561
  const search = useSearchSuggestions({
1511
1562
  service,
1563
+ maxSuggestions,
1512
1564
  enabled: isOpen && agentQuery === null
1513
1565
  });
1514
1566
  const {
@@ -1524,8 +1576,7 @@ function CommerceSearchView({
1524
1576
  const agentEnabled = useNuiAgentGate({
1525
1577
  hasAgent: agent !== void 0,
1526
1578
  isOpen,
1527
- isAuthenticated,
1528
- allowGuestAgent
1579
+ enabledForCustomer: agentEnabledForCustomer
1529
1580
  });
1530
1581
  const { recentSearches, clearRecentSearches } = useRecentSearches({
1531
1582
  service,
@@ -1683,6 +1734,15 @@ function CommerceSearchView({
1683
1734
  );
1684
1735
  }
1685
1736
  var GUEST = "guest";
1737
+ var AGENT_SWITCH_FOR = {
1738
+ guest: "guest",
1739
+ "Brand Affiliate": "brandAffiliate",
1740
+ "Retail Customer": "retailCustomer",
1741
+ "Preferred Customer/Member": "preferredCustomer"
1742
+ };
1743
+ var AGENT_SWITCHES = new Map(
1744
+ Object.entries(AGENT_SWITCH_FOR)
1745
+ );
1686
1746
  function CommerceSearch(props) {
1687
1747
  if (!isValidMarket(props.country, props.language)) {
1688
1748
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -1705,32 +1765,45 @@ function ValidMarketSearch({
1705
1765
  customer,
1706
1766
  country,
1707
1767
  language,
1768
+ clientId,
1708
1769
  logo
1709
1770
  }) {
1710
- const [hasOpened, setHasOpened] = react.useState(false);
1711
- react.useEffect(() => {
1712
- if (isOpen) setHasOpened(true);
1713
- }, [isOpen]);
1714
1771
  const copy = useSearchPanelCopy({ language });
1715
- const { minimumSearchLength, agent, allowGuestAgent } = useCommerceSearchConfig({
1772
+ const { minimumSearchLength, maxSuggestions, agent, agentEnabledFor } = useCommerceSearchConfig({
1716
1773
  country,
1717
1774
  language,
1718
- enabled: hasOpened
1775
+ ...clientId ? { clientId } : {},
1776
+ isOpen
1719
1777
  });
1720
1778
  const customerType = customer.isAuthenticated ? customer.accountType ?? GUEST : GUEST;
1721
1779
  const customerTypeRef = react.useRef(customerType);
1722
1780
  react.useEffect(() => {
1723
1781
  customerTypeRef.current = customerType;
1724
1782
  }, [customerType]);
1783
+ const agentEnabledForCustomer = agentEnabledFor[AGENT_SWITCHES.get(customerType) ?? "guest"];
1784
+ const getMarketConfig = react.useCallback(
1785
+ (context) => fetchMarketConfig({
1786
+ ...context,
1787
+ ...clientId ? { clientId } : {}
1788
+ }),
1789
+ [clientId]
1790
+ );
1725
1791
  const service = react.useMemo(
1726
1792
  () => createSearchService({
1727
1793
  country,
1728
1794
  language,
1729
- getMarketConfig: fetchMarketConfig,
1795
+ getMarketConfig,
1730
1796
  minimumSearchLength,
1797
+ maxSuggestions,
1731
1798
  getCustomerType: () => customerTypeRef.current
1732
1799
  }),
1733
- [country, language, minimumSearchLength]
1800
+ [
1801
+ country,
1802
+ language,
1803
+ getMarketConfig,
1804
+ minimumSearchLength,
1805
+ maxSuggestions
1806
+ ]
1734
1807
  );
1735
1808
  const navigateToPath = react.useCallback(
1736
1809
  (path) => {
@@ -1773,13 +1846,13 @@ function ValidMarketSearch({
1773
1846
  onClose,
1774
1847
  service,
1775
1848
  navigate: navigateToPath,
1776
- isAuthenticated,
1777
1849
  ...agent ? { agent } : {},
1778
- ...allowGuestAgent ? { allowGuestAgent } : {},
1850
+ agentEnabledForCustomer,
1779
1851
  ...agentProducts ? { agentProducts } : {},
1780
1852
  copy,
1781
1853
  logo,
1782
1854
  minimumSearchLength,
1855
+ maxSuggestions,
1783
1856
  gtmContext
1784
1857
  }
1785
1858
  );