@nyris/nyris-webapp 0.3.97 → 0.3.99

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 (94) hide show
  1. package/build/asset-manifest.json +7 -6
  2. package/build/index.html +1 -1
  3. package/build/js/settings.example.js +41 -0
  4. package/build/static/css/main.5cb72c09.css +4 -0
  5. package/build/static/css/main.5cb72c09.css.map +1 -0
  6. package/build/static/js/main.2339271b.js +3 -0
  7. package/build/static/js/{main.9e7fb38a.js.map → main.2339271b.js.map} +1 -1
  8. package/build/static/media/plate_info.6beaea36d35053be3a45.png +0 -0
  9. package/package.json +3 -3
  10. package/public/js/settings.example.js +41 -0
  11. package/src/common/assets/images/plate_info.png +0 -0
  12. package/src/components/AuthApiKeySync.tsx +34 -0
  13. package/src/components/Cart.tsx +41 -0
  14. package/src/components/ChatAssistant/useChatAssistantLogic.ts +3 -2
  15. package/src/components/GoBackButton.tsx +29 -2
  16. package/src/components/Header.tsx +3 -0
  17. package/src/components/HitsPerPage.tsx +55 -18
  18. package/src/components/ImagePreview.tsx +31 -1
  19. package/src/components/ImageUpload.tsx +14 -3
  20. package/src/components/Inquiry/InquiryModal.tsx +3 -0
  21. package/src/components/LogoutModal.tsx +2 -0
  22. package/src/components/Pagination.tsx +33 -6
  23. package/src/components/PostFilter/PostFilter.tsx +8 -1
  24. package/src/components/PostFilter/PostFilterComponent.tsx +8 -22
  25. package/src/components/PostFilter/PostFilterFindApi.test.tsx +249 -0
  26. package/src/components/PostFilter/PostFilterFindApi.tsx +128 -52
  27. package/src/components/PreFilter/PreFilter.tsx +10 -2
  28. package/src/components/Product/Product.tsx +60 -10
  29. package/src/components/Product/ProductDetailView.tsx +23 -1
  30. package/src/components/Product/ProductDetailViewModal.tsx +5 -1
  31. package/src/components/Product/ProductList.tsx +51 -16
  32. package/src/components/Rfq/RfqModal.tsx +3 -0
  33. package/src/components/ScopeSelect/ScopeSelectForm.tsx +514 -0
  34. package/src/components/ScopeSelect/ScopeSelectImageActions.tsx +62 -0
  35. package/src/components/ScopeSelect/ScopeSelectImagePreview.tsx +141 -0
  36. package/src/components/ScopeSelect/ScopeSelectInfo.tsx +68 -0
  37. package/src/components/ScopeSelect/ScopeSelectInput.tsx +123 -0
  38. package/src/components/ScopeSelect/ScopeSelectModal.tsx +310 -0
  39. package/src/components/ScopeSelect/identifyTypePlateImage.test.ts +129 -0
  40. package/src/components/ScopeSelect/identifyTypePlateImage.ts +83 -0
  41. package/src/components/ScopeSelect/parseScopeIdResponse.test.ts +103 -0
  42. package/src/components/ScopeSelect/parseScopeIdResponse.ts +72 -0
  43. package/src/components/ScopeSelect/scopeSelectHelpers.ts +54 -0
  44. package/src/components/SidePanel.tsx +43 -0
  45. package/src/components/TextSearch.tsx +80 -14
  46. package/src/components/Tooltip/TooltipComponent.tsx +12 -4
  47. package/src/components/TypePlateUpload.tsx +132 -0
  48. package/src/hooks/useEffectiveGroundingResults.ts +8 -6
  49. package/src/hooks/useGoodResultsChat.ts +8 -22
  50. package/src/hooks/useImageSearch.ts +325 -20
  51. package/src/hooks/useMachinePartsListingPagination.ts +95 -0
  52. package/src/hooks/useResultEvaluator.ts +43 -19
  53. package/src/index.css +1 -1
  54. package/src/index.tsx +4 -0
  55. package/src/layouts/AppLayout.tsx +95 -9
  56. package/src/pages/Home.tsx +60 -14
  57. package/src/pages/Logout.tsx +2 -0
  58. package/src/pages/Result.tsx +206 -20
  59. package/src/providers/AuthProvider.tsx +5 -0
  60. package/src/services/Feedback.ts +224 -35
  61. package/src/services/feedbackSubtypes.ts +63 -0
  62. package/src/services/filter.ts +4 -3
  63. package/src/services/initialMachinePartsText.payload.test.ts +61 -0
  64. package/src/services/initialMachinePartsText.test.ts +33 -0
  65. package/src/services/initialMachinePartsText.ts +11 -0
  66. package/src/services/machinePartsPage.test.ts +74 -0
  67. package/src/services/machinePartsPage.ts +120 -0
  68. package/src/services/normalizeChatResponse.ts +26 -0
  69. package/src/services/scopeLookup.test.ts +67 -0
  70. package/src/services/scopeLookup.ts +39 -0
  71. package/src/services/visualSearch.listingOptions.test.ts +79 -0
  72. package/src/services/visualSearch.ts +76 -7
  73. package/src/services/vizo.ts +17 -5
  74. package/src/stores/machinePartsListing/machinePartsListingStore.ts +35 -0
  75. package/src/stores/request/filter/filter.initialState.ts +1 -0
  76. package/src/stores/request/filter/filter.slice.ts +2 -0
  77. package/src/stores/result/prodcuts/products.initialState.ts +1 -0
  78. package/src/stores/result/prodcuts/products.slice.ts +4 -0
  79. package/src/stores/scope/scopeStore.ts +67 -4
  80. package/src/stores/types.ts +19 -14
  81. package/src/translations.ts +213 -0
  82. package/src/types.ts +40 -0
  83. package/src/utils/derivePostFilterFacets.ts +68 -0
  84. package/src/utils/fetchProductImage.ts +1 -0
  85. package/src/utils/normalizeChatAssistantResponse.ts +227 -0
  86. package/src/utils/nyrisApiKey.ts +115 -0
  87. package/src/utils/productCardImageUrl.test.ts +41 -0
  88. package/src/utils/productCardImageUrl.ts +49 -0
  89. package/src/utils/textSearchFilter.test.ts +41 -0
  90. package/src/utils/textSearchFilter.ts +13 -6
  91. package/build/static/css/main.7c7f5447.css +0 -4
  92. package/build/static/css/main.7c7f5447.css.map +0 -1
  93. package/build/static/js/main.9e7fb38a.js +0 -3
  94. /package/build/static/js/{main.9e7fb38a.js.LICENSE.txt → main.2339271b.js.LICENSE.txt} +0 -0
