repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
certificates
stars
latest
clone command
git clone gitlawb://did:key:z6Mkqhmm...XL9c/certificatesgit clone gitlawb://did:key:z6Mkqhmm.../certificates019974a8sync from playground15h ago| #1 | import { useState, useRef } from "react"; |
| #2 | import Papa from "papaparse"; |
| #3 | import { collection, writeBatch, doc } from "firebase/firestore"; |
| #4 | import { db } from "../firebase"; |
| #5 | import { useAuth } from "../contexts/AuthContext"; |
| #6 | import { useToast } from "../components/Toast"; |
| #7 | import { nanoid } from "nanoid"; |
| #8 | import { Upload, CheckCircle, AlertCircle } from "lucide-react"; |
| #9 | import type { Certificate } from "../types"; |
| #10 | |
| #11 | // Maximum rows per bulk import to prevent abuse |
| #12 | const MAX_BULK_ROWS = 500; |
| #13 | |
| #14 | // Field length limits (matching Firestore rules) |
| #15 | const FIELD_LIMITS = { |
| #16 | recipientName: 200, |
| #17 | recipientEmail: 300, |
| #18 | courseTitle: 500, |
| #19 | organization: 300, |
| #20 | customText: 2000, |
| #21 | }; |
| #22 | |
| #23 | const VALID_TEMPLATES = ["1","2","3","4","5","6","7","8","9","10"]; |
| #24 | |
| #25 | function isValidTemplate(id: string): boolean { |
| #26 | if (VALID_TEMPLATES.includes(id)) return true; |
| #27 | // Custom template IDs start with "c" and exist in localStorage |
| #28 | if (id.startsWith("c")) { |
| #29 | try { |
| #30 | const raw = localStorage.getItem("cert-custom-templates"); |
| #31 | if (!raw) return false; |
| #32 | const templates = JSON.parse(raw) as Array<{ id: string }>; |
| #33 | return templates.some((t) => t.id === id); |
| #34 | } catch { |
| #35 | return false; |
| #36 | } |
| #37 | } |
| #38 | return false; |
| #39 | } |
| #40 | |
| #41 | function isValidUploadedTemplate(id: string): boolean { |
| #42 | return id === "upload"; |
| #43 | } |
| #44 | |
| #45 | interface CsvRow { |
| #46 | recipientName: string; |
| #47 | recipientEmail: string; |
| #48 | courseTitle: string; |
| #49 | organization?: string; |
| #50 | issueDate?: string; |
| #51 | expiryDate?: string; |
| #52 | templateId?: string; |
| #53 | customText?: string; |
| #54 | } |
| #55 | |
| #56 | interface Result { |
| #57 | success: number; |
| #58 | errors: number; |
| #59 | errorMessages: string[]; |
| #60 | } |
| #61 | |
| #62 | export default function BulkImport() { |
| #63 | const { user } = useAuth(); |
| #64 | const { addToast } = useToast(); |
| #65 | const fileRef = useRef<HTMLInputElement>(null); |
| #66 | const [rows, setRows] = useState<CsvRow[]>([]); |
| #67 | const [loading, setLoading] = useState(false); |
| #68 | const [progress, setProgress] = useState(0); |
| #69 | const [result, setResult] = useState<Result | null>(null); |
| #70 | const [dragOver, setDragOver] = useState(false); |
| #71 | |
| #72 | const parseFile = (file: File) => { |
| #73 | Papa.parse<CsvRow>(file, { |
| #74 | header: true, |
| #75 | skipEmptyLines: true, |
| #76 | complete: (res) => { |
| #77 | let valid = res.data.filter((r) => r.recipientName && r.recipientEmail && r.courseTitle); |
| #78 | if (valid.length === 0) { |
| #79 | addToast("CSV must contain recipientName, recipientEmail, and courseTitle columns.", "error"); |
| #80 | return; |
| #81 | } |
| #82 | if (valid.length > MAX_BULK_ROWS) { |
| #83 | valid = valid.slice(0, MAX_BULK_ROWS); |
| #84 | addToast(`CSV capped at ${MAX_BULK_ROWS} rows. Split into multiple files for more.`, "info"); |
| #85 | } |
| #86 | setRows(valid); |
| #87 | setResult(null); |
| #88 | addToast(`Parsed ${valid.length} rows.`, "info"); |
| #89 | }, |
| #90 | error: () => addToast("Failed to parse CSV.", "error"), |
| #91 | }); |
| #92 | }; |
| #93 | |
| #94 | const handleDrop = (e: React.DragEvent) => { |
| #95 | e.preventDefault(); |
| #96 | setDragOver(false); |
| #97 | const file = e.dataTransfer.files[0]; |
| #98 | if (file) parseFile(file); |
| #99 | }; |
| #100 | |
| #101 | const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| #102 | const file = e.target.files?.[0]; |
| #103 | if (file) parseFile(file); |
| #104 | }; |
| #105 | |
| #106 | const handleIssue = async () => { |
| #107 | if (rows.length === 0) return; |
| #108 | setLoading(true); |
| #109 | setProgress(0); |
| #110 | setResult(null); |
| #111 | |
| #112 | let success = 0; |
| #113 | let errors = 0; |
| #114 | const errorMessages: string[] = []; |
| #115 | const today = new Date().toISOString().split("T")[0]; |
| #116 | |
| #117 | // Chunk into batches of 499 |
| #118 | const chunks: CsvRow[][] = []; |
| #119 | for (let i = 0; i < rows.length; i += 499) { |
| #120 | chunks.push(rows.slice(i, i + 499)); |
| #121 | } |
| #122 | |
| #123 | try { |
| #124 | for (let ci = 0; ci < chunks.length; ci++) { |
| #125 | const batch = writeBatch(db); |
| #126 | const chunk = chunks[ci]; |
| #127 | |
| #128 | for (const row of chunk) { |
| #129 | const id = nanoid(10); |
| #130 | const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max) : s; |
| #131 | const rawTemplate = row.templateId || "1"; |
| #132 | const templateId = isValidTemplate(rawTemplate) || isValidUploadedTemplate(rawTemplate) ? rawTemplate : "1"; |
| #133 | const cert: Certificate = { |
| #134 | id, |
| #135 | recipientName: truncate(row.recipientName, FIELD_LIMITS.recipientName), |
| #136 | recipientEmail: truncate(row.recipientEmail, FIELD_LIMITS.recipientEmail), |
| #137 | courseTitle: truncate(row.courseTitle, FIELD_LIMITS.courseTitle), |
| #138 | organization: truncate(row.organization || "Organization", FIELD_LIMITS.organization), |
| #139 | issueDate: row.issueDate || today, |
| #140 | expiryDate: row.expiryDate || null, |
| #141 | templateId, |
| #142 | customText: truncate(row.customText || "", FIELD_LIMITS.customText), |
| #143 | status: "active", |
| #144 | createdAt: new Date().toISOString(), |
| #145 | issuedBy: user?.uid || "unknown", |
| #146 | }; |
| #147 | batch.set(doc(db, "certificates", id), cert); |
| #148 | success++; |
| #149 | } |
| #150 | |
| #151 | await batch.commit(); |
| #152 | setProgress(Math.round(((ci + 1) / chunks.length) * 100)); |
| #153 | } |
| #154 | } catch (err: unknown) { |
| #155 | const msg = err instanceof Error ? err.message : String(err); |
| #156 | if (msg.includes("permission-denied")) { |
| #157 | errorMessages.push("Access denied — check Firestore security rules."); |
| #158 | } else { |
| #159 | errorMessages.push(msg); |
| #160 | } |
| #161 | errors = rows.length - success; |
| #162 | } |
| #163 | |
| #164 | setResult({ success, errors, errorMessages }); |
| #165 | setLoading(false); |
| #166 | setRows([]); |
| #167 | if (errors === 0) { |
| #168 | addToast(`All ${success} certificates issued!`, "success"); |
| #169 | } else { |
| #170 | addToast(`${success} issued, ${errors} errors.`, "error"); |
| #171 | } |
| #172 | }; |
| #173 | |
| #174 | if (result) { |
| #175 | return ( |
| #176 | <div className="max-w-lg mx-auto mt-8"> |
| #177 | <div className="bg-white rounded-2xl border border-gray-200 p-8 text-center"> |
| #178 | <div className={`w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4 ${result.errors === 0 ? "bg-emerald-100" : "bg-yellow-100"}`}> |
| #179 | {result.errors === 0 ? ( |
| #180 | <CheckCircle className="w-7 h-7 text-emerald-600" /> |
| #181 | ) : ( |
| #182 | <AlertCircle className="w-7 h-7 text-yellow-600" /> |
| #183 | )} |
| #184 | </div> |
| #185 | <h2 className="text-xl font-bold text-gray-900 mb-2">Import Complete</h2> |
| #186 | <p className="text-sm text-gray-500 mb-4"> |
| #187 | <span className="text-emerald-600 font-bold">{result.success}</span> issued |
| #188 | {result.errors > 0 && ( |
| #189 | <> |
| #190 | {" "}· <span className="text-red-600 font-bold">{result.errors}</span> errors |
| #191 | </> |
| #192 | )} |
| #193 | </p> |
| #194 | {result.errorMessages.map((msg, i) => ( |
| #195 | <p key={i} className="text-xs text-red-500 mb-1">{msg}</p> |
| #196 | ))} |
| #197 | <button |
| #198 | onClick={() => setResult(null)} |
| #199 | className="mt-4 px-5 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium" |
| #200 | > |
| #201 | Import More |
| #202 | </button> |
| #203 | </div> |
| #204 | </div> |
| #205 | ); |
| #206 | } |
| #207 | |
| #208 | return ( |
| #209 | <div> |
| #210 | <h1 className="text-2xl font-bold text-gray-900 mb-6">Bulk Import</h1> |
| #211 | |
| #212 | {/* Upload zone */} |
| #213 | <div |
| #214 | onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} |
| #215 | onDragLeave={() => setDragOver(false)} |
| #216 | onDrop={handleDrop} |
| #217 | onClick={() => fileRef.current?.click()} |
| #218 | className={`border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer transition-colors mb-6 ${ |
| #219 | dragOver ? "border-emerald-500 bg-emerald-50" : "border-gray-300 bg-white hover:border-gray-400" |
| #220 | }`} |
| #221 | > |
| #222 | <Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" /> |
| #223 | <p className="text-sm font-medium text-gray-700 mb-1">Drop a CSV file here or click to browse</p> |
| #224 | <p className="text-xs text-gray-400"> |
| #225 | Required columns: <code>recipientName</code>, <code>recipientEmail</code>, <code>courseTitle</code> |
| #226 | </p> |
| #227 | <input |
| #228 | ref={fileRef} |
| #229 | type="file" |
| #230 | accept=".csv" |
| #231 | onChange={handleFileChange} |
| #232 | className="hidden" |
| #233 | /> |
| #234 | </div> |
| #235 | |
| #236 | {/* Preview table */} |
| #237 | {rows.length > 0 && ( |
| #238 | <div className="bg-white rounded-xl border border-gray-200 overflow-hidden mb-6"> |
| #239 | <div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between"> |
| #240 | <h2 className="font-semibold text-gray-900">Preview ({rows.length} rows)</h2> |
| #241 | <button |
| #242 | onClick={handleIssue} |
| #243 | disabled={loading} |
| #244 | className="flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white rounded-lg text-sm font-semibold transition-colors" |
| #245 | > |
| #246 | {loading ? ( |
| #247 | <> |
| #248 | <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" /> |
| #249 | Issuing... {progress}% |
| #250 | </> |
| #251 | ) : ( |
| #252 | `Issue All ${rows.length}` |
| #253 | )} |
| #254 | </button> |
| #255 | </div> |
| #256 | |
| #257 | {loading && ( |
| #258 | <div className="h-1 bg-gray-100"> |
| #259 | <div |
| #260 | className="h-full bg-emerald-500 transition-all duration-300" |
| #261 | style={{ width: `${progress}%` }} |
| #262 | /> |
| #263 | </div> |
| #264 | )} |
| #265 | |
| #266 | <div className="overflow-x-auto"> |
| #267 | <table className="w-full text-sm"> |
| #268 | <thead> |
| #269 | <tr className="text-left text-gray-500 border-b border-gray-100 bg-gray-50"> |
| #270 | <th className="px-4 py-2 font-medium">Recipient</th> |
| #271 | <th className="px-4 py-2 font-medium">Email</th> |
| #272 | <th className="px-4 py-2 font-medium">Course</th> |
| #273 | <th className="px-4 py-2 font-medium">Org</th> |
| #274 | <th className="px-4 py-2 font-medium">Template</th> |
| #275 | </tr> |
| #276 | </thead> |
| #277 | <tbody> |
| #278 | {rows.slice(0, 5).map((r, i) => ( |
| #279 | <tr key={i} className="border-b border-gray-50 last:border-0"> |
| #280 | <td className="px-4 py-2 text-gray-900">{r.recipientName}</td> |
| #281 | <td className="px-4 py-2 text-gray-600">{r.recipientEmail}</td> |
| #282 | <td className="px-4 py-2 text-gray-600">{r.courseTitle}</td> |
| #283 | <td className="px-4 py-2 text-gray-500">{r.organization || "—"}</td> |
| #284 | <td className="px-4 py-2 text-gray-500">{r.templateId || "1"}</td> |
| #285 | </tr> |
| #286 | ))} |
| #287 | </tbody> |
| #288 | </table> |
| #289 | </div> |
| #290 | {rows.length > 5 && ( |
| #291 | <div className="px-4 py-2 text-xs text-gray-400 bg-gray-50"> |
| #292 | ...and {rows.length - 5} more rows |
| #293 | </div> |
| #294 | )} |
| #295 | </div> |
| #296 | )} |
| #297 | </div> |
| #298 | ); |
| #299 | } |
| #300 |