-
Notifications
You must be signed in to change notification settings - Fork 58
feat: upgrade Contact Support form with file attachments and Plain integration #240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
beran-t
wants to merge
22
commits into
main
Choose a base branch
from
upgrade-issue-form
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+687
−255
Open
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 e88e099
refactor: move Report Issue button from sidebar footer to top-right h…
beran-t 0f2a657
refactor: rename button to Contact Support, swap position with sandbo…
beran-t 7204c81
refactor: rebrand from Report Issue to Contact Support
beran-t a2d3147
refactor: remove sandbox ID field and widen support dialog
beran-t 57a079a
refactor: use Plain API attachments instead of Supabase Storage
beran-t 8e848e1
feat: format Plain thread body with header block and account owner email
beran-t 12c8846
feat: add Orbit link to support thread header
beran-t 84cc5ce
refactor: move metadata to internal note and map tier names
beran-t b5cb5e7
fix: use sendCustomerChat for attachments instead of deprecated creat…
beran-t 90ea7cb
fix: use description field for thread preview and ActionError for err…
beran-t 3b785e1
fix: set thread channel to CHAT for sendCustomerChat compatibility
beran-t 2be336d
fix: revert to createThread with components for attachment support
beran-t 04697e5
fix: restore metadata header in thread body and add attachment upload…
beran-t e870e74
security: verify team membership and fetch metadata server-side
beran-t 3fb3d02
fix: align file size limit to 10MB and move button back to sidebar
beran-t 35e035a
style: remove border radius from file drop zone
beran-t 4151df6
fix: pass plain object instead of FormData and reorder sidebar buttons
beran-t 5d3049b
feat: auto-open Contact Support dialog via ?support=true URL param
beran-t 849ed76
feat: add support tab to dashboard route for universal support link
beran-t 83ba3ce
fix: handle support param separately to avoid double query string
beran-t 30020c0
chore: trigger rebuild for support param forwarding
beran-t File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| </p> | ||
| )} | ||
| <input | ||
| ref={fileInputRef} | ||
| type="file" | ||
| multiple | ||
| accept={accept} | ||
| onChange={handleInputChange} | ||
| className="hidden" | ||
| disabled={isDisabled} | ||
| /> | ||
| </div> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.