@@ -11,7 +11,7 @@ import SidePanel from 'components/SidePanel';
11
11
  import Loading from 'components/Loading';
12
12
 
13
13
  import Feedback from 'components/Feedback';
14
- import { feedbackSuccessEpic } from 'services/Feedback';
14
+ import { trackResultFeedback } from 'services/Feedback';
15
15
  import useRequestStore from 'stores/request/requestStore';
16
16
  import RfqBanner from 'components/Rfq/RfqBanner';
17
17
  import InquiryBanner from 'components/Inquiry/InquiryBanner';
@@ -22,15 +22,24 @@ import ImagePreview from 'components/ImagePreview';
22
22
  import PoweredBy from 'components/PoweredBy';
23
23
  import CurrentRefinements from 'components/CurrentRefinements';
24
24
  import { GoBackButton } from 'components/GoBackButton';
25
+ import { useNavigate } from 'react-router';
25
26
  import { useCurrentRefinements } from 'react-instantsearch';
26
27
  import CustomCamera from 'components/CustomCameraDrawer';
27
28
  import { useTranslation } from 'react-i18next';
28
29
  import { useImageSearch } from 'hooks/useImageSearch';
30
+ import { useMachinePartsListingPagination } from 'hooks/useMachinePartsListingPagination';
29
31
  import MatchNotificationBanner from 'components/MatchNotificationBanner';
30
32
  import ImageUpload from 'components/ImageUpload';
