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 playground16h ago| #1 | import { useState } from "react"; |
| #2 | import { doc, setDoc } from "firebase/firestore"; |
| #3 | import { db } from "../firebase"; |
| #4 | import { useAuth } from "../contexts/AuthContext"; |
| #5 | import { useToast } from "../components/Toast"; |
| #6 | import { nanoid } from "nanoid"; |
| #7 | import { Copy, CheckCircle, Eye } from "lucide-react"; |
| #8 | import CertificateCard from "../components/CertificateCard"; |
| #9 | import { useCustomTemplates } from "../hooks/useCustomTemplates"; |
| #10 | import type { Certificate } from "../types"; |
| #11 | |
| #12 | const today = () => new Date().toISOString().split("T")[0]; |
| #13 | |
| #14 | export default function IssueCert() { |
| #15 | const { user } = useAuth(); |
| #16 | const { addToast } = useToast(); |
| #17 | const { templates: customTemplates } = useCustomTemplates(); |
| #18 | const [loading, setLoading] = useState(false); |
| #19 | const [result, setResult] = useState<{ id: string; url: string } | null>(null); |
| #20 | const [preview, setPreview] = useState(false); |
| #21 | |
| #22 | const [form, setForm] = useState({ |
| #23 | recipientName: "", |
| #24 | recipientEmail: "", |
| #25 | courseTitle: "", |
| #26 | organization: "", |
| #27 | issueDate: today(), |
| #28 | expiryDate: "", |
| #29 | templateId: "1", |
| #30 | customText: "", |
| #31 | }); |
| #32 | |
| #33 | const update = (field: string, value: string) => setForm((f) => ({ ...f, [field]: value })); |
| #34 | |
| #35 | const handleSubmit = async (e: React.FormEvent) => { |
| #36 | e.preventDefault(); |
| #37 | if (!form.recipientName || !form.courseTitle || !form.organization) { |
| #38 | addToast("Please fill in all required fields.", "error"); |
| #39 | return; |
| #40 | } |
| #41 | |
| #42 | // Input validation |
| #43 | const MAX = { recipientName: 200, recipientEmail: 300, courseTitle: 500, organization: 300, customText: 2000 }; |
| #44 | if (form.recipientName.length > MAX.recipientName) { |
| #45 | addToast(`Recipient name must be under ${MAX.recipientName} characters.`, "error"); return; |
| #46 | } |
| #47 | if (form.recipientEmail.length > MAX.recipientEmail) { |
| #48 | addToast(`Email must be under ${MAX.recipientEmail} characters.`, "error"); return; |
| #49 | } |
| #50 | if (form.courseTitle.length > MAX.courseTitle) { |
| #51 | addToast(`Course title must be under ${MAX.courseTitle} characters.`, "error"); return; |
| #52 | } |
| #53 | if (form.organization.length > MAX.organization) { |
| #54 | addToast(`Organization must be under ${MAX.organization} characters.`, "error"); return; |
| #55 | } |
| #56 | if (form.customText.length > MAX.customText) { |
| #57 | addToast(`Custom text must be under ${MAX.customText} characters.`, "error"); return; |
| #58 | } |
| #59 | |
| #60 | setLoading(true); |
| #61 | try { |
| #62 | const id = nanoid(10); |
| #63 | const cert: Certificate = { |
| #64 | id, |
| #65 | recipientName: form.recipientName, |
| #66 | recipientEmail: form.recipientEmail, |
| #67 | courseTitle: form.courseTitle, |
| #68 | organization: form.organization, |
| #69 | issueDate: form.issueDate, |
| #70 | expiryDate: form.expiryDate || null, |
| #71 | templateId: form.templateId, |
| #72 | customText: form.customText, |
| #73 | status: "active", |
| #74 | createdAt: new Date().toISOString(), |
| #75 | issuedBy: user?.uid || "unknown", |
| #76 | }; |
| #77 | |
| #78 | await setDoc(doc(db, "certificates", id), cert); |
| #79 | const url = `${window.location.origin}/#/verify/${id}`; |
| #80 | setResult({ id, url }); |
| #81 | addToast("Certificate issued successfully!", "success"); |
| #82 | } catch (err: unknown) { |
| #83 | const msg = err instanceof Error ? err.message : String(err); |
| #84 | if (msg.includes("permission-denied")) { |
| #85 | addToast("Access denied — check Firestore security rules.", "error"); |
| #86 | } else { |
| #87 | addToast("Failed to issue certificate.", "error"); |
| #88 | } |
| #89 | } finally { |
| #90 | setLoading(false); |
| #91 | } |
| #92 | }; |
| #93 | |
| #94 | const handleCopy = () => { |
| #95 | if (result) { |
| #96 | navigator.clipboard.writeText(result.url).then(() => addToast("URL copied!", "success")); |
| #97 | } |
| #98 | }; |
| #99 | |
| #100 | const previewCert: Certificate = { |
| #101 | id: "PREVIEW01", |
| #102 | recipientName: form.recipientName || "Recipient Name", |
| #103 | recipientEmail: form.recipientEmail || "", |
| #104 | courseTitle: form.courseTitle || "Course Title", |
| #105 | organization: form.organization || "Organization", |
| #106 | issueDate: form.issueDate || today(), |
| #107 | expiryDate: form.expiryDate || null, |
| #108 | templateId: form.templateId, |
| #109 | customText: form.customText, |
| #110 | status: "active", |
| #111 | createdAt: new Date().toISOString(), |
| #112 | issuedBy: "", |
| #113 | }; |
| #114 | |
| #115 | if (result) { |
| #116 | return ( |
| #117 | <div className="max-w-lg mx-auto mt-8"> |
| #118 | <div className="bg-white rounded-2xl border border-gray-200 p-8 text-center"> |
| #119 | <div className="w-14 h-14 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4"> |
| #120 | <CheckCircle className="w-7 h-7 text-emerald-600" /> |
| #121 | </div> |
| #122 | <h2 className="text-xl font-bold text-gray-900 mb-2">Certificate Issued!</h2> |
| #123 | <p className="text-sm text-gray-500 mb-6">ID: <code className="bg-gray-100 px-2 py-0.5 rounded">{result.id}</code></p> |
| #124 | <div className="flex items-center gap-2 bg-gray-50 rounded-lg px-4 py-3 mb-6"> |
| #125 | <input |
| #126 | readOnly |
| #127 | value={result.url} |
| #128 | className="flex-1 bg-transparent text-sm text-gray-700 outline-none font-mono" |
| #129 | /> |
| #130 | <button onClick={handleCopy} className="text-gray-400 hover:text-gray-600"> |
| #131 | <Copy className="w-4 h-4" /> |
| #132 | </button> |
| #133 | </div> |
| #134 | <div className="flex gap-3 justify-center"> |
| #135 | <a |
| #136 | href={result.url} |
| #137 | target="_blank" |
| #138 | rel="noopener noreferrer" |
| #139 | className="px-5 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium transition-colors" |
| #140 | > |
| #141 | View Certificate |
| #142 | </a> |
| #143 | <button |
| #144 | onClick={() => { |
| #145 | setResult(null); |
| #146 | setForm({ |
| #147 | recipientName: "", |
| #148 | recipientEmail: "", |
| #149 | courseTitle: "", |
| #150 | organization: "", |
| #151 | issueDate: today(), |
| #152 | expiryDate: "", |
| #153 | templateId: "1", |
| #154 | customText: "", |
| #155 | }); |
| #156 | }} |
| #157 | className="px-5 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium transition-colors" |
| #158 | > |
| #159 | Issue Another |
| #160 | </button> |
| #161 | </div> |
| #162 | </div> |
| #163 | </div> |
| #164 | ); |
| #165 | } |
| #166 | |
| #167 | return ( |
| #168 | <div> |
| #169 | <h1 className="text-2xl font-bold text-gray-900 mb-6">Issue Certificate</h1> |
| #170 | |
| #171 | <div className="grid lg:grid-cols-2 gap-6"> |
| #172 | {/* Form */} |
| #173 | <form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-6 space-y-4"> |
| #174 | <div> |
| #175 | <label className="block text-sm font-medium text-gray-700 mb-1">Recipient Name *</label> |
| #176 | <input |
| #177 | value={form.recipientName} |
| #178 | onChange={(e) => update("recipientName", e.target.value)} |
| #179 | required |
| #180 | className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" |
| #181 | /> |
| #182 | </div> |
| #183 | <div> |
| #184 | <label className="block text-sm font-medium text-gray-700 mb-1">Recipient Email</label> |
| #185 | <input |
| #186 | type="email" |
| #187 | value={form.recipientEmail} |
| #188 | onChange={(e) => update("recipientEmail", e.target.value)} |
| #189 | className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" |
| #190 | /> |
| #191 | </div> |
| #192 | <div> |
| #193 | <label className="block text-sm font-medium text-gray-700 mb-1">Course Title *</label> |
| #194 | <input |
| #195 | value={form.courseTitle} |
| #196 | onChange={(e) => update("courseTitle", e.target.value)} |
| #197 | required |
| #198 | className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" |
| #199 | /> |
| #200 | </div> |
| #201 | <div> |
| #202 | <label className="block text-sm font-medium text-gray-700 mb-1">Organization *</label> |
| #203 | <input |
| #204 | value={form.organization} |
| #205 | onChange={(e) => update("organization", e.target.value)} |
| #206 | required |
| #207 | className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" |
| #208 | /> |
| #209 | </div> |
| #210 | <div className="grid grid-cols-2 gap-4"> |
| #211 | <div> |
| #212 | <label className="block text-sm font-medium text-gray-700 mb-1">Issue Date</label> |
| #213 | <input |
| #214 | type="date" |
| #215 | value={form.issueDate} |
| #216 | onChange={(e) => update("issueDate", e.target.value)} |
| #217 | className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" |
| #218 | /> |
| #219 | </div> |
| #220 | <div> |
| #221 | <label className="block text-sm font-medium text-gray-700 mb-1">Expiry Date (optional)</label> |
| #222 | <input |
| #223 | type="date" |
| #224 | value={form.expiryDate} |
| #225 | onChange={(e) => update("expiryDate", e.target.value)} |
| #226 | className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" |
| #227 | /> |
| #228 | </div> |
| #229 | </div> |
| #230 | <div> |
| #231 | <label className="block text-sm font-medium text-gray-700 mb-1">Custom Text (optional)</label> |
| #232 | <textarea |
| #233 | value={form.customText} |
| #234 | onChange={(e) => update("customText", e.target.value)} |
| #235 | rows={3} |
| #236 | className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 resize-none" |
| #237 | placeholder="Override the default certificate body text..." |
| #238 | /> |
| #239 | </div> |
| #240 | |
| #241 | {/* Template picker */} |
| #242 | <div> |
| #243 | <label className="block text-sm font-medium text-gray-700 mb-2">Template</label> |
| #244 | <div className="grid grid-cols-5 gap-2"> |
| #245 | {Array.from({ length: 10 }, (_, i) => String(i + 1)).map((id) => ( |
| #246 | <button |
| #247 | key={id} |
| #248 | type="button" |
| #249 | onClick={() => update("templateId", id)} |
| #250 | className={`aspect-[1056/816] rounded-lg border-2 text-xs font-bold transition-all ${ |
| #251 | form.templateId === id |
| #252 | ? "border-emerald-500 bg-emerald-50 text-emerald-700" |
| #253 | : "border-gray-200 bg-gray-50 text-gray-500 hover:border-gray-300" |
| #254 | }`} |
| #255 | > |
| #256 | {id} |
| #257 | </button> |
| #258 | ))} |
| #259 | {customTemplates.map((t) => ( |
| #260 | <button |
| #261 | key={t.id} |
| #262 | type="button" |
| #263 | onClick={() => update("templateId", t.id)} |
| #264 | className={`aspect-[1056/816] rounded-lg border-2 text-xs font-bold transition-all truncate px-1 ${ |
| #265 | form.templateId === t.id |
| #266 | ? "border-emerald-500 bg-emerald-50 text-emerald-700" |
| #267 | : "border-purple-200 bg-purple-50 text-purple-500 hover:border-purple-300" |
| #268 | }`} |
| #269 | title={t.name} |
| #270 | > |
| #271 | {t.name.slice(0, 8)} |
| #272 | </button> |
| #273 | ))} |
| #274 | </div> |
| #275 | </div> |
| #276 | |
| #277 | <div className="flex gap-3"> |
| #278 | <button |
| #279 | type="submit" |
| #280 | disabled={loading} |
| #281 | className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white font-semibold rounded-lg transition-colors text-sm" |
| #282 | > |
| #283 | {loading ? ( |
| #284 | <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" /> |
| #285 | ) : ( |
| #286 | "Issue Certificate" |
| #287 | )} |
| #288 | </button> |
| #289 | <button |
| #290 | type="button" |
| #291 | onClick={() => setPreview(!preview)} |
| #292 | className="flex items-center gap-2 px-4 py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium transition-colors" |
| #293 | > |
| #294 | <Eye className="w-4 h-4" /> |
| #295 | {preview ? "Hide" : "Preview"} |
| #296 | </button> |
| #297 | </div> |
| #298 | </form> |
| #299 | |
| #300 | {/* Preview */} |
| #301 | {preview && ( |
| #302 | <div className="bg-white rounded-xl border border-gray-200 p-4 overflow-auto"> |
| #303 | <p className="text-xs text-gray-400 mb-3">Live Preview — Template {form.templateId}</p> |
| #304 | <div style={{ transform: "scale(0.45)", transformOrigin: "top left", width: 1056 * 0.45, height: 816 * 0.45 }}> |
| #305 | <CertificateCard cert={previewCert} /> |
| #306 | </div> |
| #307 | </div> |
| #308 | )} |
| #309 | </div> |
| #310 | </div> |
| #311 | ); |
| #312 | } |
| #313 |