@nyris/nyris-webapp 0.3.98 → 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 (85) hide show
  1. package/build/asset-manifest.json +7 -6
  2. package/build/index.html +1 -1
  3. package/build/js/settings.example.js +40 -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.93fdbd6e.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 +40 -0
  11. package/src/common/assets/images/plate_info.png +0 -0
  12. package/src/components/Cart.tsx +41 -0
  13. package/src/components/GoBackButton.tsx +29 -2
  14. package/src/components/Header.tsx +3 -0
  15. package/src/components/HitsPerPage.tsx +55 -18
  16. package/src/components/ImagePreview.tsx +31 -1
  17. package/src/components/ImageUpload.tsx +12 -1
  18. package/src/components/Inquiry/InquiryModal.tsx +3 -0
  19. package/src/components/Pagination.tsx +33 -6
  20. package/src/components/PostFilter/PostFilter.tsx +8 -1
  21. package/src/components/PostFilter/PostFilterFindApi.test.tsx +249 -0
  22. package/src/components/PostFilter/PostFilterFindApi.tsx +128 -52
  23. package/src/components/PreFilter/PreFilter.tsx +10 -2
  24. package/src/components/Product/Product.tsx +60 -10
  25. package/src/components/Product/ProductDetailView.tsx +23 -1
  26. package/src/components/Product/ProductDetailViewModal.tsx +5 -1
  27. package/src/components/Product/ProductList.tsx +51 -18
  28. package/src/components/Rfq/RfqModal.tsx +3 -0
  29. package/src/components/ScopeSelect/ScopeSelectForm.tsx +514 -0
  30. package/src/components/ScopeSelect/ScopeSelectImageActions.tsx +62 -0
  31. package/src/components/ScopeSelect/ScopeSelectImagePreview.tsx +141 -0
  32. package/src/components/ScopeSelect/ScopeSelectInfo.tsx +68 -0
  33. package/src/components/ScopeSelect/ScopeSelectInput.tsx +123 -0
  34. package/src/components/ScopeSelect/ScopeSelectModal.tsx +310 -0
  35. package/src/components/ScopeSelect/identifyTypePlateImage.test.ts +129 -0
  36. package/src/components/ScopeSelect/identifyTypePlateImage.ts +83 -0
  37. package/src/components/ScopeSelect/parseScopeIdResponse.test.ts +103 -0
  38. package/src/components/ScopeSelect/parseScopeIdResponse.ts +72 -0
  39. package/src/components/ScopeSelect/scopeSelectHelpers.ts +54 -0
  40. package/src/components/SidePanel.tsx +33 -0
  41. package/src/components/TextSearch.tsx +77 -11
  42. package/src/components/Tooltip/TooltipComponent.tsx +12 -4
  43. package/src/components/TypePlateUpload.tsx +132 -0
  44. package/src/hooks/useEffectiveGroundingResults.ts +8 -6
  45. package/src/hooks/useGoodResultsChat.ts +8 -22
  46. package/src/hooks/useImageSearch.ts +290 -12
  47. package/src/hooks/useMachinePartsListingPagination.ts +95 -0
  48. package/src/hooks/useResultEvaluator.ts +43 -19
  49. package/src/index.css +1 -1
  50. package/src/layouts/AppLayout.tsx +43 -3
  51. package/src/pages/Home.tsx +60 -14
  52. package/src/pages/Result.tsx +194 -14
  53. package/src/services/Feedback.ts +223 -35
  54. package/src/services/feedbackSubtypes.ts +63 -0
  55. package/src/services/filter.ts +1 -1
  56. package/src/services/initialMachinePartsText.payload.test.ts +61 -0
  57. package/src/services/initialMachinePartsText.test.ts +33 -0
  58. package/src/services/initialMachinePartsText.ts +11 -0
  59. package/src/services/machinePartsPage.test.ts +74 -0
  60. package/src/services/machinePartsPage.ts +120 -0
  61. package/src/services/normalizeChatResponse.ts +26 -0
  62. package/src/services/scopeLookup.test.ts +67 -0
  63. package/src/services/scopeLookup.ts +39 -0
  64. package/src/services/visualSearch.listingOptions.test.ts +79 -0
  65. package/src/services/visualSearch.ts +71 -3
  66. package/src/services/vizo.ts +4 -3
  67. package/src/stores/machinePartsListing/machinePartsListingStore.ts +35 -0
  68. package/src/stores/request/filter/filter.initialState.ts +1 -0
  69. package/src/stores/request/filter/filter.slice.ts +2 -0
  70. package/src/stores/result/prodcuts/products.initialState.ts +1 -0
  71. package/src/stores/result/prodcuts/products.slice.ts +4 -0
  72. package/src/stores/scope/scopeStore.ts +67 -4
  73. package/src/stores/types.ts +19 -14
  74. package/src/translations.ts +213 -0
  75. package/src/types.ts +40 -0
  76. package/src/utils/fetchProductImage.ts +1 -0
  77. package/src/utils/normalizeChatAssistantResponse.ts +227 -0
  78. package/src/utils/productCardImageUrl.test.ts +41 -0
  79. package/src/utils/productCardImageUrl.ts +49 -0
  80. package/src/utils/textSearchFilter.test.ts +41 -0
  81. package/src/utils/textSearchFilter.ts +13 -6
  82. package/build/static/css/main.81216c5d.css +0 -4
  83. package/build/static/css/main.81216c5d.css.map +0 -1
  84. package/build/static/js/main.93fdbd6e.js +0 -3
  85. /package/build/static/js/{main.93fdbd6e.js.LICENSE.txt → main.2339271b.js.LICENSE.txt} +0 -0
