diff --git a/src/components/ui/RaiseDisputeModalFileUpload.tsx b/src/components/ui/RaiseDisputeModalFileUpload.tsx new file mode 100644 index 0000000..175420b --- /dev/null +++ b/src/components/ui/RaiseDisputeModalFileUpload.tsx @@ -0,0 +1,333 @@ +import React, { useCallback, useId, useRef, useState } from "react"; + +export interface EvidenceFile { + id: string; + file: File; + progress: number; + error: string | null; +} + +export interface EvidenceUploadProps { + onFilesChange: (files: File[]) => void; + maxFiles?: number; +} + +const MAX_SIZE = 10 * 1024 * 1024; // 10 MB +const ALLOWED_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]); +const ALLOWED_EXT = /\.(pdf|jpg|jpeg|png)$/i; + +function validate(file: File): string | null { + const typeOk = ALLOWED_TYPES.has(file.type) || ALLOWED_EXT.test(file.name); + if (!typeOk) return "Unsupported type — PDF, JPG or PNG only"; + if (file.size > MAX_SIZE) return "File too large — max 10 MB"; + return null; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Tiny helper — joins truthy class strings, avoids needing clsx. */ +function cx(...classes: (string | false | null | undefined)[]) { + return classes.filter(Boolean).join(" "); +} + +export function EvidenceUpload({ + onFilesChange, + maxFiles = 5, +}: EvidenceUploadProps) { + const inputId = useId(); + const inputRef = useRef(null); + const [items, setItems] = useState([]); + const [dragging, setDragging] = useState(false); + + const validCount = items.filter((i) => !i.error).length; + const atMax = validCount >= maxFiles; + + const publish = useCallback( + (next: EvidenceFile[]) => { + onFilesChange(next.filter((i) => !i.error).map((i) => i.file)); + }, + [onFilesChange], + ); + + const addFiles = useCallback( + (incoming: File[]) => { + setItems((prev) => { + const capacity = maxFiles - prev.filter((i) => !i.error).length; + let slots = capacity; + const next = [...prev]; + + for (const file of incoming) { + const error = validate(file); + if (!error && slots <= 0) continue; + next.push({ + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + file, + progress: 0, + error, + }); + if (!error) slots--; + } + publish(next); + return next; + }); + }, + [maxFiles, publish], + ); + + const removeFile = useCallback( + (id: string) => { + setItems((prev) => { + const next = prev.filter((i) => i.id !== id); + publish(next); + return next; + }); + }, + [publish], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + if (!atMax) addFiles(Array.from(e.dataTransfer.files)); + }, + [atMax, addFiles], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + if (e.target.files) addFiles(Array.from(e.target.files)); + e.target.value = ""; + }, + [addFiles], + ); + + const openBrowser = useCallback(() => { + if (!atMax) inputRef.current?.click(); + }, [atMax]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.key === "Enter" || e.key === " ") && !atMax) { + e.preventDefault(); + inputRef.current?.click(); + } + }, + [atMax], + ); + + return ( +
+
{ + e.preventDefault(); + if (!atMax) setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={openBrowser} + onKeyDown={handleKeyDown} + className={cx( + // Layout & spacing + "rounded-lg py-8 px-4 text-center outline-none", + // Border — 1.5 px dashed, colour changes on drag + "border border-dashed", + dragging + ? "border-[#E84D2A] bg-[rgba(232,77,42,0.04)]" + : "border-[#757778] bg-transparent", + // Disabled-when-full state + atMax ? "cursor-not-allowed opacity-50" : "cursor-pointer opacity-100", + // Smooth transition for border colour and background + "transition-[border-color,background-color] duration-150", + )} + > + {/* Upload icon */} + + +

+ Drop files here +

+ +

+ or{" "} + +

+ +

+ PDF, JPG, PNG · max 10 MB per file +

+
+ + {/* Hidden file input */} + + + {items.length > 0 && ( +
    + {items.map((item) => ( +
  • + {/* File row */} +
    + {/* File icon */} + + + {/* Name + size */} +
    +

    + {item.file.name} +

    +

    + {formatSize(item.file.size)} +

    +
    + + {/* Remove button */} + +
    + + {/* Progress bar or error */} + {item.error ? ( +

    + + {item.error} +

    + ) : ( +
    +
    +
    + )} +
  • + ))} +
+ )} + + {items.length > 0 && ( +

+ {validCount} + {" / "} + {maxFiles} files +

+ )} +
+ ); +} \ No newline at end of file