"use client"; import { AlertCircle, File, FileText, Image, Upload, X } from "lucide-react"; import React, { useCallback, useRef, useState } from "react"; import { toast } from "sonner"; import { cx } from "../lib/utils"; import { OnboardingButton } from "../primitives/onboarding-button"; import { OnboardingLabel } from "../primitives/onboarding-label"; export interface UploadedFile { id: string; file: File; preview?: string; progress: number; status: "uploading" | "success" | "error"; error?: string; } export interface FileUploadField { /** Unique identifier for the field */ id: string; /** Label displayed above the upload area */ label: string; /** Optional description/helper text */ description?: string; /** Accepted file types (e.g., 'image/*', '.pdf,.doc,.docx') */ accept?: string; /** Maximum file size in bytes */ maxSize?: number; /** Maximum number of files allowed */ maxFiles?: number; /** Whether at least one file is required */ required?: boolean; /** Default uploaded files (for restoring state) */ defaultValue?: File[]; /** Custom drop zone title text */ dropZoneTitle?: string; /** Custom drop zone subtitle text */ dropZoneSubtitle?: string; } export interface FileUploadStepProps { /** Title displayed at the top of the step */ title?: string; /** Description text below the title */ description?: string; /** Array of file upload fields */ fields: FileUploadField[]; /** Simulated upload delay in ms (for demo purposes). Set to 0 for instant. */ uploadDelay?: number; /** Custom upload handler. If provided, replaces the simulated upload. Should return a promise that resolves on success or rejects with error message. */ onUpload?: (file: File, fieldId: string) => Promise; /** Called when files change */ onFilesChange?: (files: Record) => void; /** Called when the user submits the form */ onSubmit: (files: Record) => void | Promise; /** Text for the submit button */ submitText?: string; /** Text shown while submitting */ loadingText?: string; /** Text shown while uploading files */ uploadingText?: string; /** Optional skip button config (for optional upload steps) */ skipButton?: { text: string; onClick: () => void; }; /** Optional back button config */ backButton?: { text: string; onClick: () => void; }; /** Whether to show file size in the file list */ showFileSize?: boolean; /** Whether to show success toast on upload */ showSuccessToast?: boolean; } function formatFileSize(bytes: number): string { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / k ** i).toFixed(2)) + " " + sizes[i]; } function generateId(): string { return Math.random().toString(36).substring(2, 9); } function getFileIcon(file: File) { if (file.type.startsWith("image/")) { return Image; } if (file.type.includes("pdf") || file.type.includes("document")) { return FileText; } return File; } export function FileUploadStep({ title = "Upload your files", description = "Add the files you want to upload.", fields, uploadDelay = 1500, onUpload, onFilesChange, onSubmit, submitText = "Continue", loadingText = "Submitting...", uploadingText = "Uploading...", skipButton, backButton, showFileSize = true, showSuccessToast = true, }: FileUploadStepProps) { const [uploadedFiles, setUploadedFiles] = useState< Record >(() => { const initial: Record = {}; fields.forEach((field) => { initial[field.id] = (field.defaultValue || []).map((file) => ({ id: generateId(), file, preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined, progress: 100, status: "success" as const, })); }); return initial; }); const [errors, setErrors] = useState>({}); const [dragOver, setDragOver] = useState>({}); const [loading, setLoading] = useState(false); const fileInputRefs = useRef>({}); const validateFile = useCallback( ( field: FileUploadField, file: File, currentFiles: UploadedFile[], ): string | null => { // Check max files if (field.maxFiles && currentFiles.length >= field.maxFiles) { return `Maximum ${field.maxFiles} file${field.maxFiles > 1 ? "s" : ""} allowed`; } // Check file size if (field.maxSize && file.size > field.maxSize) { return `File size must be less than ${formatFileSize(field.maxSize)}`; } // Check file type if (field.accept) { const acceptedTypes = field.accept.split(",").map((t) => t.trim()); const fileType = file.type; const fileExtension = "." + file.name.split(".").pop()?.toLowerCase(); const isAccepted = acceptedTypes.some((type) => { if (type.startsWith(".")) { return fileExtension === type.toLowerCase(); } if (type.endsWith("/*")) { return fileType.startsWith(type.slice(0, -1)); } return fileType === type; }); if (!isAccepted) { return `File type not accepted. Allowed: ${field.accept}`; } } return null; }, [], ); const simulateUpload = useCallback( (fieldId: string, fileId: string): Promise => { return new Promise((resolve) => { if (uploadDelay === 0) { setUploadedFiles((prev) => ({ ...prev, [fieldId]: prev[fieldId].map((f) => f.id === fileId ? { ...f, status: "success" as const, progress: 100 } : f, ), })); resolve(); return; } const steps = 10; const stepTime = uploadDelay / steps; let currentStep = 0; const interval = setInterval(() => { currentStep++; const progress = Math.min((currentStep / steps) * 100, 100); setUploadedFiles((prev) => ({ ...prev, [fieldId]: prev[fieldId].map((f) => f.id === fileId ? { ...f, progress } : f, ), })); if (currentStep >= steps) { clearInterval(interval); setUploadedFiles((prev) => ({ ...prev, [fieldId]: prev[fieldId].map((f) => f.id === fileId ? { ...f, status: "success" as const, progress: 100 } : f, ), })); resolve(); } }, stepTime); }); }, [uploadDelay], ); const performUpload = useCallback( async (fieldId: string, fileId: string, file: File): Promise => { if (onUpload) { try { await onUpload(file, fieldId); setUploadedFiles((prev) => ({ ...prev, [fieldId]: prev[fieldId].map((f) => f.id === fileId ? { ...f, status: "success" as const, progress: 100 } : f, ), })); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Upload failed"; setUploadedFiles((prev) => ({ ...prev, [fieldId]: prev[fieldId].map((f) => f.id === fileId ? { ...f, status: "error" as const, error: errorMessage } : f, ), })); throw error; } } else { await simulateUpload(fieldId, fileId); } }, [onUpload, simulateUpload], ); const handleFiles = useCallback( async (fieldId: string, files: FileList | null) => { if (!files || files.length === 0) return; const field = fields.find((f) => f.id === fieldId); if (!field) return; const currentFiles = uploadedFiles[fieldId] || []; const newFiles: UploadedFile[] = []; for (const file of Array.from(files)) { const error = validateFile(field, file, [...currentFiles, ...newFiles]); if (error) { toast.error(error, { description: file.name }); continue; } const uploadedFile: UploadedFile = { id: generateId(), file, preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined, progress: 0, status: "uploading", }; newFiles.push(uploadedFile); } if (newFiles.length === 0) return; // Add files to state setUploadedFiles((prev) => ({ ...prev, [fieldId]: [...prev[fieldId], ...newFiles], })); // Clear field error setErrors((prev) => ({ ...prev, [fieldId]: "" })); // Upload each file for (const uploadedFile of newFiles) { try { await performUpload(fieldId, uploadedFile.id, uploadedFile.file); if (showSuccessToast) { toast.success("File uploaded", { description: (
{uploadedFile.preview ? ( {uploadedFile.file.name} ) : (
{React.createElement(getFileIcon(uploadedFile.file), { className: "size-4 text-muted-foreground", })}
)} {uploadedFile.file.name}
), }); } } catch { toast.error("Upload failed", { description: uploadedFile.file.name }); } } // Notify parent of changes const allFiles: Record = {}; fields.forEach((f) => { const files = f.id === fieldId ? [...currentFiles, ...newFiles] : uploadedFiles[f.id] || []; allFiles[f.id] = files .filter((uf) => uf.status === "success") .map((uf) => uf.file); }); onFilesChange?.(allFiles); }, [ fields, uploadedFiles, validateFile, performUpload, showSuccessToast, onFilesChange, ], ); const removeFile = useCallback( (fieldId: string, fileId: string) => { const file = uploadedFiles[fieldId]?.find((f) => f.id === fileId); if (file?.preview) { URL.revokeObjectURL(file.preview); } setUploadedFiles((prev) => ({ ...prev, [fieldId]: prev[fieldId].filter((f) => f.id !== fileId), })); // Notify parent of changes const allFiles: Record = {}; fields.forEach((f) => { const files = uploadedFiles[f.id] || []; allFiles[f.id] = files .filter((uf) => uf.id !== fileId && uf.status === "success") .map((uf) => uf.file); }); onFilesChange?.(allFiles); }, [fields, uploadedFiles, onFilesChange], ); const handleDragOver = useCallback((e: React.DragEvent, fieldId: string) => { e.preventDefault(); e.stopPropagation(); setDragOver((prev) => ({ ...prev, [fieldId]: true })); }, []); const handleDragLeave = useCallback((e: React.DragEvent, fieldId: string) => { e.preventDefault(); e.stopPropagation(); setDragOver((prev) => ({ ...prev, [fieldId]: false })); }, []); const handleDrop = useCallback( (e: React.DragEvent, fieldId: string) => { e.preventDefault(); e.stopPropagation(); setDragOver((prev) => ({ ...prev, [fieldId]: false })); handleFiles(fieldId, e.dataTransfer.files); }, [handleFiles], ); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Validate required fields const newErrors: Record = {}; let hasErrors = false; fields.forEach((field) => { const files = uploadedFiles[field.id] || []; const successFiles = files.filter((f) => f.status === "success"); if (field.required && successFiles.length === 0) { newErrors[field.id] = `At least one file is required`; hasErrors = true; } }); setErrors(newErrors); if (hasErrors) return; // Check if any files are still uploading const hasUploading = Object.values(uploadedFiles).some((files) => files.some((f) => f.status === "uploading"), ); if (hasUploading) { toast.error("Please wait for all files to finish uploading"); return; } setLoading(true); try { const allFiles: Record = {}; fields.forEach((f) => { allFiles[f.id] = (uploadedFiles[f.id] || []) .filter((uf) => uf.status === "success") .map((uf) => uf.file); }); await onSubmit(allFiles); } finally { setLoading(false); } }; const isValid = fields.every((field) => { if (field.required) { const files = uploadedFiles[field.id] || []; return files.some((f) => f.status === "success"); } return true; }); const hasUploading = Object.values(uploadedFiles).some((files) => files.some((f) => f.status === "uploading"), ); return (

