@nyris/nyris-webapp 0.3.99 → 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 +6 -6
- package/build/index.html +1 -1
- package/build/js/settings.example.js +3 -1
- 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.2339271b.js.map → main.916c157c.js.map} +1 -1
- package/package.json +3 -3
- package/public/js/settings.example.js +3 -1
- 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/Inquiry/InquiryModal.tsx +58 -26
- package/src/hooks/chatProductMetadata.test.ts +307 -0
- package/src/hooks/useBadResultsRecovery.ts +26 -49
- package/src/hooks/useGoodResultsChat.ts +20 -17
- package/src/hooks/useResultEvaluator.ts +21 -55
- package/src/services/Feedback.ts +5 -2
- package/src/stores/chat/conversationStore.test.ts +133 -0
- package/src/stores/chat/conversationStore.ts +55 -14
- package/src/types.ts +1 -0
- package/src/utils/chatProductMetadata.test.ts +130 -0
- package/src/utils/chatProductMetadata.ts +93 -0
- package/src/utils/compressImage.ts +3 -3
- package/build/static/css/main.5cb72c09.css +0 -4
- package/build/static/css/main.5cb72c09.css.map +0 -1
- package/build/static/js/main.2339271b.js +0 -3
- /package/build/static/js/{main.2339271b.js.LICENSE.txt → main.916c157c.js.LICENSE.txt} +0 -0
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: {
|
|
@@ -144,7 +145,8 @@ var settings = {
|
|
|
144
145
|
typePlateResultsNoScopeLabel:
|
|
145
146
|
'Scan your type plate to see only parts for your equipment.',
|
|
146
147
|
typePlateResultsClearScopeLabel: 'Search all products',
|
|
147
|
-
typePlatePopupErrorMessage:
|
|
148
|
+
typePlatePopupErrorMessage:
|
|
149
|
+
'The serial number was not found. Please check again.',
|
|
148
150
|
typePlateManualInputLabel: "Can't scan? Enter serial number manually.",
|
|
149
151
|
typePlateManualInputPlaceholder: 'e.g. 2234GH',
|
|
150
152
|
typePlateWhereToFindTitle: 'Where to find it',
|
|
@@ -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,
|
|
@@ -8,7 +8,7 @@ import { Icon } from '@nyris/nyris-react-components';
|
|
|
8
8
|
import useRequestStore from 'stores/request/requestStore';
|
|
9
9
|
import { getCroppedCanvas } from 'utils/misc';
|
|
10
10
|
import { ToastHelper } from 'helpers/ToastHelper';
|
|
11
|
-
import { Dialog, DialogContent } from 'components/Modal/Dialog';
|
|
11
|
+
import { Dialog, DialogContent, DialogTitle } from 'components/Modal/Dialog';
|
|
12
12
|
import { AutosizeTextarea } from 'components/AutosizeTextArea';
|
|
13
13
|
import Tooltip from 'components/Tooltip/TooltipComponent';
|
|
14
14
|
|
|
@@ -21,6 +21,7 @@ interface Props {
|
|
|
21
21
|
selectedRegion: any;
|
|
22
22
|
setIsInquiryModalOpen: any;
|
|
23
23
|
isInquiryModalOpen?: any;
|
|
24
|
+
allowEmailFallback?: boolean;
|
|
24
25
|
}
|
|
25
26
|
// eslint-disable-next-line
|
|
26
27
|
const emailRegex = /.+\@.+\..+$/;
|
|
@@ -44,6 +45,7 @@ export default function InquiryModal({
|
|
|
44
45
|
selectedRegion,
|
|
45
46
|
setIsInquiryModalOpen,
|
|
46
47
|
isInquiryModalOpen,
|
|
48
|
+
allowEmailFallback = false,
|
|
47
49
|
}: Props) {
|
|
48
50
|
const settings = window.settings;
|
|
49
51
|
const preFilter = useRequestStore(state => state.preFilter);
|
|
@@ -56,6 +58,27 @@ export default function InquiryModal({
|
|
|
56
58
|
const [emailValid, setEmailValid] = useState<boolean | undefined>(undefined);
|
|
57
59
|
|
|
58
60
|
const [information, setInformation] = useState('');
|
|
61
|
+
const useEmailClient =
|
|
62
|
+
allowEmailFallback && !settings.support?.emailTemplateId;
|
|
63
|
+
// encodeURIComponent, not URLSearchParams: mailto (RFC 6068) needs spaces as %20, not +
|
|
64
|
+
const supportEmailHref = `mailto:support@nyris.io?subject=${encodeURIComponent(
|
|
65
|
+
'Vizo support request',
|
|
66
|
+
)}&body=${encodeURIComponent(`Email: ${email.trim()}\n\n${information}`)}`;
|
|
67
|
+
const submitStyle: React.CSSProperties = {
|
|
68
|
+
height: '66px',
|
|
69
|
+
display: 'flex',
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
width: '50%',
|
|
72
|
+
backgroundColor: emailValid
|
|
73
|
+
? settings.theme?.primaryColor || '#0F766E'
|
|
74
|
+
: '#E9E9EC',
|
|
75
|
+
color: emailValid ? '#fff' : '#AAABB5',
|
|
76
|
+
fontSize: '14px',
|
|
77
|
+
paddingLeft: '16px',
|
|
78
|
+
border: 'none',
|
|
79
|
+
cursor: emailValid ? 'pointer' : 'default',
|
|
80
|
+
textDecoration: 'none',
|
|
81
|
+
};
|
|
59
82
|
|
|
60
83
|
const setFormattedContent = React.useCallback(
|
|
61
84
|
(text: string) => {
|
|
@@ -189,7 +212,8 @@ export default function InquiryModal({
|
|
|
189
212
|
>
|
|
190
213
|
<DialogContent
|
|
191
214
|
closeButton={false}
|
|
192
|
-
|
|
215
|
+
aria-describedby={undefined}
|
|
216
|
+
className="w-[360px] desktop:w-[378px] max-w-[calc(100vw-32px)] max-h-[calc(100dvh-32px)] overflow-y-auto flex flex-col bg-white p-0"
|
|
193
217
|
>
|
|
194
218
|
<div>
|
|
195
219
|
<div
|
|
@@ -200,7 +224,8 @@ export default function InquiryModal({
|
|
|
200
224
|
justifyContent: 'space-between',
|
|
201
225
|
}}
|
|
202
226
|
>
|
|
203
|
-
<
|
|
227
|
+
<DialogTitle
|
|
228
|
+
className="text-sm leading-normal tracking-normal"
|
|
204
229
|
style={{
|
|
205
230
|
color: '#2B2C46',
|
|
206
231
|
fontWeight: 'bold',
|
|
@@ -209,8 +234,10 @@ export default function InquiryModal({
|
|
|
209
234
|
{requestImage
|
|
210
235
|
? t('Submit your image for inquiry')
|
|
211
236
|
: t('Submit your inquiry')}
|
|
212
|
-
</
|
|
213
|
-
<
|
|
237
|
+
</DialogTitle>
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
aria-label={t('Close')}
|
|
214
241
|
onClick={() => setIsInquiryModalOpen(false)}
|
|
215
242
|
style={{ display: 'flex', padding: '1px' }}
|
|
216
243
|
>
|
|
@@ -222,7 +249,7 @@ export default function InquiryModal({
|
|
|
222
249
|
cursor: 'pointer',
|
|
223
250
|
}}
|
|
224
251
|
/>
|
|
225
|
-
</
|
|
252
|
+
</button>
|
|
226
253
|
</div>
|
|
227
254
|
{requestImage && (
|
|
228
255
|
<div
|
|
@@ -381,26 +408,31 @@ export default function InquiryModal({
|
|
|
381
408
|
>
|
|
382
409
|
{t('Cancel')}
|
|
383
410
|
</button>
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
411
|
+
{useEmailClient ? (
|
|
412
|
+
<a
|
|
413
|
+
style={submitStyle}
|
|
414
|
+
href={emailValid ? supportEmailHref : undefined}
|
|
415
|
+
aria-disabled={!emailValid}
|
|
416
|
+
tabIndex={emailValid ? 0 : -1}
|
|
417
|
+
onClick={event => {
|
|
418
|
+
if (!emailValid) {
|
|
419
|
+
event.preventDefault();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
trackClick(CLICK_SUBTYPE.RAISE_INQUIRY);
|
|
423
|
+
}}
|
|
424
|
+
>
|
|
425
|
+
{t('Open email')}
|
|
426
|
+
</a>
|
|
427
|
+
) : (
|
|
428
|
+
<button
|
|
429
|
+
style={submitStyle}
|
|
430
|
+
disabled={!emailValid}
|
|
431
|
+
onClick={handleInquiry}
|
|
432
|
+
>
|
|
433
|
+
{t('Send')}
|
|
434
|
+
</button>
|
|
435
|
+
)}
|
|
404
436
|
</div>
|
|
405
437
|
</div>
|
|
406
438
|
</DialogContent>
|