diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index d69a65e5b..c0bc9617a 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -59,5 +59,12 @@ export async function GET(request: NextRequest) { ? urlGenerator(team.slug || team.id) : PROTECTED_URLS.SANDBOXES(team.slug || team.id) - return NextResponse.redirect(new URL(redirectPath, request.url)) + const redirectUrl = new URL(redirectPath, request.url) + + // Forward ?support=true query param to auto-open the Contact Support dialog on the target page + if (searchParams.get('support') === 'true') { + redirectUrl.searchParams.set('support', 'true') + } + + return NextResponse.redirect(redirectUrl) } diff --git a/src/features/dashboard/navbar/file-drop-zone.tsx b/src/features/dashboard/navbar/file-drop-zone.tsx new file mode 100644 index 000000000..8436706dc --- /dev/null +++ b/src/features/dashboard/navbar/file-drop-zone.tsx @@ -0,0 +1,129 @@ +'use client' + +import { Upload } from 'lucide-react' +import { useCallback, useRef, useState } from 'react' +import { cn } from '@/lib/utils' + +interface FileDropZoneProps { + onFilesSelected: (files: File[]) => void + maxFiles: number + currentFileCount: number + isUploading: boolean + disabled?: boolean + accept?: string +} + +export default function FileDropZone({ + onFilesSelected, + maxFiles, + currentFileCount, + isUploading, + disabled = false, + accept, +}: FileDropZoneProps) { + const [isDragOver, setIsDragOver] = useState(false) + const fileInputRef = useRef(null) + const remaining = maxFiles - currentFileCount + + const handleFiles = useCallback( + (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return + const files = Array.from(fileList).slice(0, remaining) + if (files.length > 0) { + onFilesSelected(files) + } + }, + [onFilesSelected, remaining] + ) + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!disabled && remaining > 0) { + setIsDragOver(true) + } + }, + [disabled, remaining] + ) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(false) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(false) + if (!disabled && remaining > 0) { + handleFiles(e.dataTransfer.files) + } + }, + [disabled, remaining, handleFiles] + ) + + const handleClick = useCallback(() => { + if (!disabled && remaining > 0) { + fileInputRef.current?.click() + } + }, [disabled, remaining]) + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + handleFiles(e.target.files) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + }, + [handleFiles] + ) + + const isDisabled = disabled || remaining <= 0 + + return ( + + ) +} diff --git a/src/features/dashboard/navbar/report-issue-dialog.tsx b/src/features/dashboard/navbar/report-issue-dialog.tsx new file mode 100644 index 000000000..dd515dd77 --- /dev/null +++ b/src/features/dashboard/navbar/report-issue-dialog.tsx @@ -0,0 +1,272 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { Paperclip, X } from 'lucide-react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' +import { useCallback, useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' +import { useDashboard } from '@/features/dashboard/context' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/primitives/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form' +import { Textarea } from '@/ui/primitives/textarea' +import FileDropZone from './file-drop-zone' + +const MAX_ATTACHMENTS = 5 +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB per file + +const ACCEPTED_FILE_TYPES = + 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' + +const supportFormSchema = z.object({ + description: z.string().min(1, 'Please describe how we can help'), +}) + +type SupportFormValues = z.infer + +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = reader.result as string | null + // Strip the data URL prefix (e.g. "data:image/png;base64,") + resolve(result?.split(',')[1] ?? '') + } + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + +interface ContactSupportDialogProps { + trigger: React.ReactNode +} + +export default function ContactSupportDialog({ + trigger, +}: ContactSupportDialogProps) { + const posthog = usePostHog() + const { team } = useDashboard() + const searchParams = useSearchParams() + const router = useRouter() + const pathname = usePathname() + const trpc = useTRPC() + + const [isOpen, setIsOpen] = useState(false) + const [wasSubmitted, setWasSubmitted] = useState(false) + const [files, setFiles] = useState([]) + + // Auto-open dialog when ?support=true is in the URL + useEffect(() => { + if (searchParams.get('support') === 'true') { + setIsOpen(true) + const params = new URLSearchParams(searchParams.toString()) + params.delete('support') + const query = params.toString() + router.replace(`${pathname}${query ? `?${query}` : ''}`, { + scroll: false, + }) + } + }, [searchParams, router, pathname]) + + const form = useForm({ + resolver: zodResolver(supportFormSchema), + defaultValues: { + description: '', + }, + }) + + const contactSupportMutation = useMutation( + trpc.support.contactSupport.mutationOptions({ + onSuccess: (data) => { + posthog.capture('support_request_submitted', { + thread_id: data?.threadId, + team_id: team.id, + tier: team.tier, + attachment_count: files.length, + }) + setWasSubmitted(true) + toast.success( + 'Message sent successfully. Our team will get back to you shortly.' + ) + setIsOpen(false) + resetForm() + setTimeout(() => { + setWasSubmitted(false) + }, 100) + }, + onError: (error) => { + toast.error( + error.message ?? 'Failed to send message. Please try again.' + ) + }, + }) + ) + + const resetForm = useCallback(() => { + form.reset() + setFiles([]) + }, [form]) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) { + posthog.capture('support_form_shown') + } + if (!open && !wasSubmitted) { + posthog.capture('support_form_dismissed') + } + setIsOpen(open) + if (!open) { + resetForm() + } + }, + [posthog, wasSubmitted, resetForm] + ) + + const handleFilesSelected = useCallback((newFiles: File[]) => { + const oversized = newFiles.filter((f) => f.size > MAX_FILE_SIZE) + if (oversized.length > 0) { + toast.error( + `${oversized.length} file${oversized.length > 1 ? 's' : ''} exceeded the 10MB limit and ${oversized.length > 1 ? 'were' : 'was'} not added.` + ) + } + const valid = newFiles.filter((f) => f.size <= MAX_FILE_SIZE) + setFiles((prev) => { + const remaining = MAX_ATTACHMENTS - prev.length + return [...prev, ...valid.slice(0, remaining)] + }) + }, []) + + const removeFile = useCallback((index: number) => { + setFiles((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const onSubmit = async (values: SupportFormValues) => { + const filePayloads = await Promise.all( + files.map(async (file) => ({ + name: file.name, + type: file.type, + base64: await fileToBase64(file), + })) + ) + + contactSupportMutation.mutate({ + teamIdOrSlug: team.id, + description: values.description.trim(), + files: filePayloads.length > 0 ? filePayloads : undefined, + }) + } + + const isSubmitting = contactSupportMutation.isPending + + return ( + + {trigger} + + + + Contact Support + + Tell us how we can help. Our team will get back to you shortly. + + + +
+ + ( + + Message + +