@@ -1,74 +1,262 @@
1
1
  import NyrisAPI, {
2
+ ConversionRevenue,
2
3
  FeedbackEventPayload,
3
4
  NyrisAPISettings,
4
5
  RectCoords,
5
6
  } from '@nyris/nyris-api';
6
7
  import { getNyrisSettings } from 'utils/nyrisApiKey';
8
+ import useResultStore from 'stores/result/resultStore';
9
+ import { CONVERSION_SUBTYPE, normalizeSubtype } from './feedbackSubtypes';
7
10
 
8
- export const feedbackSuccessEpic = async (
9
- requestId: string,
10
- 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,
11
85
  ) => {
12
- const settings = window.settings;
86
+ const data = payload.data as { subtype?: string } | undefined;
87
+ const subtype = normalizeSubtype(data?.subtype);
13
88
 
14
- return await sendFeedbackByApi(settings, undefined, requestId, {
15
- event: 'feedback',
16
- data: { success },
17
- });
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
+ );
18
107
  };
19
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
+
20
204
  export const feedbackClickEpic = async (
21
205
  requestId: string,
22
206
  sessionId: string,
23
207
  position: number,
24
208
  id?: string,
25
- ) => {
26
- const settings = window.settings;
27
- return await sendFeedbackByApi(settings, sessionId, requestId, {
28
- event: 'click',
29
- data: { positions: [position], ...(id ? { product_ids: [id] } : {}) },
30
- });
31
- };
209
+ ) =>
210
+ trackFeedbackEvent(
211
+ {
212
+ event: 'click',
213
+ data: { positions: [position], ...(id ? { product_ids: [id] } : {}) },
214
+ },
215
+ { requestId, sessionId },
216
+ );
32
217
 
33
218
  export const feedbackConversionEpic = async (
34
219
  requestId: string,
35
220
  sessionId: string,
36
221
  position: number,
37
222
  id?: string,
38
- ) => {
39
- const settings = window.settings;
40
- return await sendFeedbackByApi(settings, sessionId, requestId, {
41
- event: 'conversion',
42
- data: { positions: [position], ...(id ? { product_ids: [id] } : {}) },
43
- });
44
- };
223
+ ) =>
224
+ trackConversion(
225
+ undefined,
226
+ { skus: [id], positions: [position] },
227
+ {
228
+ requestId,
229
+ sessionId,
230
+ },
231
+ );
45
232
 
46
233
  export const feedbackRegionEpic = async (
47
234
  requestId: string,
48
235
  sessionId: string,
49
236
  region: RectCoords,
50
237
  ) => {
51
- const settings = window.settings;
52
238
  const { x1, x2, y1, y2 } = region;
53
- const payload: FeedbackEventPayload = {
54
- event: 'region',
55
- data: { rect: { x: x1, y: y1, w: x2 - x1, h: y2 - y1 } },
56
- };
57
- 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
+ );
58
246
  };
59
247
 
