1 import { useEffect, useMemo, useState } from 'react';
2 import { useLocation } from 'react-router-dom';
4 import { c } from 'ttag';
6 import { Button, Href } from '@proton/atoms';
7 import Collapsible from '@proton/components/components/collapsible/Collapsible';
8 import CollapsibleContent from '@proton/components/components/collapsible/CollapsibleContent';
9 import CollapsibleHeader from '@proton/components/components/collapsible/CollapsibleHeader';
10 import CollapsibleHeaderIconButton from '@proton/components/components/collapsible/CollapsibleHeaderIconButton';
11 import { DropdownSizeUnit } from '@proton/components/components/dropdown/utils';
12 import Form from '@proton/components/components/form/Form';
13 import Icon from '@proton/components/components/icon/Icon';
14 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
15 import Modal from '@proton/components/components/modalTwo/Modal';
16 import ModalContent from '@proton/components/components/modalTwo/ModalContent';
17 import ModalFooter from '@proton/components/components/modalTwo/ModalFooter';
18 import ModalHeader from '@proton/components/components/modalTwo/ModalHeader';
19 import Option from '@proton/components/components/option/Option';
20 import SelectTwo from '@proton/components/components/selectTwo/SelectTwo';
21 import InputFieldTwo from '@proton/components/components/v2/field/InputField';
22 import TextAreaTwo from '@proton/components/components/v2/input/TextArea';
23 import useFormErrors from '@proton/components/components/v2/useFormErrors';
24 import useApi from '@proton/components/hooks/useApi';
25 import useConfig from '@proton/components/hooks/useConfig';
26 import useNotifications from '@proton/components/hooks/useNotifications';
27 import { reportBug } from '@proton/shared/lib/api/reports';
28 import type { APP_NAMES } from '@proton/shared/lib/constants';
29 import { APPS, BRAND_NAME, CLIENT_TYPES } from '@proton/shared/lib/constants';
30 import { requiredValidator } from '@proton/shared/lib/helpers/formValidators';
31 import { omit } from '@proton/shared/lib/helpers/object';
32 import { getKnowledgeBaseUrl } from '@proton/shared/lib/helpers/url';
33 import isTruthy from '@proton/utils/isTruthy';
34 import noop from '@proton/utils/noop';
36 import { getClientName, getReportInfo } from '../../helpers/report';
37 import type { Screenshot } from './AttachScreenshot';
38 import AttachScreenshot from './AttachScreenshot';
40 export type BugModalMode = 'chat-no-agents';
42 type OptionLabelItem = { type: 'label'; value: string };
43 type OptionOptionItem = {
47 clientType?: CLIENT_TYPES;
50 type OptionItem = OptionOptionItem | OptionLabelItem;
52 interface Model extends ReturnType<typeof getReportInfo> {
53 Category: OptionOptionItem | undefined;
59 export interface Props {
63 onClose: ModalProps['onClose'];
64 onExit: ModalProps['onExit'];
65 open: ModalProps['open'];
69 const getMailOptions = (): OptionItem[] => {
70 const optionType = 'option' as const;
71 const labelType = 'label' as const;
73 { type: labelType, value: c('Group').t`Account` },
74 { type: optionType, value: 'Sign in problem', title: c('Bug category').t`Sign in problem` },
75 { type: optionType, value: 'Sign up problem', title: c('Bug category').t`Sign up problem` },
76 { type: optionType, value: 'Payments problem', title: c('Bug category').t`Payments problem` },
77 { type: optionType, value: 'Custom domain problem', title: c('Bug category').t`Custom domain problem` },
78 { type: labelType, value: c('Group').t`Apps` },
79 { type: optionType, value: 'Bridge problem', title: c('Bug category').t`Bridge problem` },
80 { type: optionType, value: 'Import / export problem', title: c('Bug category').t`Import / export problem` },
81 { type: labelType, value: c('Group').t`Network` },
82 { type: optionType, value: 'Connection problem', title: c('Bug category').t`Connection problem` },
83 { type: optionType, value: 'Slow speed problem', title: c('Bug category').t`Slow speed problem` },
84 { type: labelType, value: c('Group').t`Services` },
87 value: 'Calendar problem',
88 title: c('Bug category').t`Calendar problem`,
89 app: APPS.PROTONCALENDAR,
91 { type: optionType, value: 'Contacts problem', title: c('Bug category').t`Contacts problem` },
94 value: 'Drive problem',
95 title: c('Bug category').t`Drive problem`,
96 app: APPS.PROTONDRIVE,
100 value: 'Docs problem',
101 title: c('Bug category').t`Docs problem`,
102 app: APPS.PROTONDOCS,
106 value: 'Mail problem',
107 title: c('Bug category').t`Mail problem`,
108 app: APPS.PROTONMAIL,
112 value: 'VPN problem',
113 title: c('Bug category').t`VPN problem`,
114 clientType: CLIENT_TYPES.VPN,
115 app: APPS.PROTONVPN_SETTINGS,
119 value: 'Pass problem',
120 title: c('Bug category').t`Pass problem`,
121 clientType: CLIENT_TYPES.PASS,
122 app: APPS.PROTONPASS,
126 value: 'Wallet problem',
127 title: c('wallet_signup_2024:Bug category').t`Wallet problem`,
128 clientType: CLIENT_TYPES.WALLET,
129 app: APPS.PROTONWALLET,
131 { type: labelType, value: c('Group').t`Other category` },
132 { type: optionType, value: 'Feature request', title: c('Bug category').t`Feature request` },
133 { type: optionType, value: 'Other', title: c('Bug category').t`Other` },
137 const getVPNOptions = (): OptionItem[] => {
139 { type: 'option', value: 'Login problem', title: c('Bug category').t`Sign in problem` },
140 { type: 'option', value: 'Signup problem', title: c('Bug category').t`Signup problem` },
141 { type: 'option', value: 'Payments problem', title: c('Bug category').t`Payments problem` },
142 { type: 'option', value: 'Installation problem', title: c('Bug category').t`Installation problem` },
143 { type: 'option', value: 'Update problem', title: c('Bug category').t`Update problem` },
144 { type: 'option', value: 'Application problem', title: c('Bug category').t`Application problem` },
145 { type: 'option', value: 'Connection problem', title: c('Bug category').t`Connection problem` },
146 { type: 'option', value: 'Speed problem', title: c('Bug category').t`Speed problem` },
147 { type: 'option', value: 'Manual setup problem', title: c('Bug category').t`Manual setup problem` },
148 { type: 'option', value: 'Website access problem', title: c('Bug category').t`Website access problem` },
149 { type: 'option', value: 'Streaming problem', title: c('Bug category').t`Streaming problem` },
150 { type: 'option', value: 'Feature request', title: c('Bug category').t`Feature request` },
154 const BugModal = ({ username: Username = '', email, mode, open, onClose, onExit, app: maybeApp }: Props) => {
155 const api = useApi();
156 const location = useLocation();
157 const [loading, setLoading] = useState(false);
159 const handleClose = loading ? noop : onClose;
161 const { APP_VERSION, CLIENT_TYPE, APP_NAME } = useConfig();
162 const isVpn = APP_NAME === APPS.PROTONVPN_SETTINGS;
163 const isDrive = APP_NAME === APPS.PROTONDRIVE;
164 const clearCacheLink = isVpn
165 ? 'https://protonvpn.com/support/clear-browser-cache-cookies/'
166 : getKnowledgeBaseUrl('/how-to-clean-cache-and-cookies');
167 const Client = getClientName(APP_NAME);
168 const showCategory = !isDrive;
169 const { createNotification } = useNotifications();
171 const options = useMemo(() => {
172 return isVpn ? getVPNOptions() : getMailOptions();
175 const categoryOptions = options.map((option) => {
176 const { type, value } = option;
177 if (type === 'label') {
179 <label className="text-semibold px-2 py-1 block" key={value}>
185 const { title } = option;
186 return <Option title={title} value={option} key={value} />;
189 const [model, setModel] = useState<Model>(() => {
190 const app = maybeApp || APP_NAME;
191 const defaultCategory = options.find(
192 (option): option is OptionOptionItem => option.type === 'option' && option.app === app
196 Category: defaultCategory,
199 Username: Username || '',
202 const [screenshots, setScreenshots] = useState<Screenshot[]>([]);
203 const [uploadingScreenshots, setUploadingScreenshots] = useState(false);
204 const link = <Href key="linkClearCache" href={clearCacheLink}>{c('Link').t`clearing your browser cache`}</Href>;
206 const { validator, onFormSubmit } = useFormErrors();
208 const setModelDiff = (model: Partial<Model>) => {
209 setModel((oldModel) => ({ ...oldModel, ...model }));
211 const handleChange = <K extends keyof Model>(key: K) => {
212 return (value: Model[K]) => setModelDiff({ [key]: value });
215 const categoryTitle = model.Category?.title || '';
216 const clientType = model.Category?.clientType || CLIENT_TYPE;
218 const handleSubmit = async () => {
219 if (!onFormSubmit()) {
225 const getParameters = () => {
226 const screenshotBlobs = screenshots.reduce((acc: { [key: string]: Blob }, { name, blob }) => {
231 const Title = [!isVpn && '[V5]', `[${Client}] Bug [${location.pathname}]`, categoryTitle]
237 ...omit(model, ['OSArtificial', 'Category']),
240 ClientVersion: APP_VERSION,
241 ClientType: clientType,
247 await api(reportBug(getParameters(), 'form'));
249 createNotification({ text: c('Success').t`Problem reported` });
256 if (!model.Email && email) {
257 setModel({ ...model, Email: email });
261 const OSAndOSVersionFields = (
265 label={c('Label').t`Operating system`}
267 onValue={handleChange('OS')}
272 label={c('Label').t`Operating system version`}
273 value={model.OSVersion}
274 onValue={handleChange('OSVersion')}
280 const modeAlert = (() => {
281 if (mode === 'chat-no-agents') {
285 .t`Unfortunately, we’re not online at the moment. Please complete the form below to describe your issue, and we will look into it and be in touch when we’re back online.`}
293 {c('Info').jt`Refreshing the page or ${link} will automatically resolve most issues.`}
297 .t`Reports are not end-to-end encrypted, please do not send any sensitive information.`}
303 // Retrieves the selected option by title to ensure referential equality for Select's value
304 const selectedValue = options.find(
305 (option) => (option.type === 'option' && option.title === model.Category?.title) || ''
309 <Modal as={Form} open={open} onClose={handleClose} onExit={onExit} onSubmit={handleSubmit}>
310 <ModalHeader title={c('Title').t`Report a problem`} />
317 label={c('Label').t`${BRAND_NAME} username`}
318 value={model.Username}
319 onValue={handleChange('Username')}
325 label={c('Label').t`Email address`}
326 placeholder={c('Placeholder').t`A way to contact you`}
328 onValue={handleChange('Email')}
329 error={validator([requiredValidator(model.Email)])}
334 as={SelectTwo<OptionItem>}
335 label={c('Label').t`Category`}
336 placeholder={c('Placeholder').t`Select`}
338 value={selectedValue}
339 onValue={(option: OptionItem) => {
340 if (option.type === 'option') {
341 setModelDiff({ Category: option });
344 error={validator([requiredValidator(categoryTitle)])}
347 width: DropdownSizeUnit.Anchor,
348 maxWidth: DropdownSizeUnit.Viewport,
358 label={c('Label').t`What happened?`}
359 placeholder={c('Placeholder').t`Please describe the problem and include any error messages`}
360 value={model.Description}
361 onValue={handleChange('Description')}
362 error={validator([requiredValidator(model.Description)])}
368 screenshots={screenshots}
369 setScreenshots={setScreenshots}
370 uploading={uploadingScreenshots}
371 setUploading={setUploadingScreenshots}
374 {model.OSArtificial && OSAndOSVersionFields}
376 <Collapsible className="mt-4">
380 <CollapsibleHeaderIconButton size="small">
381 <Icon name="chevron-down" />
382 </CollapsibleHeaderIconButton>
385 <label className="text-semibold">{c('Label').t`System information`}</label>
388 <CollapsibleContent className="mt-4">
389 {!model.OSArtificial && OSAndOSVersionFields}
393 label={c('Label').t`Browser`}
394 value={model.Browser}
395 onValue={handleChange('Browser')}
400 label={c('Label').t`Browser version`}
401 value={model.BrowserVersion}
402 onValue={handleChange('BrowserVersion')}
405 </CollapsibleContent>
409 <Button onClick={handleClose} disabled={loading}>
410 {c('Action').t`Cancel`}
412 <Button loading={loading} disabled={uploadingScreenshots} type="submit" color="norm">
413 {c('Action').t`Submit`}
420 export default BugModal;