{title}

{description}

{fields.map((field, index) => { const fieldFiles = uploadedFiles[field.id] || []; const canAddMore = !field.maxFiles || fieldFiles.length < field.maxFiles; return (
{field.label} {field.required && ( * )} {field.description && (

{field.description}

)} {/* Drop zone */} {canAddMore && (
handleDragOver(e, field.id)} onDragLeave={(e) => handleDragLeave(e, field.id)} onDrop={(e) => handleDrop(e, field.id)} onClick={() => fileInputRefs.current[field.id]?.click()} className={cx( "relative cursor-pointer rounded-lg border-2 border-dashed p-6 transition-colors", "hover:border-primary/50 hover:bg-muted/50", dragOver[field.id] ? "border-primary bg-primary/5" : "border-border bg-background", errors[field.id] && "border-destructive", )} > { fileInputRefs.current[field.id] = el; }} type="file" accept={field.accept} multiple={!field.maxFiles || field.maxFiles > 1} onChange={(e) => handleFiles(field.id, e.target.files)} className="sr-only" />

{field.dropZoneTitle || "Drop files here or click to upload"}

{field.dropZoneSubtitle || ( <> {field.accept && `Accepted: ${field.accept}`} {field.accept && field.maxSize && " · "} {field.maxSize && `Max size: ${formatFileSize(field.maxSize)}`} {(field.accept || field.maxSize) && field.maxFiles && " · "} {field.maxFiles && `Max files: ${field.maxFiles}`} )}

)} {/* Uploaded files list */} {fieldFiles.length > 0 && (
{fieldFiles.map((uploadedFile) => { const FileIcon = getFileIcon(uploadedFile.file); return (
{/* Thumbnail / Icon */}
{uploadedFile.preview ? ( {uploadedFile.file.name} ) : (
)}
{/* File info */}

{uploadedFile.file.name}

{showFileSize && (

{formatFileSize(uploadedFile.file.size)}

)} {/* Progress bar */} {uploadedFile.status === "uploading" && (
)} {/* Error message */} {uploadedFile.status === "error" && uploadedFile.error && (

{uploadedFile.error}

)}
{/* Remove button */}
); })}
)} {errors[field.id] && (

{errors[field.id]}

)}
); })}
{backButton && ( {backButton.text} )}
{skipButton && ( {skipButton.text} )} {loading ? loadingText : hasUploading ? uploadingText : submitText}
); }