@nyris/nyris-webapp 0.3.98 → 0.4.0
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/build/asset-manifest.json +7 -6
- package/build/index.html +1 -1
- package/build/js/settings.example.js +42 -0
- package/build/static/css/main.a7ff7a7f.css +4 -0
- package/build/static/css/main.a7ff7a7f.css.map +1 -0
- package/build/static/js/main.916c157c.js +3 -0
- package/build/static/js/{main.93fdbd6e.js.map → main.916c157c.js.map} +1 -1
- package/build/static/media/plate_info.6beaea36d35053be3a45.png +0 -0
- package/package.json +3 -3
- package/public/js/settings.example.js +42 -0
- package/src/common/assets/images/plate_info.png +0 -0
- package/src/components/Cart.tsx +41 -0
- package/src/components/ChatAssistant/ChatAssistant.test.tsx +213 -0
- package/src/components/ChatAssistant/ChatAssistant.tsx +18 -1
- package/src/components/ChatAssistant/MobileChatAssistant.tsx +18 -1
- package/src/components/ChatAssistant/OptionChip.tsx +23 -1
- package/src/components/ChatAssistant/useChatAssistantLogic.ts +35 -14
- package/src/components/GoBackButton.tsx +29 -2
- package/src/components/Header.tsx +3 -0
- package/src/components/HitsPerPage.tsx +55 -18
- package/src/components/ImagePreview.tsx +31 -1
- package/src/components/ImageUpload.tsx +12 -1
- package/src/components/Inquiry/InquiryModal.tsx +61 -26
- package/src/components/Pagination.tsx +33 -6
- package/src/components/PostFilter/PostFilter.tsx +8 -1
- package/src/components/PostFilter/PostFilterFindApi.test.tsx +249 -0
- package/src/components/PostFilter/PostFilterFindApi.tsx +128 -52
- package/src/components/PreFilter/PreFilter.tsx +10 -2
- package/src/components/Product/Product.tsx +60 -10
- package/src/components/Product/ProductDetailView.tsx +23 -1
- package/src/components/Product/ProductDetailViewModal.tsx +5 -1
- package/src/components/Product/ProductList.tsx +51 -18
- package/src/components/Rfq/RfqModal.tsx +3 -0
- package/src/components/ScopeSelect/ScopeSelectForm.tsx +514 -0
- package/src/components/ScopeSelect/ScopeSelectImageActions.tsx +62 -0
- package/src/components/ScopeSelect/ScopeSelectImagePreview.tsx +141 -0
- package/src/components/ScopeSelect/ScopeSelectInfo.tsx +68 -0
- package/src/components/ScopeSelect/ScopeSelectInput.tsx +123 -0
- package/src/components/ScopeSelect/ScopeSelectModal.tsx +310 -0
- package/src/components/ScopeSelect/identifyTypePlateImage.test.ts +129 -0
- package/src/components/ScopeSelect/identifyTypePlateImage.ts +83 -0
- package/src/components/ScopeSelect/parseScopeIdResponse.test.ts +103 -0
- package/src/components/ScopeSelect/parseScopeIdResponse.ts +72 -0
- package/src/components/ScopeSelect/scopeSelectHelpers.ts +54 -0
- package/src/components/SidePanel.tsx +33 -0
- package/src/components/TextSearch.tsx +77 -11
- package/src/components/Tooltip/TooltipComponent.tsx +12 -4
- package/src/components/TypePlateUpload.tsx +132 -0
- package/src/hooks/chatProductMetadata.test.ts +307 -0
- package/src/hooks/useBadResultsRecovery.ts +26 -49
- package/src/hooks/useEffectiveGroundingResults.ts +8 -6
- package/src/hooks/useGoodResultsChat.ts +28 -39
- package/src/hooks/useImageSearch.ts +290 -12
- package/src/hooks/useMachinePartsListingPagination.ts +95 -0
- package/src/hooks/useResultEvaluator.ts +63 -73
- package/src/index.css +1 -1
- package/src/layouts/AppLayout.tsx +43 -3
- package/src/pages/Home.tsx +60 -14
- package/src/pages/Result.tsx +194 -14
- package/src/services/Feedback.ts +225 -34
- package/src/services/feedbackSubtypes.ts +63 -0
- package/src/services/filter.ts +1 -1
- package/src/services/initialMachinePartsText.payload.test.ts +61 -0
- package/src/services/initialMachinePartsText.test.ts +33 -0
- package/src/services/initialMachinePartsText.ts +11 -0
- package/src/services/machinePartsPage.test.ts +74 -0
- package/src/services/machinePartsPage.ts +120 -0
- package/src/services/normalizeChatResponse.ts +26 -0
- package/src/services/scopeLookup.test.ts +67 -0
- package/src/services/scopeLookup.ts +39 -0
- package/src/services/visualSearch.listingOptions.test.ts +79 -0
- package/src/services/visualSearch.ts +71 -3
- package/src/services/vizo.ts +4 -3
- package/src/stores/chat/conversationStore.test.ts +133 -0
- package/src/stores/chat/conversationStore.ts +55 -14
- package/src/stores/machinePartsListing/machinePartsListingStore.ts +35 -0
- package/src/stores/request/filter/filter.initialState.ts +1 -0
- package/src/stores/request/filter/filter.slice.ts +2 -0
- package/src/stores/result/prodcuts/products.initialState.ts +1 -0
- package/src/stores/result/prodcuts/products.slice.ts +4 -0
- package/src/stores/scope/scopeStore.ts +67 -4
- package/src/stores/types.ts +19 -14
- package/src/translations.ts +213 -0
- package/src/types.ts +41 -0
- package/src/utils/chatProductMetadata.test.ts +130 -0
- package/src/utils/chatProductMetadata.ts +93 -0
- package/src/utils/compressImage.ts +3 -3
- package/src/utils/fetchProductImage.ts +1 -0
- package/src/utils/normalizeChatAssistantResponse.ts +227 -0
- package/src/utils/productCardImageUrl.test.ts +41 -0
- package/src/utils/productCardImageUrl.ts +49 -0
- package/src/utils/textSearchFilter.test.ts +41 -0
- package/src/utils/textSearchFilter.ts +13 -6
- package/build/static/css/main.81216c5d.css +0 -4
- package/build/static/css/main.81216c5d.css.map +0 -1
- package/build/static/js/main.93fdbd6e.js +0 -3
- /package/build/static/js/{main.93fdbd6e.js.LICENSE.txt → main.916c157c.js.LICENSE.txt} +0 -0
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyris/nyris-webapp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"@auth0/auth0-react": "^2.2.4",
|
|
6
6
|
"@emailjs/browser": "^4.3.3",
|
|
7
7
|
"@google/genai": "^1.46.0",
|
|
8
|
-
"@nyris/nyris-api": "^0.
|
|
9
|
-
"@nyris/nyris-react-components": "^0.
|
|
8
|
+
"@nyris/nyris-api": "^0.4.0",
|
|
9
|
+
"@nyris/nyris-react-components": "^0.4.0",
|
|
10
10
|
"@radix-ui/react-accordion": "^1.2.2",
|
|
11
11
|
"@radix-ui/react-dialog": "^1.1.4",
|
|
12
12
|
"@radix-ui/react-popover": "^1.1.4",
|
|
@@ -49,6 +49,7 @@ var settings = {
|
|
|
49
49
|
fallbackToElasticSearch: false,
|
|
50
50
|
chat: true,
|
|
51
51
|
findRelatedParts: true,
|
|
52
|
+
requestSupport: false,
|
|
52
53
|
},
|
|
53
54
|
// UI - theme
|
|
54
55
|
theme: {
|
|
@@ -105,6 +106,7 @@ var settings = {
|
|
|
105
106
|
},
|
|
106
107
|
showPoweredByNyris: '',
|
|
107
108
|
postFilterOption: '',
|
|
109
|
+
postFilterUseApi: false,
|
|
108
110
|
preFilterOption: '',
|
|
109
111
|
experienceVisualSearch: false,
|
|
110
112
|
experienceVisualSearchImages: [],
|
|
@@ -112,6 +114,46 @@ var settings = {
|
|
|
112
114
|
noSimilarSearch: false,
|
|
113
115
|
useSmartFilters: false,
|
|
114
116
|
showFeedback: false,
|
|
117
|
+
// Feedback API events that should never be sent. Use event types
|
|
118
|
+
// ('click', 'conversion', 'feedback', 'region') or subtypes:
|
|
119
|
+
// click: result_card, expand_details, similar_part_search, copy_details,
|
|
120
|
+
// raise_inquiry, filter_applied, pre_filter_applied, chat_with_vizo
|
|
121
|
+
// conversion: add_to_cart, purchase, download_cart, share_cart
|
|
122
|
+
// feedback: thumbs_up, thumbs_down
|
|
123
|
+
// region: crop, object_detection_click
|
|
124
|
+
// e.g. ['copy_details', 'filter_applied']
|
|
125
|
+
disabledFeedbackEvents: [],
|
|
126
|
+
requestQuality: false,
|
|
127
|
+
scopes: {
|
|
128
|
+
scopeID: false,
|
|
129
|
+
typePlateButtonLabel: 'Scan type plate',
|
|
130
|
+
typePlateStepOne: 'Identify machine',
|
|
131
|
+
typePlateStepTwo: 'Find part',
|
|
132
|
+
typePlatePopupTitle: 'Identify your machine',
|
|
133
|
+
typePlatePopupTitleSuccess: 'Machine identified',
|
|
134
|
+
typePlatePopupCtaButton: 'Search within this machine',
|
|
135
|
+
typePlatePopupCtaTextButton: 'Enter serial number manually',
|
|
136
|
+
typePlatePopupFindMachineButton: 'Find machine',
|
|
137
|
+
typePlatePopupSkipButton: 'Skip and search all parts',
|
|
138
|
+
typePlatePopupBackButton: 'Back to results',
|
|
139
|
+
typePlatePopupUploadButton: 'Upload image',
|
|
140
|
+
typePlatePopupUploadNewButton: 'Upload new type plate',
|
|
141
|
+
typePlatePopupTakePhotoButton: 'Take a photo',
|
|
142
|
+
typePlatePopupApplyingLabel: 'Applying...',
|
|
143
|
+
typePlateResultsScopeLabel:
|
|
144
|
+
'Searching within parts for {{scope_id}}. You can search with keyword or image to find the right item',
|
|
145
|
+
typePlateResultsNoScopeLabel:
|
|
146
|
+
'Scan your type plate to see only parts for your equipment.',
|
|
147
|
+
typePlateResultsClearScopeLabel: 'Search all products',
|
|
148
|
+
typePlatePopupErrorMessage:
|
|
149
|
+
'The serial number was not found. Please check again.',
|
|
150
|
+
typePlateManualInputLabel: "Can't scan? Enter serial number manually.",
|
|
151
|
+
typePlateManualInputPlaceholder: 'e.g. 2234GH',
|
|
152
|
+
typePlateWhereToFindTitle: 'Where to find it',
|
|
153
|
+
typePlateWhereToFindDescription:
|
|
154
|
+
'A metal or foil label on the machine housing, usually near the motor or the main panel. Make sure the serial number is sharp and readable.',
|
|
155
|
+
typePlateSerialNumberLabel: 'Serial number',
|
|
156
|
+
},
|
|
115
157
|
geoLocation: false,
|
|
116
158
|
geoLocationMessage: '',
|
|
117
159
|
showImageDetails: false,
|
|
Binary file
|
package/src/components/Cart.tsx
CHANGED
|
@@ -8,6 +8,9 @@ import { Send } from 'lucide-react';
|
|
|
8
8
|
import emailjs from '@emailjs/browser';
|
|
9
9
|
import { useAuth0 } from '@auth0/auth0-react';
|
|
10
10
|
import NoImage from '../common/assets/images/no-image.svg';
|
|
11
|
+
import { trackConversion } from 'services/Feedback';
|
|
12
|
+
import { CONVERSION_SUBTYPE } from 'services/feedbackSubtypes';
|
|
13
|
+
import useResultStore from 'stores/result/resultStore';
|
|
11
14
|
|
|
12
15
|
const EMAILJS_SERVICE_ID = 'service_zfsxshi';
|
|
13
16
|
const EMAILJS_TEMPLATE_ID = window.settings.cart?.emailTemplateId || '';
|
|
@@ -102,6 +105,42 @@ const Cart = () => {
|
|
|
102
105
|
const getQuantityDisplayValue = (sku: string, quantity: number) =>
|
|
103
106
|
quantityEdits[sku] ?? String(quantity || 1);
|
|
104
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Sends one conversion per originating search request. Items remember the
|
|
110
|
+
* request they were added from; items without one fall back to the current
|
|
111
|
+
* request id.
|
|
112
|
+
*/
|
|
113
|
+
const trackCartConversion = (subtype: string) => {
|
|
114
|
+
const current = useResultStore.getState();
|
|
115
|
+
const groups = new Map<string, { sessionId?: string; parts: any[] }>();
|
|
116
|
+
|
|
117
|
+
cartParts.forEach((part: any) => {
|
|
118
|
+
const requestId = part.feedbackRequestId || current.requestId;
|
|
119
|
+
if (!requestId) return;
|
|
120
|
+
const group = groups.get(requestId) || {
|
|
121
|
+
sessionId: part.feedbackSessionId || current.sessionId,
|
|
122
|
+
parts: [] as any[],
|
|
123
|
+
};
|
|
124
|
+
group.parts.push(part);
|
|
125
|
+
groups.set(requestId, group);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
groups.forEach(({ sessionId, parts }, requestId) => {
|
|
129
|
+
trackConversion(
|
|
130
|
+
subtype,
|
|
131
|
+
{
|
|
132
|
+
skus: parts.map(part => part.sku),
|
|
133
|
+
positions: parts.map(part => part.feedbackPosition),
|
|
134
|
+
quantity: parts.reduce(
|
|
135
|
+
(total, part) => total + (part.quantity || 1),
|
|
136
|
+
0,
|
|
137
|
+
),
|
|
138
|
+
},
|
|
139
|
+
{ requestId, sessionId },
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
105
144
|
const generateCSVContent = (): string => {
|
|
106
145
|
const rows = [['Product Sku', 'Product Name', 'Quantity']];
|
|
107
146
|
cartParts.forEach((part: any) => {
|
|
@@ -124,6 +163,7 @@ const Cart = () => {
|
|
|
124
163
|
// clearSessionProducts();
|
|
125
164
|
|
|
126
165
|
toast.success('Your data has been downloaded.');
|
|
166
|
+
trackCartConversion(CONVERSION_SUBTYPE.DOWNLOAD_CART);
|
|
127
167
|
};
|
|
128
168
|
|
|
129
169
|
const sendCSVByEmail = async (
|
|
@@ -159,6 +199,7 @@ const Cart = () => {
|
|
|
159
199
|
);
|
|
160
200
|
|
|
161
201
|
toast.success('Parts list has been sent by email.');
|
|
202
|
+
trackCartConversion(CONVERSION_SUBTYPE.SHARE_CART);
|
|
162
203
|
setIsEmailInputVisible(false);
|
|
163
204
|
setUserEmail('');
|
|
164
205
|
} catch (error) {
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fireEvent,
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
waitFor,
|
|
6
|
+
within,
|
|
7
|
+
} from '@testing-library/react';
|
|
8
|
+
import useConversationStore from 'stores/chat/conversationStore';
|
|
9
|
+
import ChatAssistant from './ChatAssistant';
|
|
10
|
+
import MobileChatAssistant from './MobileChatAssistant';
|
|
11
|
+
|
|
12
|
+
const mockSendMessage = jest.fn();
|
|
13
|
+
const mockProducts = {
|
|
14
|
+
productsFromAlgolia: [],
|
|
15
|
+
productsFromFindApi: [],
|
|
16
|
+
firstSearchResults: [],
|
|
17
|
+
imageAnalysis: {} as any,
|
|
18
|
+
setFindApiProducts: jest.fn(),
|
|
19
|
+
setFullResultPool: jest.fn(),
|
|
20
|
+
};
|
|
21
|
+
const mockRequest = { requestImages: [], regions: [] };
|
|
22
|
+
|
|
23
|
+
jest.mock('hooks/useGoodResultsChat', () => ({
|
|
24
|
+
useGoodResultsChat: () => ({ sendMessage: mockSendMessage }),
|
|
25
|
+
}));
|
|
26
|
+
jest.mock('hooks/useBadResultsRecovery', () => ({
|
|
27
|
+
useBadResultsRecovery: () => ({ sendMessage: mockSendMessage }),
|
|
28
|
+
}));
|
|
29
|
+
jest.mock('services/vizo', () => ({ chatAnalysis: jest.fn() }));
|
|
30
|
+
jest.mock('utils/compressImage', () => ({ compressImage: jest.fn() }));
|
|
31
|
+
jest.mock('utils/relatedParts', () => ({
|
|
32
|
+
loadRelatedPartsTable: jest.fn(),
|
|
33
|
+
lookupRelatedParts: jest.fn(),
|
|
34
|
+
}));
|
|
35
|
+
jest.mock('utils/fetchProductImage', () => ({
|
|
36
|
+
fetchProductImageBase64: jest.fn(),
|
|
37
|
+
getProductImageUrl: jest.fn(),
|
|
38
|
+
}));
|
|
39
|
+
jest.mock('utils/nyrisApiKey', () => ({ getEffectiveApiKey: jest.fn() }));
|
|
40
|
+
jest.mock('stores/result/resultStore', () => ({
|
|
41
|
+
__esModule: true,
|
|
42
|
+
default: Object.assign((selector: any) => selector(mockProducts), {
|
|
43
|
+
getState: () => mockProducts,
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
jest.mock('stores/request/requestStore', () => ({
|
|
47
|
+
__esModule: true,
|
|
48
|
+
default: Object.assign((selector: any) => selector(mockRequest), {
|
|
49
|
+
getState: () => mockRequest,
|
|
50
|
+
}),
|
|
51
|
+
}));
|
|
52
|
+
jest.mock('stores/ui/uiStore', () => ({
|
|
53
|
+
__esModule: true,
|
|
54
|
+
default: (selector: any) =>
|
|
55
|
+
selector({
|
|
56
|
+
toggleAiMode: jest.fn(),
|
|
57
|
+
setIsAiModeOpen: jest.fn(),
|
|
58
|
+
}),
|
|
59
|
+
}));
|
|
60
|
+
jest.mock('@nyris/nyris-react-components', () => ({ Icon: () => null }));
|
|
61
|
+
jest.mock('react-i18next', () => ({
|
|
62
|
+
useTranslation: () => ({ t: (value: string) => value }),
|
|
63
|
+
}));
|
|
64
|
+
jest.mock('components/Tooltip/TooltipComponent', () => ({
|
|
65
|
+
__esModule: true,
|
|
66
|
+
default: ({ children }: any) => children,
|
|
67
|
+
}));
|
|
68
|
+
jest.mock('components/CustomCameraDrawer', () => ({
|
|
69
|
+
__esModule: true,
|
|
70
|
+
default: () => null,
|
|
71
|
+
}));
|
|
72
|
+
jest.mock('components/ImageUpload', () => ({
|
|
73
|
+
__esModule: true,
|
|
74
|
+
default: () => null,
|
|
75
|
+
}));
|
|
76
|
+
jest.mock('components/Inquiry/InquiryModal', () => ({
|
|
77
|
+
__esModule: true,
|
|
78
|
+
default: ({ setIsInquiryModalOpen }: any) => (
|
|
79
|
+
<div role="dialog" aria-label="Support inquiry">
|
|
80
|
+
<button onClick={() => setIsInquiryModalOpen(false)}>
|
|
81
|
+
Close inquiry
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
),
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
jest.clearAllMocks();
|
|
89
|
+
HTMLElement.prototype.scrollIntoView = jest.fn();
|
|
90
|
+
window.settings = {
|
|
91
|
+
algolia: { enabled: false },
|
|
92
|
+
vizo: { findRelatedParts: false, requestSupport: true },
|
|
93
|
+
support: { emailInquiry: false, emailTemplateId: '' },
|
|
94
|
+
} as typeof window.settings;
|
|
95
|
+
mockProducts.firstSearchResults = [];
|
|
96
|
+
mockProducts.imageAnalysis = {};
|
|
97
|
+
useConversationStore.getState().reset();
|
|
98
|
+
mockSendMessage.mockImplementation(async (content: string) => {
|
|
99
|
+
const store = useConversationStore.getState();
|
|
100
|
+
store.addMessage({ role: 'user', content });
|
|
101
|
+
store.setIsLoading(true);
|
|
102
|
+
store.addMessage(
|
|
103
|
+
{
|
|
104
|
+
role: 'assistant',
|
|
105
|
+
content: 'Let us keep looking.',
|
|
106
|
+
options: [{ label: 'Continue search', action_type: 'filter' }],
|
|
107
|
+
},
|
|
108
|
+
{ isApiResponse: true },
|
|
109
|
+
);
|
|
110
|
+
store.setIsLoading(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test.each([
|
|
115
|
+
{ name: 'desktop', Component: ChatAssistant },
|
|
116
|
+
{ name: 'mobile', Component: MobileChatAssistant },
|
|
117
|
+
])(
|
|
118
|
+
'$name offers an inquiry without blocking continued chat',
|
|
119
|
+
async ({ Component }) => {
|
|
120
|
+
render(<Component />);
|
|
121
|
+
const input = screen.getByRole('textbox');
|
|
122
|
+
const sendMessage = async () => {
|
|
123
|
+
fireEvent.change(input, { target: { value: 'Still looking' } });
|
|
124
|
+
// eslint-disable-next-line testing-library/no-node-access
|
|
125
|
+
fireEvent.submit(input.closest('form')!);
|
|
126
|
+
await waitFor(() => expect(input).toHaveValue(''));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
await sendMessage();
|
|
130
|
+
await sendMessage();
|
|
131
|
+
expect(
|
|
132
|
+
screen.queryByRole('button', { name: 'Request support' }),
|
|
133
|
+
).toBeNull();
|
|
134
|
+
await sendMessage();
|
|
135
|
+
|
|
136
|
+
const supportButton = await screen.findByRole('button', {
|
|
137
|
+
name: 'Request support',
|
|
138
|
+
});
|
|
139
|
+
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenLastCalledWith({
|
|
140
|
+
behavior: 'auto',
|
|
141
|
+
block: 'end',
|
|
142
|
+
});
|
|
143
|
+
const supportSection = screen.getByRole('complementary', {
|
|
144
|
+
name: 'Human support',
|
|
145
|
+
});
|
|
146
|
+
expect(
|
|
147
|
+
within(supportSection).getByText(
|
|
148
|
+
'Still not finding it? Our team can help. Send us a quick email',
|
|
149
|
+
),
|
|
150
|
+
).toBeInTheDocument();
|
|
151
|
+
expect(
|
|
152
|
+
within(supportSection).getByRole('button', { name: 'Request support' }),
|
|
153
|
+
).toBe(supportButton);
|
|
154
|
+
const replyButtons = screen.getAllByRole('button', {
|
|
155
|
+
name: 'Continue search',
|
|
156
|
+
});
|
|
157
|
+
expect(replyButtons[replyButtons.length - 1]).toBeEnabled();
|
|
158
|
+
fireEvent.click(supportButton);
|
|
159
|
+
expect(
|
|
160
|
+
screen.getByRole('dialog', { name: 'Support inquiry' }),
|
|
161
|
+
).toBeInTheDocument();
|
|
162
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close inquiry' }));
|
|
163
|
+
|
|
164
|
+
await sendMessage();
|
|
165
|
+
expect(mockSendMessage).toHaveBeenCalledTimes(4);
|
|
166
|
+
expect(
|
|
167
|
+
screen.getAllByRole('button', { name: 'Request support' }),
|
|
168
|
+
).toHaveLength(1);
|
|
169
|
+
expect(supportButton).toBeEnabled();
|
|
170
|
+
fireEvent.click(supportButton);
|
|
171
|
+
expect(
|
|
172
|
+
screen.getByRole('dialog', { name: 'Support inquiry' }),
|
|
173
|
+
).toBeInTheDocument();
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
test('offers related parts again after restoring original results when enabled', async () => {
|
|
178
|
+
const originalResults: any = [{ id: 'original-result' }];
|
|
179
|
+
mockProducts.firstSearchResults = originalResults;
|
|
180
|
+
mockProducts.imageAnalysis = {
|
|
181
|
+
specification: { product_category: 'pump' },
|
|
182
|
+
};
|
|
183
|
+
window.settings.vizo!.findRelatedParts = true;
|
|
184
|
+
|
|
185
|
+
const store = useConversationStore.getState();
|
|
186
|
+
store.setRelatedPartsUsed(true);
|
|
187
|
+
store.setRelatedPartsTableSuggestions([
|
|
188
|
+
{ label: 'Motor', description: 'Replacement motor' },
|
|
189
|
+
]);
|
|
190
|
+
store.addMessage({
|
|
191
|
+
role: 'assistant',
|
|
192
|
+
content: 'Filtered results',
|
|
193
|
+
options: [
|
|
194
|
+
{
|
|
195
|
+
label: 'Go back to original results',
|
|
196
|
+
action_type: 'restore_original',
|
|
197
|
+
value: 'restore',
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
render(<ChatAssistant />);
|
|
203
|
+
fireEvent.click(
|
|
204
|
+
screen.getByRole('button', { name: 'Go back to original results' }),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(
|
|
208
|
+
await screen.findByRole('button', {
|
|
209
|
+
name: 'Find parts related to pump',
|
|
210
|
+
}),
|
|
211
|
+
).toBeInTheDocument();
|
|
212
|
+
expect(useConversationStore.getState().relatedPartsUsed).toBe(false);
|
|
213
|
+
});
|
|
@@ -7,6 +7,7 @@ import { Icon } from '@nyris/nyris-react-components';
|
|
|
7
7
|
import useUiStore from 'stores/ui/uiStore';
|
|
8
8
|
import OptionChip, { ChatOption } from './OptionChip';
|
|
9
9
|
import { useChatAssistantLogic } from './useChatAssistantLogic';
|
|
10
|
+
import InquiryModal from 'components/Inquiry/InquiryModal';
|
|
10
11
|
|
|
11
12
|
function ChatAssistant() {
|
|
12
13
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -26,8 +27,14 @@ function ChatAssistant() {
|
|
|
26
27
|
handleSendMessage,
|
|
27
28
|
handleOptionClick,
|
|
28
29
|
handleImageUpload,
|
|
30
|
+
isSupportInquiryOpen,
|
|
31
|
+
setIsSupportInquiryOpen,
|
|
32
|
+
requestImage,
|
|
33
|
+
selectedRegion,
|
|
29
34
|
} = useChatAssistantLogic();
|
|
30
35
|
|
|
36
|
+
const latestChatMessageId = messages[messages.length - 1]?.id;
|
|
37
|
+
|
|
31
38
|
const setIsOpen = useConversationStore(s => s.setIsOpen);
|
|
32
39
|
|
|
33
40
|
const toggleAiMode = useUiStore(state => state.toggleAiMode);
|
|
@@ -56,6 +63,15 @@ function ChatAssistant() {
|
|
|
56
63
|
'bg-[radial-gradient(ellipse_at_70%_7%,_#F3F4F8_50%,_#E4E3FF_100%)]',
|
|
57
64
|
])}
|
|
58
65
|
>
|
|
66
|
+
{isSupportInquiryOpen && (
|
|
67
|
+
<InquiryModal
|
|
68
|
+
requestImage={requestImage}
|
|
69
|
+
selectedRegion={selectedRegion}
|
|
70
|
+
isInquiryModalOpen={isSupportInquiryOpen}
|
|
71
|
+
setIsInquiryModalOpen={setIsSupportInquiryOpen}
|
|
72
|
+
allowEmailFallback
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
59
75
|
<div
|
|
60
76
|
className="absolute top-3 right-4 bg-[#FAFAFA] w-7 h-7 flex justify-center items-center rounded-lg cursor-pointer"
|
|
61
77
|
onClick={() => {
|
|
@@ -144,7 +160,8 @@ function ChatAssistant() {
|
|
|
144
160
|
option={option}
|
|
145
161
|
onClick={wrappedHandleOptionClick}
|
|
146
162
|
disabled={
|
|
147
|
-
|
|
163
|
+
option.action_type !== 'request_support' &&
|
|
164
|
+
(isLoading || msg.id !== latestChatMessageId)
|
|
148
165
|
}
|
|
149
166
|
/>
|
|
150
167
|
))}
|
|
@@ -9,6 +9,7 @@ import CustomCamera from 'components/CustomCameraDrawer';
|
|
|
9
9
|
import ImageUpload from 'components/ImageUpload';
|
|
10
10
|
import OptionChip, { ChatOption } from './OptionChip';
|
|
11
11
|
import { useChatAssistantLogic } from './useChatAssistantLogic';
|
|
12
|
+
import InquiryModal from 'components/Inquiry/InquiryModal';
|
|
12
13
|
|
|
13
14
|
function MobileChatAssistant() {
|
|
14
15
|
const { t } = useTranslation();
|
|
@@ -30,8 +31,14 @@ function MobileChatAssistant() {
|
|
|
30
31
|
handleSendMessage,
|
|
31
32
|
handleOptionClick,
|
|
32
33
|
handleImageUpload,
|
|
34
|
+
isSupportInquiryOpen,
|
|
35
|
+
setIsSupportInquiryOpen,
|
|
36
|
+
requestImage,
|
|
37
|
+
selectedRegion,
|
|
33
38
|
} = useChatAssistantLogic();
|
|
34
39
|
|
|
40
|
+
const latestChatMessageId = messages[messages.length - 1]?.id;
|
|
41
|
+
|
|
35
42
|
const setIsAiModeOpen = useUiStore(state => state.setIsAiModeOpen);
|
|
36
43
|
|
|
37
44
|
const [isOpenModalCamera, setOpenModalCamera] = useState<boolean>(false);
|
|
@@ -52,6 +59,15 @@ function MobileChatAssistant() {
|
|
|
52
59
|
|
|
53
60
|
return (
|
|
54
61
|
<div className="fixed inset-x-0 bottom-0 z-[50] flex flex-col justify-end max-h-[50vh]">
|
|
62
|
+
{isSupportInquiryOpen && (
|
|
63
|
+
<InquiryModal
|
|
64
|
+
requestImage={requestImage}
|
|
65
|
+
selectedRegion={selectedRegion}
|
|
66
|
+
isInquiryModalOpen={isSupportInquiryOpen}
|
|
67
|
+
setIsInquiryModalOpen={setIsSupportInquiryOpen}
|
|
68
|
+
allowEmailFallback
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
55
71
|
{/* Chat container — anchored to bottom, content-based height, max 50vh */}
|
|
56
72
|
<div
|
|
57
73
|
className={twMerge(
|
|
@@ -143,7 +159,8 @@ function MobileChatAssistant() {
|
|
|
143
159
|
option={option}
|
|
144
160
|
onClick={wrappedHandleOptionClick}
|
|
145
161
|
disabled={
|
|
146
|
-
|
|
162
|
+
option.action_type !== 'request_support' &&
|
|
163
|
+
(isLoading || msg.id !== latestChatMessageId)
|
|
147
164
|
}
|
|
148
165
|
/>
|
|
149
166
|
))}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RotateCcw, Search, Wrench } from 'lucide-react';
|
|
1
|
+
import { Mail, RotateCcw, Search, Wrench } from 'lucide-react';
|
|
2
2
|
import { twMerge } from 'tailwind-merge';
|
|
3
3
|
|
|
4
4
|
import Tooltip from 'components/Tooltip/TooltipComponent';
|
|
@@ -27,6 +27,28 @@ function OptionChip({ option, onClick, disabled = false }: OptionChipProps) {
|
|
|
27
27
|
const displayText =
|
|
28
28
|
isFilter && option.value?.trim() ? option.value : option.label;
|
|
29
29
|
|
|
30
|
+
if (option.action_type === 'request_support') {
|
|
31
|
+
return (
|
|
32
|
+
<aside
|
|
33
|
+
aria-label="Human support"
|
|
34
|
+
className="w-full mt-2 border-t border-solid border-[#B9D5CE] pt-3"
|
|
35
|
+
>
|
|
36
|
+
<p className="text-sm leading-5 text-[#27443D] mb-3">
|
|
37
|
+
Still not finding it? Our team can help. Send us a quick email
|
|
38
|
+
</p>
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={() => onClick(option)}
|
|
42
|
+
disabled={disabled}
|
|
43
|
+
className="inline-flex items-center justify-center gap-2 min-h-[36px] px-3 py-2 rounded-md bg-[#0F766E] text-white text-xs font-semibold hover:bg-[#115E59] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0F766E] disabled:opacity-50 disabled:cursor-not-allowed"
|
|
44
|
+
>
|
|
45
|
+
<Mail size={14} className="shrink-0" aria-hidden="true" />
|
|
46
|
+
{displayText}
|
|
47
|
+
</button>
|
|
48
|
+
</aside>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
return (
|
|
31
53
|
<Tooltip content={displayText} disabled={displayText.length <= 32}>
|
|
32
54
|
<button
|
|
@@ -6,6 +6,7 @@ import useRequestStore from 'stores/request/requestStore';
|
|
|
6
6
|
import { useGoodResultsChat } from 'hooks/useGoodResultsChat';
|
|
7
7
|
import { useBadResultsRecovery } from 'hooks/useBadResultsRecovery';
|
|
8
8
|
import { chatAnalysis } from 'services/vizo';
|
|
9
|
+
import { getChatProductMetadata } from 'utils/chatProductMetadata';
|
|
9
10
|
import { compressImage } from 'utils/compressImage';
|
|
10
11
|
import { loadRelatedPartsTable, lookupRelatedParts } from 'utils/relatedParts';
|
|
11
12
|
import {
|
|
@@ -20,6 +21,7 @@ export function useChatAssistantLogic() {
|
|
|
20
21
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
21
22
|
|
|
22
23
|
const [findingRelatedParts, setFindingRelatedParts] = useState(false);
|
|
24
|
+
const [isSupportInquiryOpen, setIsSupportInquiryOpen] = useState(false);
|
|
23
25
|
|
|
24
26
|
// Conversation store
|
|
25
27
|
const isEvaluated = useConversationStore(s => s.isEvaluated);
|
|
@@ -27,6 +29,7 @@ export function useChatAssistantLogic() {
|
|
|
27
29
|
const messages = useConversationStore(s => s.messages);
|
|
28
30
|
const isLoading = useConversationStore(s => s.isLoading);
|
|
29
31
|
const error = useConversationStore(s => s.error);
|
|
32
|
+
const sessionId = useConversationStore(s => s.sessionId);
|
|
30
33
|
const catalogSearchReady = useConversationStore(s => s.catalogSearchReady);
|
|
31
34
|
const setCatalogSearchReady = useConversationStore(
|
|
32
35
|
s => s.setCatalogSearchReady,
|
|
@@ -54,6 +57,7 @@ export function useChatAssistantLogic() {
|
|
|
54
57
|
|
|
55
58
|
// Request store
|
|
56
59
|
const requestImages = useRequestStore(state => state.requestImages);
|
|
60
|
+
const regions = useRequestStore(state => state.regions);
|
|
57
61
|
|
|
58
62
|
const p2TriggeredRef = useRef(false);
|
|
59
63
|
|
|
@@ -69,6 +73,10 @@ export function useChatAssistantLogic() {
|
|
|
69
73
|
// Catalog search state
|
|
70
74
|
const [catalogSearching, setCatalogSearching] = useState(false);
|
|
71
75
|
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
setIsSupportInquiryOpen(false);
|
|
78
|
+
}, [sessionId]);
|
|
79
|
+
|
|
72
80
|
// Helper to get the correct product setter
|
|
73
81
|
const getSetProducts = useCallback(() => {
|
|
74
82
|
return setFindApiProducts;
|
|
@@ -100,7 +108,7 @@ export function useChatAssistantLogic() {
|
|
|
100
108
|
|
|
101
109
|
// Auto-scroll
|
|
102
110
|
useEffect(() => {
|
|
103
|
-
messagesEndRef.current?.scrollIntoView({ behavior: '
|
|
111
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' });
|
|
104
112
|
}, [messages]);
|
|
105
113
|
|
|
106
114
|
// ── Handlers ──
|
|
@@ -131,6 +139,11 @@ export function useChatAssistantLogic() {
|
|
|
131
139
|
|
|
132
140
|
const handleOptionClick = useCallback(
|
|
133
141
|
async (option: any) => {
|
|
142
|
+
if (option.action_type === 'request_support') {
|
|
143
|
+
setIsSupportInquiryOpen(true);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
134
147
|
if (option.action_type === 'text_search_prompt') {
|
|
135
148
|
setInputValue('');
|
|
136
149
|
const store = useConversationStore.getState();
|
|
@@ -187,9 +200,23 @@ export function useChatAssistantLogic() {
|
|
|
187
200
|
setProducts(restoreResults);
|
|
188
201
|
setFullResultPool(restoreResults);
|
|
189
202
|
store.setPreCatalogResults(null);
|
|
203
|
+
store.setRelatedPartsUsed(false);
|
|
204
|
+
const relatedPartsOption =
|
|
205
|
+
window.settings.vizo?.findRelatedParts &&
|
|
206
|
+
store.relatedPartsTableSuggestions.length > 0
|
|
207
|
+
? [
|
|
208
|
+
{
|
|
209
|
+
label: `Find parts related to ${imageAnalysis?.specification?.product_category || 'request image'}`,
|
|
210
|
+
action_type: 'find_related_parts' as const,
|
|
211
|
+
key: 'find_related',
|
|
212
|
+
value: 'find_related_parts',
|
|
213
|
+
},
|
|
214
|
+
]
|
|
215
|
+
: [];
|
|
190
216
|
store.addMessage({
|
|
191
217
|
role: 'assistant',
|
|
192
218
|
content: `Restored your original ${restoreResults.length} result${restoreResults.length === 1 ? '' : 's'} from the initial image search. Feel free to keep filtering or run a new catalog search.`,
|
|
219
|
+
options: relatedPartsOption,
|
|
193
220
|
});
|
|
194
221
|
} else {
|
|
195
222
|
store.addMessage({
|
|
@@ -300,12 +327,7 @@ export function useChatAssistantLogic() {
|
|
|
300
327
|
const visibleProducts = currentProducts.slice(0, 30);
|
|
301
328
|
const productList = visibleProducts.map((p: any, i: number) => ({
|
|
302
329
|
rank: i + 1,
|
|
303
|
-
|
|
304
|
-
title: p.title || p.name || 'Untitled',
|
|
305
|
-
brand: p.brand || '',
|
|
306
|
-
description: (p.description || '').substring(0, 150),
|
|
307
|
-
categories: p.categories || [],
|
|
308
|
-
mpn: p.customIds?.mpn || p.ids?.mpn || '',
|
|
330
|
+
metadata: getChatProductMetadata(p),
|
|
309
331
|
}));
|
|
310
332
|
|
|
311
333
|
const productImages = await Promise.all(
|
|
@@ -350,17 +372,12 @@ export function useChatAssistantLogic() {
|
|
|
350
372
|
${productContextNotice}
|
|
351
373
|
|
|
352
374
|
PRODUCT LIST:
|
|
353
|
-
Each result includes a
|
|
375
|
+
Each JSON result includes a 1-based rank and complete product metadata, including identifiers when available. Use the result thumbnails and metadata together when deciding which current results match the uploaded image. Return matched_indices as 1-based rank values from this list.
|
|
354
376
|
`,
|
|
355
377
|
});
|
|
356
378
|
|
|
357
379
|
productList.forEach((product, index) => {
|
|
358
|
-
const
|
|
359
|
-
const tags = currentProduct?._tags;
|
|
360
|
-
const tagStr = tags
|
|
361
|
-
? ` | TAGS: type=${tags.productType}, color=${tags.color}, shape=${tags.shape}, material=${tags.material}`
|
|
362
|
-
: '';
|
|
363
|
-
const metadata = `${product.rank}. [${product.sku}] ${product.title} | brand: ${product.brand} | mpn: ${product.mpn} | categories: ${JSON.stringify(product.categories)} | desc: ${product.description}${tagStr}`;
|
|
380
|
+
const metadata = JSON.stringify(product);
|
|
364
381
|
|
|
365
382
|
if (hasVisualContext && productImages[index]?.base64) {
|
|
366
383
|
parts.push({
|
|
@@ -730,6 +747,10 @@ Each result includes a unique SKU in brackets. Use the result thumbnails and met
|
|
|
730
747
|
activeProducts,
|
|
731
748
|
productsFromAlgolia,
|
|
732
749
|
findingRelatedParts,
|
|
750
|
+
isSupportInquiryOpen,
|
|
751
|
+
setIsSupportInquiryOpen,
|
|
752
|
+
requestImage: requestImages[0],
|
|
753
|
+
selectedRegion: regions[0],
|
|
733
754
|
|
|
734
755
|
// Refs
|
|
735
756
|
messagesEndRef,
|