@nyris/nyris-webapp 0.3.96 → 0.3.98

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.
Files changed (48) hide show
  1. package/build/asset-manifest.json +6 -6
  2. package/build/index.html +1 -1
  3. package/build/js/settings.example.js +7 -1
  4. package/build/static/css/main.81216c5d.css +4 -0
  5. package/build/static/css/main.81216c5d.css.map +1 -0
  6. package/build/static/js/main.93fdbd6e.js +3 -0
  7. package/build/static/js/{main.fd12cce3.js.map → main.93fdbd6e.js.map} +1 -1
  8. package/package.json +3 -3
  9. package/public/js/settings.example.js +7 -1
  10. package/src/components/AuthApiKeySync.tsx +34 -0
  11. package/src/components/Cart.tsx +200 -85
  12. package/src/components/ChatAssistant/useChatAssistantLogic.ts +3 -2
  13. package/src/components/Header.tsx +1 -1
  14. package/src/components/ImageUpload.tsx +2 -2
  15. package/src/components/Inquiry/InquiryModal.tsx +2 -2
  16. package/src/components/LogoutModal.tsx +2 -0
  17. package/src/components/PostFilter/PostFilter.tsx +36 -3
  18. package/src/components/PostFilter/PostFilterComponent.tsx +8 -22
  19. package/src/components/PostFilter/PostFilterFindApi.tsx +41 -3
  20. package/src/components/PreFilter/PreFilter.tsx +30 -2
  21. package/src/components/Product/Product.tsx +2 -2
  22. package/src/components/Product/ProductDetailView.tsx +2 -2
  23. package/src/components/Product/ProductList.tsx +2 -0
  24. package/src/components/ScopeMissing.tsx +50 -0
  25. package/src/components/SidePanel.tsx +10 -0
  26. package/src/components/SmartFilter.tsx +59 -69
  27. package/src/components/TextSearch.tsx +6 -4
  28. package/src/hooks/useImageSearch.ts +44 -9
  29. package/src/hooks/useResultEvaluator.ts +8 -8
  30. package/src/index.tsx +4 -0
  31. package/src/layouts/AppLayout.tsx +77 -10
  32. package/src/pages/Logout.tsx +2 -0
  33. package/src/pages/Result.tsx +14 -7
  34. package/src/providers/AuthProvider.tsx +5 -0
  35. package/src/routes/ProctedRoute.tsx +52 -9
  36. package/src/services/Feedback.ts +2 -1
  37. package/src/services/filter.ts +16 -2
  38. package/src/services/visualSearch.ts +15 -5
  39. package/src/services/vizo.ts +13 -2
  40. package/src/stores/scope/scopeStore.ts +35 -0
  41. package/src/styles/Cart.scss +22 -0
  42. package/src/types.ts +6 -1
  43. package/src/utils/derivePostFilterFacets.ts +68 -0
  44. package/src/utils/nyrisApiKey.ts +115 -0
  45. package/build/static/css/main.5fff4ca0.css +0 -4
  46. package/build/static/css/main.5fff4ca0.css.map +0 -1
  47. package/build/static/js/main.fd12cce3.js +0 -3
  48. /package/build/static/js/{main.fd12cce3.js.LICENSE.txt → main.93fdbd6e.js.LICENSE.txt} +0 -0
@@ -22,6 +22,12 @@ import { getFilters } from 'services/filter';
22
22
  import { useUploadSearch } from 'hooks/useUploadSearch';
23
23
  import { ToastHelper } from 'helpers/ToastHelper';
24
24
  import { useTranslation } from 'react-i18next';
25
+ import {
26
+ clearAuthApiKey,
27
+ isNyrisApiKeyReady,
28
+ useAuthApiKeyStore,
29
+ } from 'utils/nyrisApiKey';
30
+ import useScopeStore from 'stores/scope/scopeStore';
25
31
 
