···
1
1
+
import { useState } from 'react';
2
2
+
import {
3
3
+
Button,
4
4
+
Stack,
5
5
+
Modal,
6
6
+
Select,
7
7
+
Textarea,
8
8
+
Text,
9
9
+
Group,
10
10
+
ThemeIcon,
11
11
+
} from '@mantine/core';
12
12
+
import { BsExclamation } from 'react-icons/bs';
13
13
+
import { DANGER_OVERLAY_PROPS } from '@/styles/overlays';
14
14
+
import {
15
15
+
MAX_REPORT_DETAILS_LENGTH,
16
16
+
REPORT_REASONS,
17
17
+
ReportReasonKey,
18
18
+
} from './reportReasons';
19
19
+
import { IoMdCheckmark } from 'react-icons/io';
20
20
+
21
21
+
export type ReportCardModalState =
22
22
+
| { kind: 'form' }
23
23
+
| { kind: 'submitting' }
24
24
+
| { kind: 'success' }
25
25
+
| { kind: 'error'; message: string }
26
26
+
| { kind: 'already-reported' };
27
27
+
28
28
+
export interface ReportSubmission {
29
29
+
cardId: string;
30
30
+
reason: ReportReasonKey;
31
31
+
details: string;
32
32
+
}
33
33
+
34
34
+
interface Props {
35
35
+
isOpen: boolean;
36
36
+
onClose: () => void;
37
37
+
cardId: string;
38
38
+
//if omitted, a mock submission resolves after ~800ms (used by Storybook)
39
39
+
onSubmit?: (input: ReportSubmission) => Promise<void>;
40
40
+
//Forces the modal into a particular state (used by Storybook)
41
41
+
initialState?: ReportCardModalState;
42
42
+
}
43
43
+
44
44
+
const mockSubmit = (): Promise<void> =>
45
45
+
new Promise((resolve) => setTimeout(resolve, 800));
46
46
+
47
47
+
export default function ReportCardModal(props: Props) {
48
48
+
const [state, setState] = useState<ReportCardModalState>(
49
49
+
props.initialState ?? { kind: 'form' },
50
50
+
);
51
51
+
const [reason, setReason] = useState<ReportReasonKey | null>(null);
52
52
+
const [details, setDetails] = useState('');
53
53
+
54
54
+
const resetForm = () => {
55
55
+
setState(props.initialState ?? { kind: 'form' });
56
56
+
setReason(null);
57
57
+
setDetails('');
58
58
+
};
59
59
+
60
60
+
const detailsRequired = reason === 'other';
61
61
+
const detailsValid = !detailsRequired || details.trim().length > 0;
62
62
+
const canSubmit = state.kind === 'form' && reason !== null && detailsValid;
63
63
+
64
64
+
const handleSubmit = async () => {
65
65
+
if (!canSubmit || reason === null) return;
66
66
+
setState({ kind: 'submitting' });
67
67
+
try {
68
68
+
const submitter = props.onSubmit ?? mockSubmit;
69
69
+
await submitter({
70
70
+
cardId: props.cardId,
71
71
+
reason,
72
72
+
details: details.trim(),
73
73
+
});
74
74
+
setState({ kind: 'success' });
75
75
+
} catch (err) {
76
76
+
setState({
77
77
+
kind: 'error',
78
78
+
message:
79
79
+
err instanceof Error
80
80
+
? err.message
81
81
+
: 'Could not submit your report. Please try again.',
82
82
+
});
83
83
+
}
84
84
+
};
85
85
+
86
86
+
return (
87
87
+
<Modal
88
88
+
opened={props.isOpen}
89
89
+
onClose={props.onClose}
90
90
+
onExitTransitionEnd={resetForm}
91
91
+
title="Report card"
92
92
+
size="sm"
93
93
+
overlayProps={DANGER_OVERLAY_PROPS}
94
94
+
centered
95
95
+
onClick={(e) => e.stopPropagation()}
96
96
+
>
97
97
+
{state.kind === 'already-reported' ? (
98
98
+
<AlreadyReportedState onClose={props.onClose} />
99
99
+
) : state.kind === 'success' ? (
100
100
+
<SuccessState onClose={props.onClose} />
101
101
+
) : (
102
102
+
<Stack gap="md">
103
103
+
<Select
104
104
+
label="Why are you reporting this card?"
105
105
+
placeholder="Select a reason"
106
106
+
variant="filled"
107
107
+
withAsterisk
108
108
+
data={REPORT_REASONS.map((r) => ({
109
109
+
value: r.key,
110
110
+
label: r.label,
111
111
+
}))}
112
112
+
value={reason}
113
113
+
onChange={(value) => setReason(value as ReportReasonKey | null)}
114
114
+
disabled={state.kind === 'submitting'}
115
115
+
renderOption={({ option }) => {
116
116
+
const r = REPORT_REASONS.find((x) => x.key === option.value);
117
117
+
return (
118
118
+
<Stack gap={2}>
119
119
+
<Text size="sm">{option.label}</Text>
120
120
+
{r?.description && (
121
121
+
<Text size="xs" c="dimmed">
122
122
+
{r.description}
123
123
+
</Text>
124
124
+
)}
125
125
+
</Stack>
126
126
+
);
127
127
+
}}
128
128
+
/>
129
129
+
130
130
+
<Stack gap={4}>
131
131
+
<Group justify="space-between">
132
132
+
<Text size="sm" fw={500}>
133
133
+
Additional details{' '}
134
134
+
{detailsRequired ? (
135
135
+
<Text span c="red">
136
136
+
*
137
137
+
</Text>
138
138
+
) : (
139
139
+
<Text span c="dimmed" size="sm">
140
140
+
(optional)
141
141
+
</Text>
142
142
+
)}
143
143
+
</Text>
144
144
+
<Text size="xs" c="dimmed">
145
145
+
{details.length} / {MAX_REPORT_DETAILS_LENGTH}
146
146
+
</Text>
147
147
+
</Group>
148
148
+
<Textarea
149
149
+
placeholder="Add context that will help our moderators"
150
150
+
variant="filled"
151
151
+
rows={3}
152
152
+
maxLength={MAX_REPORT_DETAILS_LENGTH}
153
153
+
value={details}
154
154
+
onChange={(e) => setDetails(e.currentTarget.value)}
155
155
+
disabled={state.kind === 'submitting'}
156
156
+
error={
157
157
+
detailsRequired && details.length === 0
158
158
+
? 'Please add a short description'
159
159
+
: undefined
160
160
+
}
161
161
+
/>
162
162
+
</Stack>
163
163
+
164
164
+
{state.kind === 'error' && (
165
165
+
<Text size="xs" fw={600} c="red">
166
166
+
{state.message}
167
167
+
</Text>
168
168
+
)}
169
169
+
170
170
+
<Group justify="flex-end" gap="xs">
171
171
+
<Button
172
172
+
variant="subtle"
173
173
+
color="gray"
174
174
+
onClick={props.onClose}
175
175
+
disabled={state.kind === 'submitting'}
176
176
+
>
177
177
+
Cancel
178
178
+
</Button>
179
179
+
<Button
180
180
+
color="red"
181
181
+
onClick={handleSubmit}
182
182
+
loading={state.kind === 'submitting'}
183
183
+
disabled={!canSubmit}
184
184
+
>
185
185
+
Submit report
186
186
+
</Button>
187
187
+
</Group>
188
188
+
</Stack>
189
189
+
)}
190
190
+
</Modal>
191
191
+
);
192
192
+
}
193
193
+
194
194
+
function SuccessState({ onClose }: { onClose: () => void }) {
195
195
+
return (
196
196
+
<Stack gap="md" align="center">
197
197
+
<ThemeIcon variant="light" color="green" size={'xl'} radius="xl">
198
198
+
<IoMdCheckmark size={24} />
199
199
+
</ThemeIcon>
200
200
+
<Stack gap={4} align="center">
201
201
+
<Text fw={600}>Report received</Text>
202
202
+
<Text size="sm" c="dimmed" ta="center">
203
203
+
Our moderators will review it. Thanks for helping keep Semble safe —
204
204
+
your report stays anonymous.
205
205
+
</Text>
206
206
+
</Stack>
207
207
+
<Button onClick={onClose} fullWidth>
208
208
+
Ok
209
209
+
</Button>
210
210
+
</Stack>
211
211
+
);
212
212
+
}
213
213
+
214
214
+
function AlreadyReportedState({ onClose }: { onClose: () => void }) {
215
215
+
return (
216
216
+
<Stack gap="md" align="center">
217
217
+
<ThemeIcon variant="light" color="gray" size={'xl'} radius="xl">
218
218
+
<BsExclamation size={24} />
219
219
+
</ThemeIcon>
220
220
+
<Stack gap={4} align="center">
221
221
+
<Text fw={600}>You've already reported this card</Text>
222
222
+
<Text size="sm" c="dimmed" ta="center">
223
223
+
Our moderators are reviewing it.
224
224
+
</Text>
225
225
+
</Stack>
226
226
+
<Button onClick={onClose} fullWidth>
227
227
+
Close
228
228
+
</Button>
229
229
+
</Stack>
230
230
+
);
231
231
+
}