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 { useEffect, useState, useRef } from "react"; |
| #2 | import { useParams, Link } from "react-router-dom"; |
| #3 | import { doc, getDoc } from "firebase/firestore"; |
| #4 | import { db } from "../firebase"; |
| #5 | import { QRCodeSVG } from "qrcode.react"; |
| #6 | import html2canvas from "html2canvas"; |
| #7 | import jsPDF from "jspdf"; |
| #8 | import { Download, Linkedin, Copy, CheckCircle, XCircle, ArrowLeft } from "lucide-react"; |
| #9 | import type { Certificate } from "../types"; |
| #10 | import CertificateCard from "../components/CertificateCard"; |
| #11 | import { useToast } from "../components/Toast"; |
| #12 | |
| #13 | type State = "loading" | "active" | "revoked" | "not-found"; |
| #14 | |
| #15 | export default function Verify() { |
| #16 | const { certId } = useParams<{ certId: string }>(); |
| #17 | const [cert, setCert] = useState<Certificate | null>(null); |
| #18 | const [state, setState] = useState<State>("loading"); |
| #19 | const certRef = useRef<HTMLDivElement>(null); |
| #20 | const { addToast } = useToast(); |
| #21 | |
| #22 | useEffect(() => { |
| #23 | if (!certId) { setState("not-found"); return; } |
| #24 | (async () => { |
| #25 | try { |
| #26 | const snap = await getDoc(doc(db, "certificates", certId)); |
| #27 | if (!snap.exists()) { setState("not-found"); return; } |
| #28 | const data = { id: snap.id, ...snap.data() } as Certificate; |
| #29 | setCert(data); |
| #30 | setState(data.status === "revoked" ? "revoked" : "active"); |
| #31 | } catch (err: unknown) { |
| #32 | const msg = err instanceof Error ? err.message : String(err); |
| #33 | if (msg.includes("permission-denied")) { |
| #34 | addToast("Access denied — check Firestore security rules.", "error"); |
| #35 | } else { |
| #36 | addToast("Failed to load certificate.", "error"); |
| #37 | } |
| #38 | setState("not-found"); |
| #39 | } |
| #40 | })(); |
| #41 | }, [certId]); |
| #42 | |
| #43 | const handleDownloadOriginal = () => { |
| #44 | if (!cert?.fileData) return; |
| #45 | const link = document.createElement("a"); |
| #46 | link.href = cert.fileData; |
| #47 | link.download = cert.fileName || `certificate-${cert.id}`; |
| #48 | document.body.appendChild(link); |
| #49 | link.click(); |
| #50 | document.body.removeChild(link); |
| #51 | addToast("Original file downloaded!", "success"); |
| #52 | }; |
| #53 | |
| #54 | const handleDownload = async () => { |
| #55 | if (!certRef.current) return; |
| #56 | try { |
| #57 | const canvas = await html2canvas(certRef.current, { scale: 2, useCORS: true }); |
| #58 | const imgData = canvas.toDataURL("image/png"); |
| #59 | const pdf = new jsPDF({ orientation: "landscape", unit: "px", format: [1056, 816] }); |
| #60 | pdf.addImage(imgData, "PNG", 0, 0, 1056, 816); |
| #61 | pdf.save(`certificate-${cert?.id}.pdf`); |
| #62 | addToast("PDF downloaded!", "success"); |
| #63 | } catch { |
| #64 | addToast("Failed to generate PDF.", "error"); |
| #65 | } |
| #66 | }; |
| #67 | |
| #68 | const handleCopyLink = () => { |
| #69 | const url = window.location.href; |
| #70 | navigator.clipboard.writeText(url).then(() => addToast("Link copied!", "success")); |
| #71 | }; |
| #72 | |
| #73 | const handleLinkedIn = () => { |
| #74 | if (!cert) return; |
| #75 | const d = new Date(cert.issueDate); |
| #76 | const params = new URLSearchParams({ |
| #77 | startTask: "CERTIFICATION_NAME", |
| #78 | name: cert.courseTitle, |
| #79 | organizationName: cert.organization, |
| #80 | issueYear: String(d.getFullYear()), |
| #81 | issueMonth: String(d.getMonth() + 1), |
| #82 | certUrl: `${window.location.origin}/#/verify/${cert.id}`, |
| #83 | }); |
| #84 | if (cert.expiryDate) { |
| #85 | const ed = new Date(cert.expiryDate); |
| #86 | params.set("expirationYear", String(ed.getFullYear())); |
| #87 | params.set("expirationMonth", String(ed.getMonth() + 1)); |
| #88 | } |
| #89 | window.open(`https://www.linkedin.com/profile/add?${params.toString()}`, "_blank"); |
| #90 | }; |
| #91 | |
| #92 | const verifyUrl = `${window.location.origin}/#/verify/${certId}`; |
| #93 | |
| #94 | if (state === "loading") { |
| #95 | return ( |
| #96 | <div className="min-h-screen flex items-center justify-center bg-gray-50"> |
| #97 | <div className="animate-spin rounded-full h-10 w-10 border-b-2 border-emerald-600" /> |
| #98 | </div> |
| #99 | ); |
| #100 | } |
| #101 | |
| #102 | if (state === "not-found") { |
| #103 | return ( |
| #104 | <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> |
| #105 | <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-10 text-center max-w-md"> |
| #106 | <XCircle className="w-14 h-14 text-red-400 mx-auto mb-4" /> |
| #107 | <h2 className="text-2xl font-bold text-gray-900 mb-2">Certificate Not Found</h2> |
| #108 | <p className="text-gray-500 mb-6"> |
| #109 | No certificate exists with ID <code className="bg-gray-100 px-2 py-0.5 rounded text-sm">{certId}</code>. Please double-check the ID and try again. |
| #110 | </p> |
| #111 | <Link to="/" className="inline-flex items-center gap-2 text-emerald-600 hover:text-emerald-700 font-medium"> |
| #112 | <ArrowLeft className="w-4 h-4" /> Back to Home |
| #113 | </Link> |
| #114 | </div> |
| #115 | </div> |
| #116 | ); |
| #117 | } |
| #118 | |
| #119 | if (!cert) return null; |
| #120 | |
| #121 | return ( |
| #122 | <div className="min-h-screen bg-gray-50 py-8 px-4"> |
| #123 | <div className="max-w-4xl mx-auto"> |
| #124 | {/* Back link */} |
| #125 | <Link to="/" className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 mb-6"> |
| #126 | <ArrowLeft className="w-4 h-4" /> Back to Home |
| #127 | </Link> |
| #128 | |
| #129 | {/* Certificate */} |
| #130 | <div className="relative bg-white rounded-2xl shadow-lg overflow-hidden"> |
| #131 | {/* Badge overlay */} |
| #132 | {state === "active" && ( |
| #133 | <div className="absolute top-4 right-4 z-10 bg-emerald-600 text-white px-4 py-2 rounded-lg font-bold text-sm flex items-center gap-2 shadow-lg"> |
| #134 | <CheckCircle className="w-4 h-4" /> VERIFIED |
| #135 | </div> |
| #136 | )} |
| #137 | {state === "revoked" && ( |
| #138 | <div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none"> |
| #139 | <div className="bg-red-600/90 text-white px-12 py-4 rotate-[-12deg] text-3xl font-black tracking-widest shadow-2xl"> |
| #140 | REVOKED |
| #141 | </div> |
| #142 | </div> |
| #143 | )} |
| #144 | |
| #145 | {/* Certificate render */} |
| #146 | <div ref={certRef} className="flex justify-center overflow-auto"> |
| #147 | <CertificateCard cert={cert} /> |
| #148 | </div> |
| #149 | </div> |
| #150 | |
| #151 | {/* Info bar */} |
| #152 | <div className="mt-6 bg-white rounded-2xl shadow-sm border border-gray-200 p-6"> |
| #153 | <div className="flex flex-wrap items-center justify-between gap-4"> |
| #154 | <div> |
| #155 | <h3 className="font-semibold text-gray-900">{cert.courseTitle}</h3> |
| #156 | <p className="text-sm text-gray-500"> |
| #157 | Issued to <strong>{cert.recipientName}</strong> by {cert.organization} on{" "} |
| #158 | {new Date(cert.issueDate).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })} |
| #159 | </p> |
| #160 | {cert.expiryDate && ( |
| #161 | <p className="text-sm text-gray-400"> |
| #162 | Expires: {new Date(cert.expiryDate).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })} |
| #163 | </p> |
| #164 | )} |
| #165 | </div> |
| #166 | <div className="flex gap-3"> |
| #167 | {state === "active" && ( |
| #168 | <> |
| #169 | <button |
| #170 | onClick={handleLinkedIn} |
| #171 | className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors" |
| #172 | > |
| #173 | <Linkedin className="w-4 h-4" /> Add to LinkedIn |
| #174 | </button> |
| #175 | {cert.certType === "uploaded" ? ( |
| #176 | <button |
| #177 | onClick={handleDownloadOriginal} |
| #178 | className="flex items-center gap-2 px-4 py-2.5 bg-gray-800 hover:bg-gray-900 text-white rounded-lg text-sm font-medium transition-colors" |
| #179 | > |
| #180 | <Download className="w-4 h-4" /> Download Original |
| #181 | </button> |
| #182 | ) : ( |
| #183 | <button |
| #184 | onClick={handleDownload} |
| #185 | className="flex items-center gap-2 px-4 py-2.5 bg-gray-800 hover:bg-gray-900 text-white rounded-lg text-sm font-medium transition-colors" |
| #186 | > |
| #187 | <Download className="w-4 h-4" /> Download PDF |
| #188 | </button> |
| #189 | )} |
| #190 | </> |
| #191 | )} |
| #192 | <button |
| #193 | onClick={handleCopyLink} |
| #194 | className="flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium transition-colors" |
| #195 | > |
| #196 | <Copy className="w-4 h-4" /> Copy Link |
| #197 | </button> |
| #198 | </div> |
| #199 | </div> |
| #200 | </div> |
| #201 | |
| #202 | {/* QR Code */} |
| #203 | <div className="mt-6 bg-white rounded-2xl shadow-sm border border-gray-200 p-6 text-center"> |
| #204 | <p className="text-sm text-gray-500 mb-4">Scan to verify this certificate</p> |
| #205 | <div className="inline-block"> |
| #206 | <QRCodeSVG value={verifyUrl} size={160} /> |
| #207 | </div> |
| #208 | <p className="text-xs text-gray-400 mt-3 font-mono">{cert.id}</p> |
| #209 | </div> |
| #210 | </div> |
| #211 | </div> |
| #212 | ); |
| #213 | } |
| #214 |