26
32
  i18n.use(initReactI18next).init({
27
33
  resources: translations,
@@ -33,7 +39,7 @@ i18n.use(initReactI18next).init({
33
39
  });
34
40
 
35
41
  function AppLayout(): JSX.Element {
36
- const { isAuthenticated } = useAuth0();
42
+ const { isAuthenticated, logout } = useAuth0();
37
43
  const { t } = useTranslation();
38
44
  const { auth0, alogoliaFilterField, showPoweredByNyris } = window.settings;
39
45
  const { uploadFile } = useUploadSearch();
@@ -49,6 +55,7 @@ function AppLayout(): JSX.Element {
49
55
 
50
56
  const algoliaFilter = useRequestStore(state => state.algoliaFilter);
51
57
  const metaFilter = useRequestStore(state => state.metaFilter);
58
+ const scopeItemKeys = useScopeStore(state => state.filterAttributes);
52
59
 
53
60
  const setAlgoliaProducts = useResultStore(state => state.setAlgoliaProducts);
54
61
  const setIsAlgoliaLoading = useUiStore(state => state.setIsAlgoliaLoading);
@@ -88,7 +95,7 @@ function AppLayout(): JSX.Element {
88
95
  useEffect(() => {
89
96
  setAlgoliaProducts(items);
90
97
  // eslint-disable-next-line react-hooks/exhaustive-deps
91
- }, [items]);
98
+ }, [items, scopeItemKeys]);
92
99
 
93
100
  useEffect(() => {
94
101
  if (status === 'loading' || status === 'stalled') {
@@ -112,13 +119,13 @@ function AppLayout(): JSX.Element {
112
119
 
113
120
  const showPreFilter = useMemo(() => {
114
121
  if (settings.shouldUseUserMetadata && user) {
115
- if (user['/user_metadata'].value) {
122
+ if (user['/user_metadata']?.value) {
116
123
  setMetaFilter(user['/user_metadata'].value);
117
124
  }
118
125
  }
119
126
 
120
127
  if (settings.shouldUseUserMetadata && user) {
121
- if (settings.preFilterOption && !user['/user_metadata'].value) {
128
+ if (settings.preFilterOption && !user['/user_metadata']?.value) {
122
129
  return true;
123
130
  }
124
131
  return false;
@@ -132,7 +139,15 @@ function AppLayout(): JSX.Element {
132
139
  user,
133
140
  ]);
134
141
 
142
+ const authApiKeyVersion = useAuthApiKeyStore(state => state.version);
143
+ const isMissingAuthApiKey = useAuthApiKeyStore(state => state.isMissingClaim);
144
+ const isApiKeyReady = isNyrisApiKeyReady();
145
+
135
146
  const getPreFilters = useCallback(async () => {
147
+ if (!isNyrisApiKeyReady()) {
148
+ return;
149
+ }
150
+
136
151
  setPreFilterLoading(true);
137
152
  try {
138
153
  const res = await getFilters(1000, settings);
@@ -145,10 +160,10 @@ function AppLayout(): JSX.Element {
145
160
  }, [settings, setPreFilterList, setPreFilterLoading]);
146
161
 
147
162
  useEffect(() => {
148
- if (showPreFilter) {
163
+ if (showPreFilter && !settings.scopeFilter && isApiKeyReady) {
149
164
  getPreFilters();
150
165
  }
151
- }, [getPreFilters, showPreFilter]);
166
+ }, [getPreFilters, showPreFilter, settings.scopeFilter, isApiKeyReady, authApiKeyVersion]);
152
167
 
153
168
  const getImageFromClipboard = useCallback(
154
169
  (event: ClipboardEvent): File | null => {
@@ -206,17 +221,69 @@ function AppLayout(): JSX.Element {
206
221
  return;
207
222
  }
208
223
 
224
+ // Auth0: only listen once authApiKey is resolved (not while waiting / missing claim).
225
+ if (auth0.enabled && !isApiKeyReady) {
226
+ return;
227
+ }
228
+
209
229
  document.addEventListener('paste', handleClipboardPaste);
210
230
 
211
231
  return () => {
212
232
  document.removeEventListener('paste', handleClipboardPaste);
213
233
  };
214
- }, [handleClipboardPaste, showLayout]);
234
+ }, [auth0.enabled, handleClipboardPaste, isApiKeyReady, showLayout]);
235
+
236
+ const applyScopeFilter = (filters: string | undefined) => {
237
+ const scopeFilterClause =
238
+ window.settings.scopeFilter && scopeItemKeys.length
239
+ ? `(${scopeItemKeys[0].values.map(key => `${window.settings.alogoliaFilterField}:'${key}'`).join(' OR ')})`
240
+ : '';
241
+
242
+ if (!window.settings.scopeFilter || !window.settings.auth0?.enabled) {
243
+ return filters;
244
+ }
245
+
246
+ if (!scopeFilterClause) {
247
+ return `sku:NOT_FOUND`;
248
+ }
249
+
250
+ return filters
251
+ ? `${scopeFilterClause} AND (${filters})`
252
+ : scopeFilterClause;
253
+ };
215
254
 
216
255
  if (!showLayout) {
217
256
  return <Outlet />;
218
257
  }
219
258
 
259
+ // Auth0 mode: wait for AuthApiKeySync before rendering search UI / prefilters.
260
+ if (auth0.enabled && !isApiKeyReady && !isMissingAuthApiKey) {
261
+ return <div />;
262
+ }
263
+
264
+ if (isMissingAuthApiKey) {
265
+ return (
266
+ <div className="full-height flex flex-col items-center justify-center bg-[#F3F4F8] p-6 text-center">
267
+ <p className="mt-2 max-w-md text-sm text-gray-600">
268
+ Search is currently unavailable for your account. Please contact your
269
+ administrator.
270
+ </p>
271
+ <button
272
+ type="button"
273
+ className="mt-6 bg-[#2B2C46] px-6 py-3 text-sm text-white"
274
+ onClick={() => {
275
+ clearAuthApiKey();
276
+ logout({
277
+ logoutParams: { returnTo: window.location.origin },
278
+ });
279
+ }}
280
+ >
281
+ {t('Log out')}
282
+ </button>
283
+ </div>
284
+ );
285
+ }
286
+
220
287
  return (
221
288
  <div className="full-height flex flex-col bg-[#F3F4F8]">
222
289
  <div>
@@ -226,7 +293,7 @@ function AppLayout(): JSX.Element {
226
293
  {window.settings?.algolia.enabled && (
227
294
  <Configure
228
295
  query={query}
229
- filters={
296
+ filters={applyScopeFilter(
230
297
  specifications && !requestImages.length
231
298
  ? `${alogoliaFilterField}:'${specifications.prefilter_value}'`
232
299
  : !query && !algoliaFilter.includes('score=1')
@@ -237,8 +304,8 @@ function AppLayout(): JSX.Element {
237
304
  algoliaFilter ? 'AND ' : ''
238
305
  }${alogoliaFilterField}:'${metaFilter}'`
239
306
  : ''
240
- }`
241
- }
307
+ }`,
308
+ )}
242
309
  // facets={['brand', 'keyword_0']}
243
310
  hitsPerPage={20}
244
311
  />
@@ -1,5 +1,6 @@
1
1
  import { useEffect } from 'react';
2
2
  import { useAuth0 } from '@auth0/auth0-react';
3
+ import { clearAuthApiKey } from 'utils/nyrisApiKey';
3
4
 
4
5
  const Logout = () => {
5
6
  const { user, isAuthenticated, isLoading, loginWithRedirect, logout } =
@@ -10,6 +11,7 @@ const Logout = () => {
10
11
  if (!isLoading && !user && !isAuthenticated && auth0.enabled) {
11
12
  loginWithRedirect();
12
13
  } else if (isAuthenticated) {
14
+ clearAuthApiKey();
13
15
  logout({ logoutParams: { returnTo: window.location.origin } });
14
16
  } else if (!auth0.enabled) {
15
17
  window.location.href = '/';
@@ -174,16 +174,21 @@ function Results() {
174
174
  ]);
175
175
 
176
176
  const disablePostFilter = useMemo(() => {
177
- if (!settings.algolia.enabled) {
177
+ // Find-API post-filters do not depend on Algolia hits.
178
+ if (!settings.algolia.enabled || settings.postFilterOption) {
178
179
  return false;
179
180
  }
180
181
 
181
- return settings.postFilterOption && productsFromAlgolia.length > 0
182
- ? false
183
- : true;
182
+ return productsFromAlgolia.length > 0 ? false : true;
184
183
  }, [settings, productsFromAlgolia]);
185
184
 
186
- const isPostFilterApplied = items.length > 0;
185
+ const postFilterSelections = useRequestStore(
186
+ state => state.postFilterSelections,
187
+ );
188
+ const hasFindApiPostFilterSelection = Object.values(
189
+ postFilterSelections || {},
190
+ ).some(values => values?.length > 0);
191
+ const isPostFilterApplied = items.length > 0 || hasFindApiPostFilterSelection;
187
192
 
188
193
  return (
189
194
  <>
@@ -322,7 +327,7 @@ function Results() {
322
327
  >
323
328
  {t('Showing results for machine', {
324
329
  prefilter_title:
325
- window.settings.preFilterTitle?.toLocaleLowerCase(),
330
+ window.settings.visualSearchFilterKey,
326
331
  prefilter_value: specifications.prefilter_value,
327
332
  })}
328
333
  </div>
@@ -364,7 +369,8 @@ function Results() {
364
369
 
365
370
  {feedbackStatus === 'visible' &&
366
371
  !showFeedbackSuccess &&
367
- productsFromAlgolia.length > 0 && (
372
+ (productsFromAlgolia.length > 0 ||
373
+ productsFromFindApi.length > 0) && (
368
374
  <div className={'feedback-floating'}>
369
375
  <div className="feedback-section feedback-backdrop-blur" />
370
376
 
@@ -570,6 +576,7 @@ function Results() {
570
576
  onClose={() => {
571
577
  setOpenModalCamera(s => !s);
572
578
  }}
579
+ newSearch={true}
573
580
  />
574
581
  {isAiModeOpen && (
575
582
  <div className="desktop:hidden">
@@ -1,4 +1,8 @@
1
1
  import { Auth0Provider } from '@auth0/auth0-react';
2
+ import { AuthApiKeySync } from 'components/AuthApiKeySync';
3
+ import { bootstrapAuth0ApiKeyGuard } from 'utils/nyrisApiKey';
4
+
5
+ bootstrapAuth0ApiKeyGuard();
2
6
 
3
7
  const AuthProvider = ({ children }: any) => {
4
8
  const settings = window.settings;
@@ -15,6 +19,7 @@ const AuthProvider = ({ children }: any) => {
15
19
  redirect_uri: window.location.origin,
16
20
  }}
17
21
  >
22
+ <AuthApiKeySync />
18
23
  {children}
19
24
  </Auth0Provider>
20
25
  );
@@ -1,15 +1,25 @@
1
1
  import { useAuth0 } from '@auth0/auth0-react';
2
2
  import EmailVerification from 'components/EmailVerification';
3
+ import ScopeMissing from 'components/ScopeMissing';
3
4
  import { useEffect, ReactElement } from 'react';
5
+ import { getScopes } from 'services/filter';
6
+ import useScopeStore from 'stores/scope/scopeStore';
4
7
 
5
8
  export default function ProtectedRoute({
6
9
  children,
7
10
  }: {
8
11
  children: ReactElement;
9
12
  }) {
10
- const { auth0 } = window.settings;
13
+ const { auth0, scopeFilter } = window.settings;
11
14
 
12
15
  const { user, isAuthenticated, isLoading, loginWithRedirect } = useAuth0();
16
+ const setScope = useScopeStore(state => state.setScope);
17
+ const filterAttributes = useScopeStore(state => state.filterAttributes);
18
+
19
+ const setScopeLoading = useScopeStore(state => state.setScopeLoading);
20
+ const scopeIsLoading = useScopeStore(state => state.isLoading);
21
+ const hasFetchedScope = useScopeStore(state => state.hasFetched);
22
+ const setScopeFetched = useScopeStore(state => state.setScopeFetched);
13
23
 
14
24
  useEffect(() => {
15
25
  if (!isLoading && !user && !isAuthenticated && auth0.enabled) {
@@ -17,21 +27,54 @@ export default function ProtectedRoute({
17
27
  }
18
28
  }, [loginWithRedirect, isLoading, user, isAuthenticated, auth0]);
19
29
 
30
+ useEffect(() => {
31
+ if (!scopeFilter || !isAuthenticated || !user?.email || hasFetchedScope) {
32
+ return;
33
+ }
34
+ setScopeFetched(true);
35
+ setScopeLoading(true);
36
+ getScopes(user.email, window.settings)
37
+ .then(response => {
38
+ setScope(response.itemKeys || [], response.filterAttributes || []);
39
+ })
40
+ .catch((e: any) => {
41
+ console.log('err getScopes', e);
42
+ })
43
+ .finally(() => {
44
+ setScopeLoading(false);
45
+ });
46
+ }, [
47
+ scopeFilter,
48
+ isAuthenticated,
49
+ user,
50
+ hasFetchedScope,
51
+ setScope,
52
+ setScopeLoading,
53
+ setScopeFetched,
54
+ ]);
55
+
20
56
  if (!auth0.enabled) {
21
57
  return children;
22
58
  }
23
59
 
24
- // if (isLoading) {
25
- // return <div>Loading...</div>;
26
- // }
27
-
28
- if (!isLoading && isAuthenticated && user?.email_verified) {
29
- return children;
60
+ if (isLoading || !isAuthenticated) {
61
+ return <div></div>;
30
62
  }
31
63
 
32
- if (!isLoading && isAuthenticated && !user?.email_verified) {
64
+ if (!user?.email_verified) {
33
65
  return <EmailVerification />;
34
66
  }
35
67
 
36
- return <div></div>;
68
+ if (scopeFilter) {
69
+ // wait for the scope fetch to finish before deciding access, otherwise ScopeMissing flashes early
70
+ if (scopeIsLoading || !hasFetchedScope) {
71
+ return <div></div>;
72
+ }
73
+
74
+ if (filterAttributes.length === 0) {
75
+ return <ScopeMissing />;
76
+ }
77
+ }
78
+
79
+ return children;
37
80
  }
@@ -3,6 +3,7 @@ import NyrisAPI, {
3
3
  NyrisAPISettings,
4
4
  RectCoords,
5
5
  } from '@nyris/nyris-api';
6
+ import { getNyrisSettings } from 'utils/nyrisApiKey';
6
7
 
7
8
  export const feedbackSuccessEpic = async (
8
9
  requestId: string,
@@ -62,7 +63,7 @@ export const sendFeedbackByApi = async (
62
63
  requestId: string | undefined,
63
64
  payload: FeedbackEventPayload,
64
65
  ) => {
65
- const api = new NyrisAPI(settings);
66
+ const api = new NyrisAPI(getNyrisSettings(settings));
66
67
  if (requestId) {
67
68
  try {
68
69
  await api.sendFeedback({ sessionId, requestId, payload }).then(() => {});
@@ -1,4 +1,5 @@
1
1
  import NyrisAPI, { NyrisAPISettings } from '@nyris/nyris-api';
2
+ import { getNyrisSettings } from 'utils/nyrisApiKey';
2
3
 
3
4
  export interface Filter {
4
5
  key?: string;
@@ -9,7 +10,7 @@ export const getFilters = async (
9
10
  number: number = 10,
10
11
  settings: NyrisAPISettings,
11
12
  ) => {
12
- const nyrisApi = new NyrisAPI(settings);
13
+ const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
13
14
  return nyrisApi.getFilters(number);
14
15
  };
15
16
 
@@ -18,7 +19,20 @@ export const searchFilters = async (
18
19
  value: string,
19
20
  settingsNyris: NyrisAPISettings,
20
21
  ) => {
21
- const nyrisApi = new NyrisAPI(settingsNyris);
22
+ const nyrisApi = new NyrisAPI(getNyrisSettings(settingsNyris));
22
23
  const newValue = value ? value : '';
23
24
  return nyrisApi.searchFilters(key, newValue);
24
25
  };
26
+
27
+ export interface ScopeResponse {
28
+ itemKeys: string[];
29
+ filterAttributes: Filter[];
30
+ }
31
+
32
+ export const getScopes = async (
33
+ scopeIds: string,
34
+ settings: NyrisAPISettings,
35
+ ): Promise<ScopeResponse> => {
36
+ const nyrisApi = new NyrisAPI(settings);
37
+ return nyrisApi.getScopes(scopeIds);
38
+ };
@@ -9,6 +9,7 @@ import { isEqual } from 'lodash';
9
9
 
10
10
  import { DEFAULT_REGION } from '../constants';
11
11
  import { getUserLocation } from 'helpers/getGeoLocation';
12
+ import { getNyrisSettings } from 'utils/nyrisApiKey';
12
13
 
13
14
  export interface Filter {
14
15
  key?: string;
@@ -41,7 +42,7 @@ export const findRegions = async (
41
42
  image: HTMLCanvasElement,
42
43
  settings: NyrisAPISettings,
43
44
  ) => {
44
- const nyrisApi = new NyrisAPI(settings);
45
+ const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
45
46
  let regions = await nyrisApi.findRegions(image);
46
47
  const selectedRegion = getRegionByMaxConfidence(regions);
47
48
  return {
@@ -58,14 +59,16 @@ export const find = async ({
58
59
  region,
59
60
  filters,
60
61
  text,
62
+ scopeIds,
61
63
  }: {
62
64
  image?: HTMLCanvasElement;
63
65
  settings: NyrisAPISettings;
64
66
  region?: RectCoords;
65
67
  filters?: Filter[];
66
68
  text?: string;
69
+ scopeIds?: string;
67
70
  }) => {
68
- const nyrisApi = new NyrisAPI(settings);
71
+ const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
69
72
  let options: ImageSearchOptions = text ? { text } : {};
70
73
 
71
74
  if (window.settings?.geoLocation) {
@@ -82,7 +85,14 @@ export const find = async ({
82
85
  if (region) {
83
86
  options = { ...options, cropRect: region };
84
87
  }
85
- return nyrisApi.find(options, image, filters);
88
+ return nyrisApi.find(
89
+ options,
90
+ image,
91
+ filters,
92
+ undefined,
93
+ undefined,
94
+ scopeIds ? [scopeIds] : [],
95
+ );
86
96
  };
87
97
 
88
98
  export const findCad = ({
@@ -94,7 +104,7 @@ export const findCad = ({
94
104
  settings: NyrisAPISettings;
95
105
  filters?: Filter[];
96
106
  }) => {
97
- const nyrisApi = new NyrisAPI(settings);
107
+ const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
98
108
 
99
109
  return nyrisApi.findByCad(file, {}, filters);
100
110
  };
@@ -106,7 +116,7 @@ export const getRequestImage = ({
106
116
  url: string;
107
117
  settings: NyrisAPISettings;
108
118
  }) => {
109
- const nyrisApi = new NyrisAPI(settings);
119
+ const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
110
120
 
111
121
  return nyrisApi.getCadRequestImage(url);
112
122
  };
@@ -1,6 +1,17 @@
1
1
  import { RectCoords } from '@nyris/nyris-api';
2
2
  import axios from 'axios';
3
3
  import { cropImage } from 'utils/cropImageToBase64';
4
+ import { getEffectiveApiKey } from 'utils/nyrisApiKey';
5
+
6
+ function resolveAiOrNyrisApiKey(explicitApiKey?: string): string {
7
+ if (explicitApiKey) {
8
+ return explicitApiKey;
9
+ }
10
+ if (window.settings.aiApiKey) {
11
+ return window.settings.aiApiKey;
12
+ }
13
+ return getEffectiveApiKey();
14
+ }
4
15
 
5
16
  export interface ChatMultimodalInput {
6
17
  metadata: string;
@@ -111,7 +122,7 @@ export async function rerankSmartFilters({
111
122
  formData,
112
123
  {
113
124
  headers: {
114
- 'x-api-key': apiKey || window.settings.aiApiKey || window.settings.apiKey,
125
+ 'x-api-key': resolveAiOrNyrisApiKey(apiKey),
115
126
  },
116
127
  },
117
128
  );
@@ -156,7 +167,7 @@ export async function generateSmartFilters({
156
167
  formData,
157
168
  {
158
169
  headers: {
159
- 'x-api-key': window.settings.aiApiKey || window.settings.apiKey,
170
+ 'x-api-key': resolveAiOrNyrisApiKey(),
160
171
  },
161
172
  },
162
173
  );
@@ -0,0 +1,35 @@
1
+ import { create } from 'zustand';
2
+ import { Filter } from '@nyris/nyris-api';
3
+
4
+ interface ScopeState {
5
+ itemKeys: string[];
6
+ filterAttributes: Filter[];
7
+ isLoading: boolean;
8
+ hasFetched: boolean;
9
+ }
10
+
11
+ interface ScopeAction {
12
+ setScope: (itemKeys: string[], filterAttributes: Filter[]) => void;
13
+ setScopeLoading: (isLoading: boolean) => void;
14
+ setScopeFetched: (hasFetched: boolean) => void;
15
+ resetScope: () => void;
16
+ }
17
+
18
+ const initialState: ScopeState = {
19
+ itemKeys: [],
20
+ filterAttributes: [],
21
+ isLoading: false,
22
+ hasFetched: false,
23
+ };
24
+
25
+ const useScopeStore = create<ScopeState & ScopeAction>()(set => ({
26
+ ...initialState,
27
+
28
+ setScope: (itemKeys, filterAttributes) =>
29
+ set({ itemKeys, filterAttributes, hasFetched: true }),
30
+ setScopeLoading: isLoading => set({ isLoading }),
31
+ setScopeFetched: hasFetched => set({ hasFetched }),
32
+ resetScope: () => set(initialState),
33
+ }));
34
+
35
+ export default useScopeStore;
@@ -205,6 +205,28 @@
205
205
  margin-right: 8px;
206
206
  }
207
207
  }
208
+ .cart-email-button {
209
+ background-color: #fff;
210
+ color: #3e36dc;
211
+ border: 1px solid #3e36dc;
212
+ width: 100%;
213
+ height: 48px;
214
+ display: flex;
215
+ flex-direction: row;
216
+ align-items: center;
217
+ justify-content: center;
218
+ font-weight: 600;
219
+ font-size: 16px;
220
+ border-radius: 8px;
221
+ margin-top: 8px;
222
+ &:disabled {
223
+ opacity: 0.6;
224
+ cursor: not-allowed;
225
+ }
226
+ svg {
227
+ margin-right: 8px;
228
+ }
229
+ }
208
230
  }
209
231
  }
210
232
  }
package/src/types.ts CHANGED
@@ -79,7 +79,11 @@ export interface AppSettings extends NyrisAPISettings {
79
79
  brandName?: string;
80
80
  cadenas?: Cadenas;
81
81
  cadSearch?: boolean;
82
- cart?: boolean;
82
+ cart?: {
83
+ enabled: boolean;
84
+ buttonText: string;
85
+ emailTemplateId: string;
86
+ };
83
87
  clarityId?: string;
84
88
  CTAButton?: CTAButtonSettings;
85
89
  experienceVisualSearch?: boolean;
@@ -110,6 +114,7 @@ export interface AppSettings extends NyrisAPISettings {
110
114
  refinements?: any;
111
115
  regions: boolean;
112
116
  rfq?: Rfq;
117
+ scopeFilter?: boolean;
113
118
  secondaryCTAButton?: SecondaryCTAButton;
114
119
  secondaryCTAButtonText?: string;
115
120
  secondaryTitle: string;
@@ -0,0 +1,68 @@
1
+ interface PostFilterFacetValue {
2
+ value: string;
3
+ count: number;
4
+ }
5
+
6
+ export interface PostFilterFacet {
7
+ name: string;
8
+ values: PostFilterFacetValue[];
9
+ }
10
+
11
+ interface ProductWithFilters {
12
+ filters?: Record<string, unknown>;
13
+ }
14
+
15
+ /**
16
+ * Aggregates post-filter facet options from result.filters when the find API
17
+ * does not return top-level facets (e.g. visual search). Matches the pre-02ede11
18
+ * PostFilterFindApi counting behavior.
19
+ */
20
+ export function derivePostFilterFacetsFromResults(
21
+ results: ProductWithFilters[] | undefined | null,
22
+ ): PostFilterFacet[] {
23
+ if (!results?.length) {
24
+ return [];
25
+ }
26
+
27
+ const countsByAttribute: Record<string, Record<string, number>> = {};
28
+
29
+ results.forEach(product => {
30
+ const filters = product?.filters;
31
+ if (!filters || typeof filters !== 'object') {
32
+ return;
33
+ }
34
+
35
+ Object.entries(filters).forEach(([attribute, filterValues]) => {
36
+ if (!Array.isArray(filterValues)) {
37
+ return;
38
+ }
39
+
40
+ if (!countsByAttribute[attribute]) {
41
+ countsByAttribute[attribute] = {};
42
+ }
43
+
44
+ filterValues.forEach((val: unknown) => {
45
+ if (typeof val === 'string' && val) {
46
+ countsByAttribute[attribute][val] =
47
+ (countsByAttribute[attribute][val] || 0) + 1;
48
+ }
49
+ });
50
+ });
51
+ });
52
+
53
+ return Object.entries(countsByAttribute).map(([name, counts]) => ({
54
+ name,
55
+ values: Object.entries(counts).map(([value, count]) => ({ value, count })),
56
+ }));
57
+ }
58
+
59
+ export function resolvePostFilterFacets(
60
+ apiFacets: unknown,
61
+ results: ProductWithFilters[] | undefined | null,
62
+ ): PostFilterFacet[] {
63
+ if (Array.isArray(apiFacets) && apiFacets.length > 0) {
64
+ return apiFacets as PostFilterFacet[];
65
+ }
66
+
67
+ return derivePostFilterFacetsFromResults(results);
68
+ }