Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c5a5492
feat: upgrade issue report form with file attachments and enriched me…
beran-t Feb 14, 2026
e88e099
refactor: move Report Issue button from sidebar footer to top-right h…
beran-t Feb 14, 2026
0f2a657
refactor: rename button to Contact Support, swap position with sandbo…
beran-t Feb 14, 2026
7204c81
refactor: rebrand from Report Issue to Contact Support
beran-t Feb 14, 2026
a2d3147
refactor: remove sandbox ID field and widen support dialog
beran-t Feb 15, 2026
57a079a
refactor: use Plain API attachments instead of Supabase Storage
beran-t Feb 25, 2026
8e848e1
feat: format Plain thread body with header block and account owner email
beran-t Feb 25, 2026
12c8846
feat: add Orbit link to support thread header
beran-t Feb 25, 2026
84cc5ce
refactor: move metadata to internal note and map tier names
beran-t Feb 25, 2026
b5cb5e7
fix: use sendCustomerChat for attachments instead of deprecated creat…
beran-t Feb 25, 2026
90ea7cb
fix: use description field for thread preview and ActionError for err…
beran-t Feb 25, 2026
3b785e1
fix: set thread channel to CHAT for sendCustomerChat compatibility
beran-t Feb 25, 2026
2be336d
fix: revert to createThread with components for attachment support
beran-t Feb 25, 2026
04697e5
fix: restore metadata header in thread body and add attachment upload…
beran-t Feb 25, 2026
e870e74
security: verify team membership and fetch metadata server-side
beran-t Feb 25, 2026
3fb3d02
fix: align file size limit to 10MB and move button back to sidebar
beran-t Feb 25, 2026
35e035a
style: remove border radius from file drop zone
beran-t Feb 25, 2026
4151df6
fix: pass plain object instead of FormData and reorder sidebar buttons
beran-t Feb 25, 2026
5d3049b
feat: auto-open Contact Support dialog via ?support=true URL param
beran-t Feb 26, 2026
849ed76
feat: add support tab to dashboard route for universal support link
beran-t Feb 26, 2026
83ba3ce
fix: handle support param separately to avoid double query string
beran-t Feb 26, 2026
30020c0
chore: trigger rebuild for support param forwarding
beran-t Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/app/dashboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
132 changes: 132 additions & 0 deletions src/features/dashboard/navbar/file-drop-zone.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
handleFiles(e.target.files)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
},
[handleFiles]
)

const isDisabled = disabled || remaining <= 0

return (
<div
role="button"
tabIndex={isDisabled ? -1 : 0}
onClick={handleClick}
onKeyDown={(e) => {
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'
)}
>
<Upload className="size-5 text-fg-tertiary" />
<p className="text-sm text-fg-secondary">
{isUploading
? 'Uploading...'
: remaining > 0
? 'Drag files here or click to upload'
: 'Maximum files reached'}
</p>
{remaining > 0 && !isUploading && (
<p className="text-xs text-fg-tertiary">
Up to {remaining} more file{remaining !== 1 ? 's' : ''} (max 10MB each)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mismatched file size limit: The UI tells users "max 10MB each" but no 10MB validation exists anywhere — neither client-side nor server-side. The server enforces 50MB (support-actions.ts:11), not 10MB. Additionally, files exceeding 50MB are silently filtered out server-side (support-actions.ts:225) with no error feedback to the user.

Either add a client-side size check matching the displayed limit, or update the displayed text to match the actual server limit.

</p>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={accept}
onChange={handleInputChange}
className="hidden"
disabled={isDisabled}
/>
</div>
)
}
246 changes: 246 additions & 0 deletions src/features/dashboard/navbar/report-issue-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof supportFormSchema>

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<File[]>([])

// 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<SupportFormValues>({
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 (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{trigger}</DialogTrigger>

<DialogContent className="max-w-[640px]">
<DialogHeader>
<DialogTitle>Contact Support</DialogTitle>
<DialogDescription>
Tell us how we can help. Our team will get back to you shortly.
</DialogDescription>
</DialogHeader>

<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-3"
>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea
placeholder="Describe what you need help with..."
className="min-h-28"
disabled={isExecuting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<div className="flex flex-col gap-2">
<FileDropZone
onFilesSelected={handleFilesSelected}
maxFiles={MAX_ATTACHMENTS}
currentFileCount={files.length}
isUploading={false}
disabled={isExecuting}
accept={ACCEPTED_FILE_TYPES}
/>

{files.map((file, i) => (
<div
key={`${file.name}-${i}`}
className="flex items-center gap-2 text-sm text-fg-secondary"
>
<Paperclip className="size-3.5 shrink-0 text-fg-tertiary" />
<span className="truncate flex-1">{file.name}</span>
<span className="shrink-0 text-xs text-fg-tertiary">
{(file.size / 1024).toFixed(0)}KB
</span>
<button
type="button"
onClick={() => removeFile(i)}
disabled={isExecuting}
className="shrink-0 text-fg-tertiary hover:text-fg transition-colors"
>
<X className="size-3.5" />
</button>
</div>
))}
</div>

<DialogFooter className="mt-2">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isExecuting}
>
Cancel
</Button>
<Button
type="submit"
disabled={isExecuting || !form.formState.isValid}
loading={isExecuting}
>
Send
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
Loading
Loading