248
+ /** Transport. Silently skips when there is no request id to attach to. */
60
249
  export const sendFeedbackByApi = async (
61
250
  settings: NyrisAPISettings,
62
251
  sessionId: string | undefined,
63
252
  requestId: string | undefined,
64
253
  payload: FeedbackEventPayload,
65
254
  ) => {
66
- const api = new NyrisAPI(getNyrisSettings(settings));
67
- if (requestId) {
68
- try {
69
- await api.sendFeedback({ sessionId, requestId, payload }).then(() => {});
70
- } catch (error) {
71
- console.log('error sendFeedbackByApi', error);
72
- }
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);
73
261
  }
74
262
  };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Recommended subtype vocabulary for Feedback API events.
3
+ *
4
+ * Subtypes are free strings on the API side, so customers can add their own,
5
+ * but these constants are the default vocabulary the search suite converges on.
6
+ * Casing is normalized (trimmed + lowercased) before sending; the API does the
7
+ * same at ingestion.
8
+ */
9
+
10
+ export const FEEDBACK_EVENT = {
11
+ CLICK: 'click',
12
+ CONVERSION: 'conversion',
13
+ FEEDBACK: 'feedback',
14
+ REGION: 'region',
15
+ } as const;
16
+
17
+ /** Interaction actions on the result set. */
18
+ export const CLICK_SUBTYPE = {
19
+ RESULT_CARD: 'result_card',
20
+ EXPAND_DETAILS: 'expand_details',
21
+ SIMILAR_PART_SEARCH: 'similar_part_search',
22
+ COPY_DETAILS: 'copy_details',
23
+ RAISE_INQUIRY: 'raise_inquiry',
24
+ FILTER_APPLIED: 'filter_applied',
25
+ PRE_FILTER_APPLIED: 'pre_filter_applied',
26
+ CHAT_WITH_VIZO: 'chat_with_vizo',
27
+ } as const;
28
+
29
+ /** Actions that create business value. Omit subtype for legacy conversions. */
30
+ export const CONVERSION_SUBTYPE = {
31
+ ADD_TO_CART: 'add_to_cart',
32
+ PURCHASE: 'purchase',
33
+ DOWNLOAD_CART: 'download_cart',
34
+ SHARE_CART: 'share_cart',
35
+ } as const;
36
+
37
+ /** Explicit result-quality feedback. */
38
+ export const FEEDBACK_SUBTYPE = {
39
+ THUMBS_UP: 'thumbs_up',
40
+ THUMBS_DOWN: 'thumbs_down',
41
+ } as const;
42
+
43
+ /** How the searched image region was selected. */
44
+ export const REGION_SUBTYPE = {
45
+ CROP: 'crop',
46
+ OBJECT_DETECTION_CLICK: 'object_detection_click',
47
+ } as const;
48
+
49
+ export type FeedbackEventType =
50
+ (typeof FEEDBACK_EVENT)[keyof typeof FEEDBACK_EVENT];
51
+ export type ClickSubtype = (typeof CLICK_SUBTYPE)[keyof typeof CLICK_SUBTYPE];
52
+ export type ConversionSubtype =
53
+ (typeof CONVERSION_SUBTYPE)[keyof typeof CONVERSION_SUBTYPE];
54
+ export type FeedbackSubtype =
55
+ (typeof FEEDBACK_SUBTYPE)[keyof typeof FEEDBACK_SUBTYPE];
56
+ export type RegionSubtype =
57
+ (typeof REGION_SUBTYPE)[keyof typeof REGION_SUBTYPE];
58
+
59
+ /** Normalizes a subtype the same way the API does at ingestion. */
60
+ export const normalizeSubtype = (subtype?: string): string | undefined => {
61
+ const normalized = subtype?.trim().toLowerCase();
62
+ return normalized ? normalized : undefined;
63
+ };
@@ -33,6 +33,6 @@ export const getScopes = async (
33
33
  scopeIds: string,
34
34
  settings: NyrisAPISettings,
35
35
  ): Promise<ScopeResponse> => {
36
- const nyrisApi = new NyrisAPI(settings);
36
+ const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
37
37
  return nyrisApi.getScopes(scopeIds);
38
38
  };
