FeedbackWidget.tsx328 lines · main
1// @ts-nocheck
2import { useDebounce } from '@uidotdev/usehooks'
3import { LOCAL_STORAGE_KEYS, useParams } from 'common'
4import { AnimatePresence, motion } from 'framer-motion'
5import { toPng } from 'html-to-image'
6import { Camera, CircleCheck, Image as ImageIcon, Upload, X } from 'lucide-react'
7import { useRouter } from 'next/router'
8import { ChangeEvent, useEffect, useRef, useState } from 'react'
9import { toast } from 'sonner'
10import {
11 Button,
12 cn,
13 DropdownMenu,
14 DropdownMenuContent,
15 DropdownMenuItem,
16 DropdownMenuSeparator,
17 DropdownMenuTrigger,
18 PopoverSeparator,
19 TextArea,
20} from 'ui'
21import { Admonition } from 'ui-patterns'
22
23import {
24 convertB64toBlob,
25 isLikelySupportRequest,
26 uploadAttachment,
27} from './FeedbackDropdown.utils'
28import { SupportLink } from '@/components/interfaces/Support/SupportLink'
29import { InlineLinkClassName } from '@/components/ui/InlineLink'
30import { useFeedbackCategoryQuery } from '@/data/feedback/feedback-category'
31import { useSendFeedbackMutation } from '@/data/feedback/feedback-send'
32import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
33import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
34import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
35import { timeout } from '@/lib/helpers'
36import { useProfile } from '@/lib/profile'
37
38interface FeedbackWidgetProps {
39 onClose: () => void
40 onSwitchToIssueOptions: () => void
41}
42
43export const FeedbackWidget = ({ onClose, onSwitchToIssueOptions }: FeedbackWidgetProps) => {
44 const router = useRouter()
45 const { profile } = useProfile()
46 const { ref, slug } = useParams()
47 const { data: org } = useSelectedOrganizationQuery()
48
49 const uploadButtonRef = useRef(null)
50 const [feedback, setFeedback] = useState('')
51 const [isSending, setSending] = useState(false)
52 const [isSavingScreenshot, setIsSavingScreenshot] = useState(false)
53 const [isFeedbackSent, setIsFeedbackSent] = useState(false)
54
55 const debouncedFeedback = useDebounce(feedback, 500)
56
57 const [storedFeedback, setStoredFeedback] = useLocalStorageQuery<string | null>(
58 LOCAL_STORAGE_KEYS.FEEDBACK_WIDGET_CONTENT,
59 null
60 )
61 const [screenshot, setScreenshot, { isSuccess }] = useLocalStorageQuery<string | null>(
62 LOCAL_STORAGE_KEYS.FEEDBACK_WIDGET_SCREENSHOT,
63 null
64 )
65
66 const { data: category } = useFeedbackCategoryQuery({ prompt: debouncedFeedback })
67
68 // Use client-side heuristic for immediate feedback, AI result takes precedence when available
69 const isLikelySupport = isLikelySupportRequest(feedback)
70 const effectiveCategory = category ?? (isLikelySupport ? 'support' : null)
71
72 const { mutate: sendEvent } = useSendEventMutation()
73 const { mutate: submitFeedback } = useSendFeedbackMutation({
74 onSuccess: () => {
75 setIsFeedbackSent(true)
76 setFeedback('')
77 setStoredFeedback(null)
78 setScreenshot(null)
79 setSending(false)
80 },
81 onError: (error) => {
82 toast.error(`Failed to submit feedback: ${error.message}`)
83 setSending(false)
84 },
85 })
86
87 const captureScreenshot = async () => {
88 setIsSavingScreenshot(true)
89
90 function filter(node: HTMLElement) {
91 if ((node?.children ?? []).length > 0) {
92 return node.children[0].id !== 'feedback-widget'
93 }
94 return true
95 }
96
97 // Give time for dropdown to close
98 await timeout(100)
99 toPng(document.body, { filter })
100 .then((dataUrl: any) => setScreenshot(dataUrl))
101 .catch(() => toast.error('Failed to capture screenshot'))
102 .finally(() => setIsSavingScreenshot(false))
103 }
104
105 const onFilesUpload = async (event: ChangeEvent<HTMLInputElement>) => {
106 event.persist()
107 const [file] = event.target.files || (event as any).dataTransfer.items
108
109 const reader = new FileReader()
110 reader.onload = function (event) {
111 const dataUrl = event.target?.result
112 if (typeof dataUrl === 'string') setScreenshot(dataUrl)
113 }
114 reader.readAsDataURL(file)
115 event.target.value = ''
116 }
117
118 const handlePasteEvent = async () => {
119 // [Joshen] Support pasting images via Cmd / Ctrl + V
120 const [data] = await navigator.clipboard.read()
121
122 if (screenshot === undefined && data.types[0] === 'image/png') {
123 const blob = await data.getType('image/png')
124 const reader = new FileReader()
125 reader.onload = function (event) {
126 const dataUrl = event.target?.result
127 if (typeof dataUrl === 'string') setScreenshot(dataUrl)
128 }
129 reader.readAsDataURL(blob)
130 }
131 }
132
133 const sendFeedback = async () => {
134 if (feedback.length === 0 && screenshot !== undefined) {
135 return toast.error('Please include a message in your feedback.')
136 } else if (feedback.length > 0) {
137 setSending(true)
138
139 const attachmentUrl =
140 screenshot && profile?.gotrue_id
141 ? await uploadAttachment({
142 image: screenshot,
143 userId: profile.gotrue_id,
144 })
145 : undefined
146 const formattedFeedback =
147 attachmentUrl !== undefined ? `${feedback}\n\nAttachments:\n${attachmentUrl}` : feedback
148
149 submitFeedback({
150 projectRef: ref,
151 organizationSlug: slug,
152 message: formattedFeedback,
153 pathname: router.asPath,
154 })
155 }
156 }
157
158 // Hydrate form from localStorage once it's ready; deps intentionally omit storedFeedback/screenshot
159 // so we don't overwrite user edits when those values change after initial load.
160 useEffect(() => {
161 if (storedFeedback) setFeedback(storedFeedback)
162 if (screenshot) setScreenshot(screenshot)
163 // eslint-disable-next-line react-hooks/exhaustive-deps -- hydrate once when localStorage is ready only
164 }, [isSuccess])
165
166 // Persist debounced input to localStorage; only re-run when debounced value changes.
167 useEffect(() => {
168 if (debouncedFeedback.length > 0) setStoredFeedback(debouncedFeedback)
169 // eslint-disable-next-line react-hooks/exhaustive-deps -- setStoredFeedback is stable; only sync on debounced value
170 }, [debouncedFeedback])
171
172 const ThanksMessageView = () => (
173 <>
174 <div className="py-6 px-4 grid gap-4 text-center text-foreground-light">
175 <CircleCheck className="mx-auto text-brand-500" size={24} />
176 <div className="flex flex-col gap-1">
177 <p className="text-foreground text-base">Your feedback has been sent. Thanks!</p>
178 <p className="text-sm text-balance">
179 We don’t always respond to feedback. If you need help with your project, use the button
180 below.
181 </p>
182 </div>
183 </div>
184 <PopoverSeparator />
185 <div className="px-4 pt-4 pb-4 flex flex-row items-center justify-between">
186 <Button type="default" size="tiny" onClick={onSwitchToIssueOptions}>
187 Get help
188 </Button>
189 <Button type="default" size="tiny" onClick={onClose}>
190 Close
191 </Button>
192 </div>
193 </>
194 )
195
196 return isFeedbackSent ? (
197 <ThanksMessageView />
198 ) : (
199 <>
200 <div className="p-4">
201 <TextArea
202 placeholder="My idea for improving Briven is..."
203 rows={6}
204 value={feedback}
205 onChange={(e) => setFeedback(e.target.value)}
206 onPaste={handlePasteEvent}
207 className="text-sm mb-1 resize-none"
208 />
209 </div>
210
211 <AnimatePresence>
212 {effectiveCategory === 'support' && (
213 <motion.div
214 key="support-alert"
215 initial={{ opacity: 0, y: 16 }}
216 animate={{ opacity: 1, y: 0 }}
217 exit={{ opacity: 0, y: 16 }}
218 transition={{ duration: 0.2 }}
219 >
220 <Admonition
221 type="caution"
222 title="This looks like an issue that’s better handled by support"
223 className="rounded-none border-x-0 border-b-0"
224 >
225 <p>
226 Please{' '}
227 <SupportLink
228 className={cn(InlineLinkClassName)}
229 queryParams={{ projectRef: slug, message: feedback }}
230 >
231 open a support ticket
232 </SupportLink>{' '}
233 to get help, as we do not reply to all product feedback.
234 </p>
235 </Admonition>
236 </motion.div>
237 )}
238 </AnimatePresence>
239
240 <PopoverSeparator />
241
242 <div className="px-4 pt-4 pb-4 flex flex-row items-center justify-between">
243 <Button type="default" size="tiny" onClick={onSwitchToIssueOptions}>
244 Get help instead
245 </Button>
246 <div className="flex items-center gap-2 flex-row">
247 {!!screenshot ? (
248 <div
249 style={{ backgroundImage: `url("${screenshot}")` }}
250 onClick={() => {
251 const blob = convertB64toBlob(screenshot)
252 const blobUrl = URL.createObjectURL(blob)
253 window.open(blobUrl, '_blank')
254 }}
255 className="cursor-pointer rounded-sm h-[26px] w-[26px] border border-control relative bg-cover bg-center bg-no-repeat"
256 >
257 <button
258 className={[
259 'cursor-pointer rounded-full bg-red-900 h-3 w-3',
260 'flex items-center justify-center absolute -top-1 -right-1',
261 ].join(' ')}
262 onClick={(event) => {
263 event.stopPropagation()
264 setScreenshot(null)
265 }}
266 >
267 <X size={8} strokeWidth={3} />
268 </button>
269 </div>
270 ) : (
271 <DropdownMenu>
272 <DropdownMenuTrigger asChild>
273 <Button
274 type="default"
275 disabled={isSavingScreenshot}
276 loading={isSavingScreenshot}
277 className="w-7"
278 icon={<ImageIcon size={14} />}
279 />
280 </DropdownMenuTrigger>
281 <DropdownMenuContent side="bottom" align="end" className="w-fit">
282 <DropdownMenuItem
283 className="flex gap-2"
284 key="upload-screenshot"
285 onSelect={() => {
286 if (uploadButtonRef.current) (uploadButtonRef.current as any).click()
287 }}
288 >
289 <Upload size={14} />
290 Upload screenshot
291 </DropdownMenuItem>
292 <DropdownMenuSeparator />
293 <DropdownMenuItem
294 className="flex gap-2"
295 key="capture-screenshot"
296 onSelect={() => captureScreenshot()}
297 >
298 <Camera size={14} />
299 Capture screenshot
300 </DropdownMenuItem>
301 </DropdownMenuContent>
302 </DropdownMenu>
303 )}
304 <input
305 type="file"
306 ref={uploadButtonRef}
307 className="hidden"
308 accept="image/png"
309 onChange={onFilesUpload}
310 />
311 <Button
312 disabled={feedback.length === 0 || isSending}
313 loading={isSending}
314 onClick={() => {
315 sendFeedback()
316 sendEvent({
317 action: 'send_feedback_button_clicked',
318 groups: { project: ref, organization: org?.slug },
319 })
320 }}
321 >
322 Send
323 </Button>
324 </div>
325 </div>
326 </>
327 )
328}