31
33
  import SmartFilter from 'components/SmartFilter';
32
34
  import { ChatAssistant, MobileChatAssistant } from 'components/ChatAssistant';
33
35
  import IdentifiedAttributes from 'components/IdentifiedAttributes';
36
+ import useScopeStore from 'stores/scope/scopeStore';
37
+ import { TypePlateUpload } from 'components/TypePlateUpload';
38
+ import { ScopeSelectModal } from 'components/ScopeSelect/ScopeSelectModal';
39
+
40
+ const DEFAULT_SCOPE_RESULTS_LABEL =
41
+ 'Searching within parts for {{scope_id}}. ' +
42
+ 'You can search with keyword or image to find the right item';
34
43
 
35
44
  function Results() {
36
45
  const settings = window.settings;
@@ -55,8 +64,10 @@ function Results() {
55
64
  const showFeedback = useUiStore(state => state.showFeedback);
56
65
  const setIsFindApiLoading = useUiStore(state => state.setIsFindApiLoading);
57
66
  const isAiModeOpen = useUiStore(state => state.isAiModeOpen);
67
+ const requestQuality = useResultStore(state => state.requestQuality);
68
+ const [isRequestQualityExpanded, setIsRequestQualityExpanded] =
69
+ useState(true);
58
70
 
59
- const requestId = useResultStore(state => state.requestId);
60
71
  const imageAnalysis = useResultStore(state => state.imageAnalysis);
61
72
 
62
73
  const specificationFilter = useRequestStore(
@@ -64,13 +75,32 @@ function Results() {
64
75
  );
65
76
  const requestImages = useRequestStore(state => state.requestImages);
66
77
  const specifications = useRequestStore(state => state.specifications);
67
- const query = useRequestStore(state => state.query);
68
78
  const regions = useRequestStore(state => state.regions);
69
-
79
+ const query = useRequestStore(state => state.query);
80
+ const scopeItemKeys = useScopeStore(state => state.itemKeys);
81
+ const activeScopeId = useScopeStore(state => state.scopeId);
82
+ const resetScope = useScopeStore(state => state.resetScope);
83
+ const navigate = useNavigate();
70
84
  const [showPostFilter, setShowPostFilter] = useState(false);
71
85
  const [isOpenModalCamera, setOpenModalCamera] = useState<boolean>(false);
72
86
 
73
87
  const isShowFilter = query || requestImages[0];
88
+ const isScopeFeatureEnabled = Boolean(window.settings?.scopes?.scopeID);
89
+ const isScopeFilterActive =
90
+ isScopeFeatureEnabled && scopeItemKeys.length > 0 && Boolean(activeScopeId);
91
+ const [isTypePlateOpen, setIsTypePlateOpen] = useState(false);
92
+ const [isNoScopeBannerDismissed, setIsNoScopeBannerDismissed] =
93
+ useState<boolean>(false);
94
+
95
+ useEffect(() => {
96
+ if (isScopeFilterActive) {
97
+ setIsNoScopeBannerDismissed(false);
98
+ }
99
+ }, [isScopeFilterActive]);
100
+
101
+ useEffect(() => {
102
+ setIsNoScopeBannerDismissed(false);
103
+ }, [query, requestImages]);
74
104
 
75
105
  const { items } = useCurrentRefinements();
76
106
 
@@ -80,6 +110,46 @@ function Results() {
80
110
  document.title = 'Search results';
81
111
  }, [cadenas?.cadenas3dWebView, cadenas?.cadenasAPIKey]);
82
112
  const { singleImageSearch } = useImageSearch();
113
+ const listingPagination = useMachinePartsListingPagination();
114
+ // Scoped keyword/image results come from Find API, which has no paging, so
115
+ // the Algolia page-size control would only change hidden results.
116
+ const isFindApiSearchUnderAlgolia =
117
+ isScopeFilterActive &&
118
+ Boolean(settings.algolia?.enabled) &&
119
+ !listingPagination.isListing;
120
+
121
+ const clearActiveScopeFilter = () => {
122
+ const keyword = (useRequestStore.getState().query || '').trim();
123
+ const searchImage = useRequestStore.getState().requestImages[0];
124
+ const region = useRequestStore.getState().regions[0];
125
+ resetScope();
126
+ if (searchImage) {
127
+ void singleImageSearch({
128
+ image: searchImage,
129
+ imageRegion: region,
130
+ settings,
131
+ showFeedback: false,
132
+ newSearch: false,
133
+ });
134
+ return;
135
+ }
136
+ if (keyword) {
137
+ if (window.settings.algolia?.enabled) {
138
+ useRequestStore.getState().setAlgoliaFilter('');
139
+ return;
140
+ }
141
+ void singleImageSearch({
142
+ text: keyword,
143
+ settings,
144
+ showFeedback: false,
145
+ newSearch: false,
146
+ });
147
+ return;
148
+ }
149
+ useResultStore.getState().reset();
150
+ useRequestStore.getState().reset();
151
+ navigate('/');
152
+ };
83
153
 
84
154
  const getImageFromUrl = async (
85
155
  url: string,
@@ -134,7 +204,7 @@ function Results() {
134
204
  }, 3000);
135
205
  setFeedbackStatus('submitted');
136
206
  setShowFeedback(false);
137
- feedbackSuccessEpic(requestId, data);
207
+ trackResultFeedback(data);
138
208
  };
139
209
 
140
210
  useEffect(() => {
@@ -174,16 +244,21 @@ function Results() {
174
244
  ]);
175
245
 
176
246
  const disablePostFilter = useMemo(() => {
177
- if (!settings.algolia.enabled) {
247
+ // Find-API post-filters do not depend on Algolia hits.
248
+ if (!settings.algolia.enabled || settings.postFilterOption) {
178
249
  return false;
179
250
  }
180
251
 
181
- return settings.postFilterOption && productsFromAlgolia.length > 0
182
- ? false
183
- : true;
252
+ return productsFromAlgolia.length > 0 ? false : true;
184
253
  }, [settings, productsFromAlgolia]);
185
254
 
186
- const isPostFilterApplied = items.length > 0;
255
+ const postFilterSelections = useRequestStore(
256
+ state => state.postFilterSelections,
257
+ );
258
+ const hasFindApiPostFilterSelection = Object.values(
259
+ postFilterSelections || {},
260
+ ).some(values => values?.length > 0);
261
+ const isPostFilterApplied = items.length > 0 || hasFindApiPostFilterSelection;
187
262
 
188
263
  return (
189
264
  <>
@@ -231,6 +306,33 @@ function Results() {
231
306
  />
232
307
  </div>
233
308
  )}
309
+ {window.settings.requestQuality && requestQuality && (
310
+ <div className="mx-4 rounded-3xl bg-[#F3F4F8] border border-[#e4e3ff] p-3.5 text-[#3B3E5F]">
311
+ <button
312
+ type="button"
313
+ className="w-full flex items-center justify-between gap-2"
314
+ onClick={() =>
315
+ setIsRequestQualityExpanded(prev => !prev)
316
+ }
317
+ >
318
+ <span className="text-[#3B3E5F] font-bold text-sm leading-4">
319
+ Request Image Quality
320
+ </span>
321
+ <Icon
322
+ name="arrow_down"
323
+ className={twMerge(
324
+ 'fill-[#3B3E5F] w-3 h-3 shrink-0 transition-transform',
325
+ isRequestQualityExpanded && 'rotate-180',
326
+ )}
327
+ />
328
+ </button>
329
+ {isRequestQualityExpanded && (
330
+ <div className="text-[#767A9F] text-sm mt-2.5">
331
+ {requestQuality}
332
+ </div>
333
+ )}
334
+ </div>
335
+ )}
234
336
  {window.settings.showImageDetails &&
235
337
  (imageAnalysis?.imageDescription ||
236
338
  Object.keys(imageAnalysis?.specification || {}).length >
@@ -349,6 +451,69 @@ function Results() {
349
451
  <GoBackButton className="col-span-full mb-2 desktop:mb-0 mt-2 desktop:mt-0" />
350
452
  <CurrentRefinements className="col-span-full" />
351
453
  <MatchNotificationBanner className="col-span-full" />
454
+ {isScopeFilterActive && (
455
+ <div className="col-span-full flex items-center justify-between gap-4 rounded-2xl border border-solid border-theme-primary bg-white px-4 py-[.8rem] text-sm text-primary">
456
+ <p className="min-w-0 flex-1 font-normal text-left">
457
+ {t(
458
+ window.settings.scopes
459
+ ?.typePlateResultsScopeLabel ||
460
+ DEFAULT_SCOPE_RESULTS_LABEL,
461
+ { scope_id: activeScopeId },
462
+ )}
463
+ </p>
464
+ <button
465
+ type="button"
466
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-primary hover:text-black cursor-pointer"
467
+ onClick={clearActiveScopeFilter}
468
+ aria-label={t(
469
+ window.settings.scopes
470
+ ?.typePlateResultsClearScopeLabel ||
471
+ 'Search all products',
472
+ )}
473
+ title={t(
474
+ window.settings.scopes
475
+ ?.typePlateResultsClearScopeLabel ||
476
+ 'Search all products',
477
+ )}
478
+ >
479
+ <Icon name="close" className="h-3 w-3" />
480
+ </button>
481
+ </div>
482
+ )}
483
+ {isScopeFeatureEnabled &&
484
+ !isScopeFilterActive &&
485
+ !isNoScopeBannerDismissed && (
486
+ <div className="col-span-full flex items-center justify-between gap-4 rounded-2xl border border-solid border-theme-primary bg-white px-4 py-2.5 text-sm text-[#2B2C46]">
487
+ <div className="min-w-0 flex-1 text-left">
488
+ <p className="font-normal text-[#2B2C46]">
489
+ {t(
490
+ window.settings.scopes
491
+ ?.typePlateResultsNoScopeLabel ||
492
+ 'Scan your type plate to see only parts for your equipment.',
493
+ )}
494
+ </p>
495
+ </div>
496
+ <div className="flex shrink-0 items-center gap-2 desktop:gap-3">
497
+ <TypePlateUpload
498
+ alternativeView
499
+ showModal={false}
500
+ open={isTypePlateOpen}
501
+ onOpenChange={setIsTypePlateOpen}
502
+ />
503
+ <button
504
+ type="button"
505
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-primary hover:text-black cursor-pointer"
506
+ onClick={() =>
507
+ setIsNoScopeBannerDismissed(true)
508
+ }
509
+ aria-label={t('Close')}
510
+ title={t('Close')}
511
+ >
512
+ <Icon name="close" className="h-3 w-3" />
513
+ </button>
514
+ </div>
515
+ </div>
516
+ )}
352
517
  <ProductList />
353
518
  </div>
354
519
 
@@ -364,7 +529,8 @@ function Results() {
364
529
 
365
530
  {feedbackStatus === 'visible' &&
366
531
  !showFeedbackSuccess &&
367
- productsFromAlgolia.length > 0 && (
532
+ (productsFromAlgolia.length > 0 ||
533
+ productsFromFindApi.length > 0) && (
368
534
  <div className={'feedback-floating'}>
369
535
  <div className="feedback-section feedback-backdrop-blur" />
370
536
 
@@ -380,15 +546,26 @@ function Results() {
380
546
  </div>
381
547
  )}
382
548
  <div>
383
- <Pagination
384
- isLoading={isFindApiLoading || isAlgoliaLoading}
385
- className={
386
- productsFromAlgolia.length === 0 &&
387
- !isAlgoliaLoading
388
- ? 'opacity-0 hidden'
389
- : ''
390
- }
391
- />
549
+ {listingPagination.isListing ? (
550
+ <Pagination
551
+ isLoading={isFindApiLoading || isAlgoliaLoading}
552
+ pageCount={listingPagination.pageCount}
553
+ pageIndex={listingPagination.pageIndex}
554
+ onPageChange={listingPagination.changePage}
555
+ />
556
+ ) : (
557
+ !isScopeFilterActive && (
558
+ <Pagination
559
+ isLoading={isFindApiLoading || isAlgoliaLoading}
560
+ className={
561
+ productsFromAlgolia.length === 0 &&
562
+ !isAlgoliaLoading
563
+ ? 'opacity-0 hidden'
564
+ : ''
565
+ }
566
+ />
567
+ )
568
+ )}
392
569
  <div
393
570
  style={{
394
571
  display: 'flex',
@@ -436,6 +613,9 @@ function Results() {
436
613
  { label: '40', value: 40 },
437
614
  { label: '50', value: 50 },
438
615
  ]}
616
+ onFindApiPageSizeChange={listingPagination.changePageSize}
617
+ useFindApiPageSize={listingPagination.isListing}
618
+ showPageSize={!isFindApiSearchUnderAlgolia}
439
619
  />
440
620
  </div>
441
621
  </div>
@@ -565,6 +745,12 @@ function Results() {
565
745
  openModal={showPostFilter}
566
746
  handleClose={() => setShowPostFilter(false)}
567
747
  />
748
+ {isScopeFeatureEnabled && (
749
+ <ScopeSelectModal
750
+ openModal={isTypePlateOpen}
751
+ handleClose={() => setIsTypePlateOpen(false)}
752
+ />
753
+ )}
568
754
  <CustomCamera
569
755
  show={isOpenModalCamera}
570
756
  onClose={() => {
@@ -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,73 +1,262 @@
1
1
  import NyrisAPI, {
2
+ ConversionRevenue,
2
3
  FeedbackEventPayload,
3
4
  NyrisAPISettings,
4
5
  RectCoords,
5
6
  } from '@nyris/nyris-api';
7
+ import { getNyrisSettings } from 'utils/nyrisApiKey';
8
+ import useResultStore from 'stores/result/resultStore';
9
+ import { CONVERSION_SUBTYPE, normalizeSubtype } from './feedbackSubtypes';
6
10
 
7
- export const feedbackSuccessEpic = async (
8
- requestId: string,
9
- success: boolean,
11
+ export interface FeedbackIds {
12
+ requestId?: string;
13
+ sessionId?: string;
14
+ }
15
+
16
+ interface ProductRef {
17
+ sku?: string;
18
+ position?: number;
19
+ }
20
+
21
+ interface ProductRefs {
22
+ skus?: (string | undefined)[];
23
+ positions?: (number | undefined)[];
24
+ }
25
+
26
+ type ConversionDetails = ProductRefs & {
27
+ quantity?: number;
28
+ revenue?: ConversionRevenue;
29
+ };
30
+
31
+ const compactStrings = (values?: (string | undefined)[]) =>
32
+ values?.filter((value): value is string => !!value) ?? [];
33
+
34
+ const compactNumbers = (values?: (number | undefined)[]) =>
35
+ values?.filter(
36
+ (value): value is number => typeof value === 'number' && value >= 0,
37
+ ) ?? [];
38
+
39
+ /**
40
+ * Builds the `product_ids` / `positions` part of a click or conversion payload,
41
+ * dropping empty arrays so the payload matches the API examples.
42
+ */
43
+ const productData = ({ skus, positions }: ProductRefs) => {
44
+ const product_ids = compactStrings(skus);
45
+ const cleanPositions = compactNumbers(positions);
46
+ return {
47
+ ...(product_ids.length ? { product_ids } : {}),
48
+ ...(cleanPositions.length ? { positions: cleanPositions } : {}),
49
+ };
50
+ };
51
+
52
+ const resolveIds = (ids?: FeedbackIds): FeedbackIds => {
53
+ if (ids && (ids.requestId || ids.sessionId)) {
54
+ return ids;
55
+ }
56
+ const { requestId, sessionId } = useResultStore.getState();
57
+ return { requestId, sessionId };
58
+ };
59
+
60
+ /**
61
+ * An event is suppressed when its event type OR its subtype appears in
62
+ * `window.settings.disabledFeedbackEvents` (case-insensitive).
63
+ */
64
+ export const isFeedbackEventEnabled = (event: string, subtype?: string) => {
65
+ const disabled = (window.settings?.disabledFeedbackEvents || [])
66
+ .filter((entry): entry is string => typeof entry === 'string')
67
+ .map(entry => entry.trim().toLowerCase())
68
+ .filter(Boolean);
69
+
70
+ if (disabled.length === 0) return true;
71
+ if (disabled.includes(event.trim().toLowerCase())) return false;
72
+
73
+ const normalized = normalizeSubtype(subtype);
74
+ return !(normalized && disabled.includes(normalized));
75
+ };
76
+
77
+ /**
78
+ * Central dispatcher: resolves request/session ids from the result store when
79
+ * not provided, applies the disabled-events setting, normalizes the subtype,
80
+ * and sends the event.
81
+ */
82
+ export const trackFeedbackEvent = async (
83
+ payload: FeedbackEventPayload,
84
+ ids?: FeedbackIds,
10
85
  ) => {
11
- const settings = window.settings;
86
+ const data = payload.data as { subtype?: string } | undefined;
87
+ const subtype = normalizeSubtype(data?.subtype);
12
88
 
13
- return await sendFeedbackByApi(settings, undefined, requestId, {
14
- event: 'feedback',
15
- data: { success },
16
- });
89
+ if (!isFeedbackEventEnabled(payload.event, subtype)) return;
90
+
91
+ const normalizedPayload = (
92
+ data
93
+ ? {
94
+ ...payload,
95
+ data: subtype ? { ...data, subtype } : omitSubtype(data),
96
+ }
97
+ : payload
98
+ ) as FeedbackEventPayload;
99
+
100
+ const { requestId, sessionId } = resolveIds(ids);
101
+ return sendFeedbackByApi(
102
+ window.settings,
103
+ sessionId,
104
+ requestId,
105
+ normalizedPayload,
106
+ );
17
107
  };
18
108
 
109
+ const omitSubtype = <T extends { subtype?: string }>(data: T) => {
110
+ const { subtype: _ignored, ...rest } = data;
111
+ return rest;
112
+ };
113
+
114
+ /** `click` event for a single result or a product-less interaction. */
115
+ export const trackClick = (
116
+ subtype: string,
117
+ product?: ProductRef,
118
+ ids?: FeedbackIds,
119
+ ) =>
120
+ trackFeedbackEvent(
121
+ {
122
+ event: 'click',
123
+ data: {
124
+ subtype,
125
+ ...productData({
126
+ skus: [product?.sku],
127
+ positions: [product?.position],
128
+ }),
129
+ },
130
+ },
131
+ ids,
132
+ );
133
+
134
+ /** `conversion` event. Pass `undefined` as subtype for a legacy conversion. */
135
+ export const trackConversion = (
136
+ subtype: string | undefined,
137
+ details: ConversionDetails = {},
138
+ ids?: FeedbackIds,
139
+ ) => {
140
+ const { quantity, revenue, ...refs } = details;
141
+ return trackFeedbackEvent(
142
+ {
143
+ event: 'conversion',
144
+ data: {
145
+ ...(subtype ? { subtype } : {}),
146
+ ...productData(refs),
147
+ ...(typeof quantity === 'number' ? { quantity } : {}),
148
+ ...(revenue ? { revenue } : {}),
149
+ },
150
+ },
151
+ ids,
152
+ );
153
+ };
154
+
155
+ /**
156
+ * `conversion` / `purchase`. There is no checkout flow in the search suite
157
+ * today, so this is provided for host integrations and future use.
158
+ */
159
+ export const trackPurchase = (details: ConversionDetails, ids?: FeedbackIds) =>
160
+ trackConversion(CONVERSION_SUBTYPE.PURCHASE, details, ids);
161
+
162
+ /** `feedback` event from the "Did we find what you were looking for?" prompt. */
163
+ export const trackResultFeedback = (
164
+ success: boolean,
165
+ comment?: string,
166
+ ids?: FeedbackIds,
167
+ ) =>
168
+ trackFeedbackEvent(
169
+ {
170
+ event: 'feedback',
171
+ data: {
172
+ success,
173
+ ...(comment ? { comment } : {}),
174
+ },
175
+ },
176
+ ids,
177
+ );
178
+
179
+ /** `region` event; converts x1/y1/x2/y2 to the API's x/y/w/h rectangle. */
180
+ export const trackRegion = (
181
+ region: RectCoords,
182
+ subtype: string,
183
+ ids?: FeedbackIds,
184
+ ) => {
185
+ const { x1, x2, y1, y2 } = region;
186
+ return trackFeedbackEvent(
187
+ {
188
+ event: 'region',
189
+ data: { subtype, rect: { x: x1, y: y1, w: x2 - x1, h: y2 - y1 } },
190
+ },
191
+ ids,
192
+ );
193
+ };
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Legacy wrappers kept for backwards compatibility.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export const feedbackSuccessEpic = async (
200
+ requestId: string,
201
+ success: boolean,
202
+ ) => trackResultFeedback(success, undefined, { requestId });
203
+
19
204
  export const feedbackClickEpic = async (
20
205
  requestId: string,
21
206
  sessionId: string,
22
207
  position: number,
23
208
  id?: string,
24
- ) => {
25
- const settings = window.settings;
26
- return await sendFeedbackByApi(settings, sessionId, requestId, {
27
- event: 'click',
28
- data: { positions: [position], ...(id ? { product_ids: [id] } : {}) },
29
- });
30
- };
209
+ ) =>
210
+ trackFeedbackEvent(
211
+ {
212
+ event: 'click',
213
+ data: { positions: [position], ...(id ? { product_ids: [id] } : {}) },
214
+ },
215
+ { requestId, sessionId },
216
+ );
31
217
 
32
218
  export const feedbackConversionEpic = async (
33
219
  requestId: string,
34
220
  sessionId: string,
35
221
  position: number,
36
222
  id?: string,
37
- ) => {
38
- const settings = window.settings;
39
- return await sendFeedbackByApi(settings, sessionId, requestId, {
40
- event: 'conversion',
41
- data: { positions: [position], ...(id ? { product_ids: [id] } : {}) },
42
- });
43
- };
223
+ ) =>
224
+ trackConversion(
225
+ undefined,
226
+ { skus: [id], positions: [position] },
227
+ {
228
+ requestId,
229
+ sessionId,
230
+ },
231
+ );
44
232
 
45
233
  export const feedbackRegionEpic = async (
46
234
  requestId: string,
47
235
  sessionId: string,
48
236
  region: RectCoords,
49
237
  ) => {
50
- const settings = window.settings;
51
238
  const { x1, x2, y1, y2 } = region;
52
- const payload: FeedbackEventPayload = {
53
- event: 'region',
54
- data: { rect: { x: x1, y: y1, w: x2 - x1, h: y2 - y1 } },
55
- };
56
- return await sendFeedbackByApi(settings, sessionId, requestId, payload);
239
+ return trackFeedbackEvent(
240
+ {
241
+ event: 'region',
242
+ data: { rect: { x: x1, y: y1, w: x2 - x1, h: y2 - y1 } },
243
+ },
244
+ { requestId, sessionId },
245
+ );
57
246
  };
58
247
 
248
+ /** Transport. Silently skips when there is no request id to attach to. */
59
249
  export const sendFeedbackByApi = async (
60
250
  settings: NyrisAPISettings,
61
251
  sessionId: string | undefined,
62
252
  requestId: string | undefined,
63
253
  payload: FeedbackEventPayload,
64
254
  ) => {
65
- const api = new NyrisAPI(settings);
66
- if (requestId) {
67
- try {
68
- await api.sendFeedback({ sessionId, requestId, payload }).then(() => {});
69
- } catch (error) {
70
- console.log('error sendFeedbackByApi', error);
71
- }
255
+ if (!requestId || !window.settings.showFeedback) return;
256
+ try {
257
+ const api = new NyrisAPI(getNyrisSettings(settings));
258
+ await api.sendFeedback({ sessionId, requestId, payload });
259
+ } catch (error) {
260
+ console.log('error sendFeedbackByApi', error);
72
261
  }
73
262
  };