diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index 23b30facb..2e0fa921c 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..8481f8698 --- /dev/null +++ b/src/features/dashboard/navbar/file-drop-zone.tsx @@ -0,0 +1,132 @@ +'use client' + +import { cn } from '@/lib/utils' +import { Upload } from 'lucide-react' +import { useCallback, useRef, useState } from 'react' + +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 ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleClick() + } + }} + onDragOver={handleDragOver} + onDragEnter={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={cn( + 'flex flex-col items-center justify-center gap-1.5 border border-dashed p-4 transition-colors cursor-pointer', + isDragOver && 'border-fg-accent bg-bg-accent/10', + !isDragOver && !isDisabled && 'border-stroke hover:border-fg-tertiary hover:bg-bg-hover', + isDisabled && 'cursor-not-allowed opacity-50 border-stroke' + )} + > + +

+ {isUploading + ? 'Uploading...' + : remaining > 0 + ? 'Drag files here or click to upload' + : 'Maximum files reached'} +

+ {remaining > 0 && !isUploading && ( +

+ Up to {remaining} more file{remaining !== 1 ? 's' : ''} (max 10MB each) +

+ )} + +
+ ) +} 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..48769a971 --- /dev/null +++ b/src/features/dashboard/navbar/report-issue-dialog.tsx @@ -0,0 +1,246 @@ +'use client' + +import { useDashboard } from '@/features/dashboard/context' +import { contactSupportAction } from '@/server/support/support-actions' +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 { zodResolver } from '@hookform/resolvers/zod' +import { Paperclip, X } from 'lucide-react' +import { useAction } from 'next-safe-action/hooks' +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 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 + +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 [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 { execute: submitSupport, isExecuting } = useAction( + contactSupportAction, + { + 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.serverError ?? '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 = (values: SupportFormValues) => { + submitSupport({ + description: values.description.trim(), + teamIdOrSlug: team.id, + files, + }) + } + + return ( + + {trigger} + + + + Contact Support + + Tell us how we can help. Our team will get back to you shortly. + + + +
+ + ( + + Message + +