FeedbackWidget.tsx328 lines · main
| 1 | // @ts-nocheck |
| 2 | import { useDebounce } from '@uidotdev/usehooks' |
| 3 | import { LOCAL_STORAGE_KEYS, useParams } from 'common' |
| 4 | import { AnimatePresence, motion } from 'framer-motion' |
| 5 | import { toPng } from 'html-to-image' |
| 6 | import { Camera, CircleCheck, Image as ImageIcon, Upload, X } from 'lucide-react' |
| 7 | import { useRouter } from 'next/router' |
| 8 | import { ChangeEvent, useEffect, useRef, useState } from 'react' |
| 9 | import { toast } from 'sonner' |
| 10 | import { |
| 11 | Button, |
| 12 | cn, |
| 13 | DropdownMenu, |
| 14 | DropdownMenuContent, |
| 15 | DropdownMenuItem, |
| 16 | DropdownMenuSeparator, |
| 17 | DropdownMenuTrigger, |
| 18 | PopoverSeparator, |
| 19 | TextArea, |
| 20 | } from 'ui' |
| 21 | import { Admonition } from 'ui-patterns' |
| 22 | |
| 23 | import { |
| 24 | convertB64toBlob, |
| 25 | isLikelySupportRequest, |
| 26 | uploadAttachment, |
| 27 | } from './FeedbackDropdown.utils' |
| 28 | import { SupportLink } from '@/components/interfaces/Support/SupportLink' |
| 29 | import { InlineLinkClassName } from '@/components/ui/InlineLink' |
| 30 | import { useFeedbackCategoryQuery } from '@/data/feedback/feedback-category' |
| 31 | import { useSendFeedbackMutation } from '@/data/feedback/feedback-send' |
| 32 | import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' |
| 33 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 34 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 35 | import { timeout } from '@/lib/helpers' |
| 36 | import { useProfile } from '@/lib/profile' |
| 37 | |
| 38 | interface FeedbackWidgetProps { |
| 39 | onClose: () => void |
| 40 | onSwitchToIssueOptions: () => void |
| 41 | } |
| 42 | |
| 43 | export 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 | } |