@@ -0,0 +1,61 @@
1
+ import NyrisAPI, { NyrisAPISettings } from '@nyris/nyris-api';
2
+
3
+ function entries(body: FormData) {
4
+ return Array.from(body.entries()).map(([key, value]) => [key, String(value)]);
5
+ }
6
+
7
+ async function capture(
8
+ text: string | undefined,
9
+ scopeIds: string[],
10
+ ) {
11
+ const api = new NyrisAPI({
12
+ apiKey: 'test-key',
13
+ baseUrl: 'https://api.nyris.io',
14
+ jpegQuality: 0.9,
15
+ maxWidth: 1024,
16
+ maxHeight: 1024,
17
+ xOptions: false,
18
+ } as NyrisAPISettings);
19
+ const request = jest.fn().mockResolvedValue({ data: { results: [] } });
20
+ (api as unknown as { httpClient: { request: jest.Mock } }).httpClient = {
21
+ request,
22
+ };
23
+ await api.find(
24
+ text !== undefined ? { text } : {},
25
+ undefined,
26
+ undefined,
27
+ undefined,
28
+ undefined,
29
+ scopeIds,
30
+ );
31
+ return request.mock.calls[0][0] as {
32
+ url: string;
33
+ data: FormData | null;
34
+ };
35
+ }
36
+
37
+ describe('initial machine find payload', () => {
38
+ it('sends the caller-provided item-key batch and scope id', async () => {
39
+ const itemKeys = Array.from(
40
+ { length: 15 },
41
+ (_, index) => `ITEM-${index + 1}`,
42
+ );
43
+ const text = itemKeys.slice(10).join(', ');
44
+ const config = await capture(text, ['TestScope2']);
45
+
46
+ expect(config.url).toBe('https://api.nyris.io/find/v1.1');
47
+ expect(entries(config.data as FormData)).toEqual([
48
+ ['text', text],
49
+ ['scope[0]', 'TestScope2'],
50
+ ]);
51
+ });
52
+
53
+ it('sends a keyword without item keys when a machine scope is active', async () => {
54
+ const config = await capture('pump', ['TestScope2']);
55
+
56
+ expect(entries(config.data as FormData)).toEqual([
57
+ ['text', 'pump'],
58
+ ['scope[0]', 'TestScope2'],
59
+ ]);
60
+ });
61
+ });
@@ -0,0 +1,33 @@
1
+ import { initialMachinePartsText } from './initialMachinePartsText';
2
+
3
+ function keys(count: number) {
4
+ return Array.from({ length: count }, (_, index) => `ITEM-${index + 1}`);
5
+ }
6
+
7
+ describe('initialMachinePartsText', () => {
8
+ it('joins fewer than 20 item keys in order', () => {
9
+ const itemKeys = keys(3);
10
+ expect(initialMachinePartsText(itemKeys)).toBe(
11
+ 'ITEM-1, ITEM-2, ITEM-3',
12
+ );
13
+ expect(itemKeys).toEqual(keys(3));
14
+ });
15
+
16
+ it('joins exactly 20 item keys', () => {
17
+ const text = initialMachinePartsText(keys(20));
18
+ expect(text?.split(', ')).toHaveLength(20);
19
+ expect(text?.endsWith('ITEM-20')).toBe(true);
20
+ });
21
+
22
+ it('stops at the first 20 item keys', () => {
23
+ const itemKeys = keys(21);
24
+ const text = initialMachinePartsText(itemKeys);
25
+ expect(text?.split(', ')).toHaveLength(20);
26
+ expect(text?.includes('ITEM-21')).toBe(false);
27
+ expect(itemKeys).toHaveLength(21);
28
+ });
29
+
30
+ it('returns undefined when there are no item keys', () => {
31
+ expect(initialMachinePartsText([])).toBeUndefined();
32
+ });
33
+ });
@@ -0,0 +1,11 @@
1
+ import {
2
+ DEFAULT_MACHINE_PARTS_PAGE_SIZE,
3
+ machinePartsPageText,
4
+ } from './machinePartsPage';
5
+
6
+ /** First page at the default page size. The source array is not changed. */
7
+ export function initialMachinePartsText(
8
+ itemKeys: readonly string[],
9
+ ): string | undefined {
10
+ return machinePartsPageText(itemKeys, 0, DEFAULT_MACHINE_PARTS_PAGE_SIZE);
11
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ isScopeOnlyMachinePartsListing,
3
+ machinePartsPageCount,
4
+ machinePartsPageItemKeys,
5
+ machinePartsPageText,
6
+ visibleMachinePartsPageIndexes,
7
+ } from './machinePartsPage';
8
+
9
+ function keys(count: number) {
10
+ return Array.from({ length: count }, (_, index) => `ITEM-${index + 1}`);
11
+ }
12
+
13
+ describe('machinePartsPage', () => {
14
+ it('splits 15 item keys into pages of 10 without overlap', () => {
15
+ const itemKeys = keys(15);
16
+ expect(machinePartsPageCount(itemKeys.length, 10)).toBe(2);
17
+ expect(machinePartsPageItemKeys(itemKeys, 0, 10)).toEqual(keys(10));
18
+ expect(machinePartsPageItemKeys(itemKeys, 1, 10)).toEqual(
19
+ keys(15).slice(10),
20
+ );
21
+ expect(machinePartsPageText(itemKeys, 1, 10)).toBe(
22
+ 'ITEM-11, ITEM-12, ITEM-13, ITEM-14, ITEM-15',
23
+ );
24
+ expect(itemKeys).toHaveLength(15);
25
+ });
26
+
27
+ it('counts pages for 611 item keys from the key list, not the response', () => {
28
+ expect(machinePartsPageCount(611, 10)).toBe(62);
29
+ expect(machinePartsPageCount(611, 20)).toBe(31);
30
+ expect(machinePartsPageCount(611, 30)).toBe(21);
31
+ expect(machinePartsPageCount(611, 40)).toBe(16);
32
+ expect(machinePartsPageCount(611, 50)).toBe(13);
33
+
34
+ const itemKeys = keys(611);
35
+ const lastPage = machinePartsPageItemKeys(itemKeys, 30, 20);
36
+ expect(lastPage).toHaveLength(11);
37
+ expect(lastPage[0]).toBe('ITEM-601');
38
+ expect(lastPage[lastPage.length - 1]).toBe('ITEM-611');
39
+ });
40
+
41
+ it('keeps a full last page when the key count divides evenly', () => {
42
+ expect(machinePartsPageCount(10000, 50)).toBe(200);
43
+ expect(machinePartsPageItemKeys(keys(10000), 199, 50)).toHaveLength(50);
44
+ });
45
+
46
+ it('returns no text for an empty page', () => {
47
+ expect(machinePartsPageText(keys(15), 2, 10)).toBeUndefined();
48
+ expect(machinePartsPageCount(0, 20)).toBe(0);
49
+ });
50
+
51
+ it('shows every page when the count fits, otherwise a window', () => {
52
+ expect(visibleMachinePartsPageIndexes(0, 2)).toEqual([0, 1]);
53
+ expect(visibleMachinePartsPageIndexes(0, 62)).toHaveLength(7);
54
+ expect(visibleMachinePartsPageIndexes(61, 62)).toContain(61);
55
+ });
56
+
57
+ it('treats an active machine scope as a listing even if Algolia is on', () => {
58
+ const listing = {
59
+ scopeEnabled: true,
60
+ scopeId: 'TestScope2',
61
+ itemKeyCount: 15,
62
+ keyword: '',
63
+ hasSearchImage: false,
64
+ hasPostFilters: false,
65
+ };
66
+ expect(isScopeOnlyMachinePartsListing(listing)).toBe(true);
67
+ expect(
68
+ isScopeOnlyMachinePartsListing({ ...listing, keyword: 'pump' }),
69
+ ).toBe(false);
70
+ expect(
71
+ isScopeOnlyMachinePartsListing({ ...listing, scopeEnabled: false }),
72
+ ).toBe(false);
73
+ });
74
+ });
@@ -0,0 +1,120 @@
1
+ export const DEFAULT_MACHINE_PARTS_PAGE_SIZE = 20;
2
+
3
+ export function normalizeMachinePartsPageSize(pageSize: number): number {
4
+ if (!Number.isFinite(pageSize) || pageSize <= 0) {
5
+ return DEFAULT_MACHINE_PARTS_PAGE_SIZE;
6
+ }
7
+ return Math.floor(pageSize);
8
+ }
9
+
10
+ /** 0-based page index, matching the existing pagination control. */
11
+ export function normalizeMachinePartsPageIndex(pageIndex: number): number {
12
+ if (!Number.isFinite(pageIndex) || pageIndex <= 0) {
13
+ return 0;
14
+ }
15
+ return Math.floor(pageIndex);
16
+ }
17
+
18
+ export function machinePartsPageCount(
19
+ itemKeyCount: number,
20
+ pageSize: number,
21
+ ): number {
22
+ if (!Number.isFinite(itemKeyCount) || itemKeyCount <= 0) {
23
+ return 0;
24
+ }
25
+ const size = normalizeMachinePartsPageSize(pageSize);
26
+ return Math.ceil(itemKeyCount / size);
27
+ }
28
+
29
+ /** Does not mutate `itemKeys`. */
30
+ export function machinePartsPageItemKeys(
31
+ itemKeys: readonly string[],
32
+ pageIndex: number,
33
+ pageSize: number,
34
+ ): string[] {
35
+ const size = normalizeMachinePartsPageSize(pageSize);
36
+ const start = normalizeMachinePartsPageIndex(pageIndex) * size;
37
+ return itemKeys.slice(start, start + size);
38
+ }
39
+
40
+ export function machinePartsPageText(
41
+ itemKeys: readonly string[],
42
+ pageIndex: number,
43
+ pageSize: number,
44
+ ): string | undefined {
45
+ const text = machinePartsPageItemKeys(itemKeys, pageIndex, pageSize).join(
46
+ ', ',
47
+ );
48
+ if (text.length === 0) {
49
+ return undefined;
50
+ }
51
+ return text;
52
+ }
53
+
54
+ /** Page numbers shown around the current page. Indexes are 0-based. */
55
+ export function visibleMachinePartsPageIndexes(
56
+ pageIndex: number,
57
+ pageCount: number,
58
+ padding = 3,
59
+ ): number[] {
60
+ if (pageCount <= 0) {
61
+ return [];
62
+ }
63
+ const current = Math.min(
64
+ normalizeMachinePartsPageIndex(pageIndex),
65
+ pageCount - 1,
66
+ );
67
+ if (pageCount <= padding * 2 + 1) {
68
+ return Array.from({ length: pageCount }, (_, index) => index);
69
+ }
70
+
71
+ let start = Math.max(0, current - padding);
72
+ let end = Math.min(pageCount - 1, current + padding);
73
+ if (current - padding < 0) {
74
+ end = Math.min(pageCount - 1, end + (padding - current));
75
+ }
76
+ if (current + padding > pageCount - 1) {
77
+ start = Math.max(0, start - (current + padding - (pageCount - 1)));
78
+ }
79
+
80
+ const pages: number[] = [];
81
+ for (let index = start; index <= end; index += 1) {
82
+ pages.push(index);
83
+ }
84
+ return pages;
85
+ }
86
+
87
+ export function hasActivePostFilters(
88
+ selections: Record<string, string[]> | undefined,
89
+ ): boolean {
90
+ return Object.values(selections || {}).some(
91
+ values => Array.isArray(values) && values.length > 0,
92
+ );
93
+ }
94
+
95
+ export function isScopeOnlyMachinePartsListing({
96
+ scopeEnabled,
97
+ scopeId,
98
+ itemKeyCount,
99
+ keyword,
100
+ hasSearchImage,
101
+ hasPostFilters,
102
+ }: {
103
+ scopeEnabled: boolean;
104
+ scopeId: string | null;
105
+ itemKeyCount: number;
106
+ keyword: string;
107
+ hasSearchImage: boolean;
108
+ hasPostFilters: boolean;
109
+ }): boolean {
110
+ if (!scopeEnabled) {
111
+ return false;
112
+ }
113
+ if (!scopeId || itemKeyCount <= 0) {
114
+ return false;
115
+ }
116
+ if (keyword.trim().length > 0 || hasSearchImage || hasPostFilters) {
117
+ return false;
118
+ }
119
+ return true;
120
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The chat API sometimes returns the JSON payload as a string, optionally
3
+ * wrapped in a markdown code fence (```json ... ```). Parse such strings into
4
+ * objects; leave plain-text responses (e.g. request_quality) untouched.
5
+ *
6
+ * A JSON object embedded in surrounding text is left as text. Callers must not
7
+ * treat that leftover text as structured data.
8
+ */
9
+ export function normalizeChatResponse(data: unknown): unknown {
10
+ if (typeof data !== 'string') {
11
+ return data;
12
+ }
13
+
14
+ const fenced = data.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
15
+ const candidate = (fenced ? fenced[1] : data).trim();
16
+
17
+ if (!candidate.startsWith('{') && !candidate.startsWith('[')) {
18
+ return data;
19
+ }
20
+
21
+ try {
22
+ return JSON.parse(candidate);
23
+ } catch {
24
+ return data;
25
+ }
26
+ }