Generate Certificates: Adding QR with UUID of certificates to verify
This commit is contained in:
+10
-1
@@ -75,6 +75,15 @@ const Navbar = () => {
|
|||||||
>
|
>
|
||||||
Internship
|
Internship
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/login-internify");
|
||||||
|
}}
|
||||||
|
className="block w-full text-left hover:text-red-600 transition"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,7 +123,7 @@ const Navbar = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate("/login-internify");
|
navigate("/login-internify");
|
||||||
}}
|
}}
|
||||||
className="block w-full text-left hover:text-blue-600 transition"
|
className="block w-full text-left hover:text-red-600 transition"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useCertificateVerification } from "../../hooks/useCertificates";
|
||||||
|
|
||||||
|
export default function VerificationCert() {
|
||||||
|
const { uuid } = useParams<{ uuid: string }>();
|
||||||
|
const { verification, loading, error } = useCertificateVerification(uuid ?? "");
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container flex items-center justify-center min-h-screen">
|
||||||
|
<p className="text-gray-500 animate-pulse">Memverifikasi sertifikat...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !verification) {
|
||||||
|
return (
|
||||||
|
<div className="container flex flex-col items-center justify-center min-h-screen text-center px-4">
|
||||||
|
<h1 className="text-2xl font-bold text-red-600 mb-2">Sertifikat Tidak Valid</h1>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Sertifikat dengan UUID ini tidak dapat ditemukan atau sudah tidak berlaku.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container flex items-center justify-center min-h-screen px-4">
|
||||||
|
<div className="bg-white shadow-md rounded-lg p-8 max-w-md w-full">
|
||||||
|
<h1 className="text-2xl font-bold text-green-600 mb-6 text-center">
|
||||||
|
Sertifikat Valid
|
||||||
|
</h1>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Nama Intern</span>
|
||||||
|
<p className="font-semibold">{verification.intern_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Proyek</span>
|
||||||
|
<p className="font-semibold">{verification.project_name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Tanggal Terbit</span>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{new Date(verification.created_at).toLocaleDateString("id-ID", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">UUID</span>
|
||||||
|
<p className="font-mono text-xs break-all">{verification.uuid}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import SertificateHistory from "./components/CertificateHistory";
|
|||||||
|
|
||||||
// import { useClaimCertificate } from "../../../hooks/useCertificates";
|
// import { useClaimCertificate } from "../../../hooks/useCertificates";
|
||||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||||
|
|
||||||
import { useMyCertificates } from "../../../hooks/useCertificates";
|
import { useMyCertificates } from "../../../hooks/useCertificates";
|
||||||
import type { Project } from "../../../types/project.types";
|
import type { Project } from "../../../types/project.types";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FileText, FileUp, Link } from "lucide-react";
|
import { FileText, FileUp, Link } from "lucide-react";
|
||||||
|
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
|
||||||
|
|
||||||
interface SubmitStatusTableProps {
|
interface SubmitStatusTableProps {
|
||||||
type: "file" | "link";
|
type: "file" | "link";
|
||||||
filePath?: string | null; // ganti dari files: File[]
|
filePath?: string | null;
|
||||||
link?: string;
|
link?: string;
|
||||||
submittedAt?: Date;
|
submittedAt?: Date;
|
||||||
deadline?: Date;
|
deadline?: Date;
|
||||||
@@ -66,7 +67,7 @@ export default function SubmitStatusTable({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmDelete(false)}
|
onClick={() => setConfirmDelete(false)}
|
||||||
className="text-xs font-medium px-4 py-1.5 rounded-lg bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
|
className="text-xs font-medium px-4 py-1.5 rounded-lg bg-card border border-card-outline text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -106,7 +107,7 @@ export default function SubmitStatusTable({
|
|||||||
key={row.label}
|
key={row.label}
|
||||||
className="grid grid-cols-[180px_1fr] border-b border-box-border"
|
className="grid grid-cols-[180px_1fr] border-b border-box-border"
|
||||||
>
|
>
|
||||||
<div className="px-4 py-3 text-sm text-gray-500 border-r border-box-border">
|
<div className="px-4 py-3 text-sm text-font border-r border-box-border">
|
||||||
{row.label}
|
{row.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 text-sm text-gray-800">{row.value}</div>
|
<div className="px-4 py-3 text-sm text-gray-800">{row.value}</div>
|
||||||
@@ -115,15 +116,20 @@ export default function SubmitStatusTable({
|
|||||||
|
|
||||||
{/* File / Link row */}
|
{/* File / Link row */}
|
||||||
<div className="grid grid-cols-[180px_1fr]">
|
<div className="grid grid-cols-[180px_1fr]">
|
||||||
<div className="px-4 py-3 text-sm text-gray-500 border-r border-box-border">
|
<div className="px-4 py-3 text-sm text-font border-r border-box-border">
|
||||||
{type === "file" ? "File submission" : "Link submission"}
|
{type === "file" ? "File submission" : "Link submission"}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 flex flex-col gap-2">
|
<div className="px-4 py-3 flex flex-col gap-2">
|
||||||
{type === "file" ? (
|
{type === "file" ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-800">
|
<a
|
||||||
<FileText className="w-4 h-4 text-red-700 stroke-[2.5]" />
|
href={resolveImageUrl(filePath ?? "")}
|
||||||
{filePath ? filePath.split("/").pop() : "File submitted"}
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 stroke-[2.5]" />
|
||||||
|
<span>{resolveImageUrl(filePath ?? "").split("/").pop()}</span>
|
||||||
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<a href={link} target="_blank" rel="noopener noreferrer"
|
<a href={link} target="_blank" rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 text-sm text-blue-600 hover:underline break-all">
|
className="flex items-center gap-1.5 text-sm text-blue-600 hover:underline break-all">
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
|||||||
if (ok) { setFiles([]); setIsEditing(false); }
|
if (ok) { setFiles([]); setIsEditing(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sudah ada submission dan tidak sedang edit -> tampilkan status
|
// Batal edit — kembali ke tampilan status tanpa kehilangan submission lama
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setFiles([]);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (submission && !isEditing) {
|
if (submission && !isEditing) {
|
||||||
return (
|
return (
|
||||||
<SubmitStatusTable
|
<SubmitStatusTable
|
||||||
@@ -79,6 +84,8 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentFileName = submission?.file_path?.split("/").pop();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<input
|
<input
|
||||||
@@ -97,6 +104,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{files.length > 0 ? (
|
{files.length > 0 ? (
|
||||||
|
// File baru sudah dipilih — tampilkan itu (menggantikan file lama)
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{files.map((file, index) => (
|
{files.map((file, index) => (
|
||||||
<div key={index} className="flex items-center justify-between text-sm font-medium text-gray-800" onClick={(e) => e.stopPropagation()}>
|
<div key={index} className="flex items-center justify-between text-sm font-medium text-gray-800" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -110,6 +118,17 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : isEditing && currentFileName ? (
|
||||||
|
// Mode edit, belum pilih file baru — tampilkan file submission lama sebagai referensi
|
||||||
|
<div className="flex flex-col items-center justify-center h-full space-y-2 text-center py-6">
|
||||||
|
<FileText className="w-6 h-6 text-gray-400" />
|
||||||
|
<p className="text-sm font-semibold text-gray-700">
|
||||||
|
File saat ini: <span className="text-red-700">{currentFileName}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-gray-400 font-medium pointer-events-none">
|
||||||
|
Klik atau drag & drop untuk mengganti file
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center h-full space-y-2 pointer-events-none text-center py-6">
|
<div className="flex flex-col items-center justify-center h-full space-y-2 pointer-events-none text-center py-6">
|
||||||
<UploadCloud className="w-8 h-8 text-gray-400 mx-auto stroke-[1.5]" />
|
<UploadCloud className="w-8 h-8 text-gray-400 mx-auto stroke-[1.5]" />
|
||||||
@@ -118,13 +137,25 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="submit" disabled={loading}
|
<div className="flex gap-2">
|
||||||
className="w-full bg-[#B30000] hover:bg-[#990000] disabled:opacity-60 text-white font-bold py-3 rounded-xl transition-colors shadow-md flex items-center justify-center gap-2 tracking-wide cursor-pointer"
|
{isEditing && (
|
||||||
>
|
<button
|
||||||
{loading ? "Mengirim..." : isEditing ? "Update Submission" : "Save Changes"}
|
type="button"
|
||||||
<LuSendHorizontal className="w-4 h-4 stroke-[2.5]" />
|
onClick={handleCancelEdit}
|
||||||
</button>
|
className="w-full bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-bold py-3 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit" disabled={loading}
|
||||||
|
className="w-full bg-[#B30000] hover:bg-[#990000] disabled:opacity-60 text-white font-bold py-3 rounded-xl transition-colors shadow-md flex items-center justify-center gap-2 tracking-wide cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? "Mengirim..." : isEditing ? "Update Submission" : "Save Changes"}
|
||||||
|
<LuSendHorizontal className="w-4 h-4 stroke-[2.5]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
|
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
|
||||||
import { useProjectDetail } from "../../../../hooks/useProjects";
|
import { useProjectDetail } from "../../../../hooks/useProjects";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { generateCertificate } from "../../../utils/SertificateGenerator";
|
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
|
||||||
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
|
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
|
||||||
import Avatar from "../../Avatar";
|
import Avatar from "../../Avatar";
|
||||||
import ProgressBar from "../../ProgressBar";
|
import ProgressBar from "../../ProgressBar";
|
||||||
@@ -40,15 +40,15 @@ function StatusBadge({ status }: { status: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Main Component
|
// Main Component
|
||||||
export default function EligibleInternTable({
|
export default function EligibleInternTable({
|
||||||
interns,
|
interns,
|
||||||
loading,
|
loading,
|
||||||
eligibleCount,
|
eligibleCount,
|
||||||
projectId
|
projectId
|
||||||
}: EligibleInternTableProps) {
|
}: EligibleInternTableProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { project } = useProjectDetail(String(projectId));
|
const { project } = useProjectDetail(String(projectId));
|
||||||
const { generate: SubmitCertificates, loading: generatingCertificates, error, success, reset } = useGenerateCertificates();
|
const { generate: SubmitCertificates } = useGenerateCertificates();
|
||||||
|
|
||||||
const initialSelected = new Set(
|
const initialSelected = new Set(
|
||||||
interns.filter(i => i.is_eligible).map(i => i.id_user)
|
interns.filter(i => i.is_eligible).map(i => i.id_user)
|
||||||
@@ -57,7 +57,6 @@ export default function EligibleInternTable({
|
|||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Halaman (Pagination)
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const totalPages = Math.ceil(interns.length / PER_PAGE);
|
const totalPages = Math.ceil(interns.length / PER_PAGE);
|
||||||
const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||||
@@ -78,48 +77,52 @@ export default function EligibleInternTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!project?.certificate_template) {
|
if (!project?.certificate_template) {
|
||||||
setGenerateError("Template sertifikat belum diupload.");
|
setGenerateError("Template sertifikat belum diupload.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selected.size === 0) {
|
if (selected.size === 0) {
|
||||||
setGenerateError("Pilih minimal satu intern.");
|
setGenerateError("Pilih minimal satu intern.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setGenerateError(null);
|
setGenerateError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Generate blob PNG per intern & kumpulkan ke ZIP
|
const result = await SubmitCertificates(projectId, Array.from(selected));
|
||||||
const selectedInterns = interns.filter((i) => selected.has(i.id_user));
|
if (!result) return;
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
await Promise.all(
|
const zip = new JSZip();
|
||||||
selectedInterns.map(async (intern) => {
|
const templateUrl = resolveImageUrl(project.certificate_template);
|
||||||
const blob = await generateCertificate(
|
|
||||||
project.certificate_template,
|
await Promise.all(
|
||||||
intern.full_name
|
result.map(async (cert) => {
|
||||||
|
const verifyUrl = `${window.location.origin}/verify/${cert.uuid}`;
|
||||||
|
|
||||||
|
const blob = await generateCertificate(
|
||||||
|
templateUrl,
|
||||||
|
cert.user.full_name,
|
||||||
|
cert.project.project_name,
|
||||||
|
cert.certificate_no,
|
||||||
|
verifyUrl
|
||||||
);
|
);
|
||||||
zip.file(`Sertifikat - ${intern.full_name}.png`, blob);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. POST ke backend untuk mencatat sertifikat sudah di-generate
|
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
|
||||||
const ok = await SubmitCertificates(projectId, Array.from(selected));
|
})
|
||||||
if (!ok) return;
|
);
|
||||||
|
|
||||||
// 3. Navigasi ke result page
|
// 3. Navigasi ke result page
|
||||||
navigate(`/mentor/certificates/${projectId}/result`);
|
navigate(`/mentor/certificates/${projectId}/result`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setGenerateError(
|
setGenerateError(
|
||||||
err instanceof Error ? err.message : "Gagal generate sertifikat."
|
err instanceof Error ? err.message : "Gagal generate sertifikat."
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -281,10 +284,10 @@ export default function EligibleInternTable({
|
|||||||
{/* Bottom Action Bar */}
|
{/* Bottom Action Bar */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
className="text-gray-700 hover:underline"
|
className="text-gray-700 hover:underline"
|
||||||
onClick={()=> navigate(`/mentor/certificates/${project?.slug}/result`)}>
|
onClick={() => navigate(`/mentor/certificates/${project?.slug}/result`)}>
|
||||||
Ke Hasil
|
Ke Hasil
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={generating || selected.size === 0}
|
disabled={generating || selected.size === 0}
|
||||||
@@ -302,7 +305,7 @@ export default function EligibleInternTable({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import api from "../../lib/api";
|
import api from "../../lib/api";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
// Flag supaya font hanya di-load sekali (tidak berulang tiap generate)
|
|
||||||
let fontsLoaded = false;
|
|
||||||
|
|
||||||
export function resolveImageUrl(path: string): string {
|
export function resolveImageUrl(path: string): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path.startsWith("data:")) return path; // untuk QR data URL
|
if (path.startsWith("data:")) return path; // untuk QR data URL
|
||||||
@@ -36,13 +33,13 @@ export async function ensureFontsLoaded(): Promise<void> {
|
|||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generate satu sertifikat sebagai Blob ─────────────────────────────────────
|
// Generate satu sertifikat sebagai Blob
|
||||||
export async function generateCertificate(
|
export async function generateCertificate(
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
internName: string,
|
internName: string,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
certificateNo: string,
|
certificateNo: string,
|
||||||
verifyUrl: string
|
verifyUrl: string,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
// Pastikan font siap sebelum menggambar teks
|
// Pastikan font siap sebelum menggambar teks
|
||||||
await ensureFontsLoaded();
|
await ensureFontsLoaded();
|
||||||
@@ -82,23 +79,24 @@ export async function generateCertificate(
|
|||||||
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
|
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
|
||||||
|
|
||||||
// QR code — kalau verifyUrl diisi
|
// QR code — kalau verifyUrl diisi
|
||||||
// if (verifyUrl) {
|
const qrDataUrl = await QRCode.toDataURL(verifyUrl, {
|
||||||
// const qrDataUrl = await QRCode.toDataURL(verifyUrl, { width: 200, margin: 1 });
|
width: 200,
|
||||||
// const qrImg = await loadImage(qrDataUrl);
|
margin: 1,
|
||||||
// const qrSize = canvas.width * 0.08;
|
});
|
||||||
// ctx.drawImage(
|
const qrImg = await loadImage(qrDataUrl);
|
||||||
// qrImg,
|
const qrSize = canvas.width * 0.103;
|
||||||
// canvas.width * 0.1 - qrSize / 2, // posisi X — sesuaikan
|
ctx.drawImage(
|
||||||
// canvas.height * 0.88 - qrSize / 2, // posisi Y — sesuaikan
|
qrImg,
|
||||||
// qrSize,
|
canvas.width * 0.77 - qrSize / 2, // posisi X — sesuaikan
|
||||||
// qrSize
|
canvas.height * 0.715 - qrSize / 2, // posisi Y — sesuaikan
|
||||||
// );
|
qrSize,
|
||||||
// }
|
qrSize,
|
||||||
|
);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(blob) => (blob ? resolve(blob) : reject(new Error("toBlob gagal"))),
|
(blob) => (blob ? resolve(blob) : reject(new Error("toBlob gagal"))),
|
||||||
"image/png"
|
"image/png",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,52 +7,63 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="preview" style="max-width: 100%;"></canvas>
|
<canvas id="preview" style="max-width: 100%;"></canvas>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Helper: bungkus loading image jadi Promise, biar bisa di-await
|
||||||
|
function loadImage(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function draw() {
|
async function draw() {
|
||||||
// Tunggu semua font dari Google Fonts selesai load
|
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
|
|
||||||
const img = new Image();
|
// Load template sertifikat & QR dummy secara paralel
|
||||||
img.crossOrigin = "anonymous";
|
const [templateImg, qrImg] = await Promise.all([
|
||||||
img.onload = () => {
|
loadImage("http://localhost:9000/uploads/1783080498359-420570422.png"),
|
||||||
const canvas = document.getElementById("preview");
|
loadImage("https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=dummy-uuid-preview")
|
||||||
canvas.width = img.naturalWidth;
|
]);
|
||||||
canvas.height = img.naturalHeight;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
|
|
||||||
// Nama intern — pakai Great Vibes (font kaligrafi/script)
|
const canvas = document.getElementById("preview");
|
||||||
ctx.font = `105px "Great Vibes", cursive`;
|
canvas.width = templateImg.naturalWidth;
|
||||||
ctx.fillStyle = "#800000";
|
canvas.height = templateImg.naturalHeight;
|
||||||
ctx.textAlign = "center";
|
const ctx = canvas.getContext("2d");
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
ctx.fillText("Jonathan Kristina", canvas.width * 0.57, canvas.height * 0.48);
|
|
||||||
|
|
||||||
// Nama intern — versi kecil
|
// 1. Gambar template dulu (paling belakang)
|
||||||
ctx.font = `30px "Great Vibes", cursive`;
|
ctx.drawImage(templateImg, 0, 0);
|
||||||
ctx.fillStyle = "#800000";
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
ctx.fillText("Jonathan Kristina", canvas.width * 0.825, canvas.height * 0.895);
|
|
||||||
|
|
||||||
// Nama project — pakai Grenze
|
// 2. Nama intern — Great Vibes
|
||||||
ctx.font = `400 54px "Grenze", serif`;
|
ctx.font = `105px "Great Vibes", cursive`;
|
||||||
ctx.fillStyle = "#090909";
|
ctx.fillStyle = "#800000";
|
||||||
ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615);
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText("Jonathan Kristina", canvas.width * 0.57, canvas.height * 0.48);
|
||||||
|
|
||||||
// Certificate no — monospace biasa
|
ctx.font = `30px "Great Vibes", cursive`;
|
||||||
ctx.font = `50px Grenze`;
|
ctx.fillText("Jonathan Kristina", canvas.width * 0.825, canvas.height * 0.895);
|
||||||
ctx.fillStyle = "#090909";
|
|
||||||
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.666, canvas.height * 0.305);
|
|
||||||
|
|
||||||
// Certificate no — monospace biasa
|
// 3. Nama project — Grenze
|
||||||
ctx.font = `20px Grenze`;
|
ctx.font = `400 54px "Grenze", serif`;
|
||||||
ctx.fillStyle = "#800000";
|
ctx.fillStyle = "#090909";
|
||||||
ctx.fillText("CERT/20260609/PRJ2/USR5",canvas.width * 0.825, canvas.height * 0.918);
|
ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615);
|
||||||
};
|
|
||||||
|
|
||||||
img.src = "http://localhost:9000/uploads/1783080498359-420570422.png"; // ganti URL template kamu
|
// 4. Certificate no
|
||||||
|
ctx.font = `50px Grenze`;
|
||||||
|
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.665, canvas.height * 0.305);
|
||||||
|
|
||||||
|
ctx.font = `20px Grenze`;
|
||||||
|
ctx.fillStyle = "#800000";
|
||||||
|
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.825, canvas.height * 0.918);
|
||||||
|
|
||||||
|
// 5. QR code — posisi & ukuran pakai persentase, sama seperti elemen lain
|
||||||
|
const qrSize = canvas.width * 0.103; // atur besar-kecil di sini
|
||||||
|
const qrX = canvas.width * 0.77; // titik kiri-atas QR (bukan tengah!)
|
||||||
|
const qrY = canvas.height * 0.715;
|
||||||
|
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
draw();
|
draw();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// hooks/useCertificates.ts
|
// hooks/useCertificates.ts
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import type { Certificate } from "../types/certificate.types";
|
import type { Certificate, CertificateVerifyResult } from "../types/certificate.types";
|
||||||
import {
|
import {
|
||||||
claimCertificate,
|
claimCertificate,
|
||||||
getMyCertificates,
|
getMyCertificates,
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
getCertificateDetail,
|
getCertificateDetail,
|
||||||
getCertificatesByProject,
|
getCertificatesByProject,
|
||||||
uploadCertificateTemplate,
|
uploadCertificateTemplate,
|
||||||
generateCertificate
|
generateCertificate,
|
||||||
|
verifyCertificateByUuid
|
||||||
} from "../services/CertificateService";
|
} from "../services/CertificateService";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
@@ -106,20 +107,26 @@ export const useGenerateCertificates = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
const generate = async (id_project: number, id_users: number[]): Promise<boolean> => {
|
const generate = async (
|
||||||
|
id_project: number,
|
||||||
|
id_users: number[]
|
||||||
|
): Promise<Certificate[] | null> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
try {
|
try {
|
||||||
await generateCertificate(id_project, id_users);
|
const data = await generateCertificate(id_project, id_users);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
return true;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
switch (err.response?.status) {
|
switch (err.response?.status) {
|
||||||
case 400:
|
case 400:
|
||||||
setError("Ada intern yang tidak eligible atau parameter tidak lengkap.");
|
setError("Ada intern yang tidak eligible atau parameter tidak lengkap.");
|
||||||
break;
|
break;
|
||||||
|
case 401:
|
||||||
|
setError("Sesi kamu berakhir, silakan login ulang.");
|
||||||
|
break;
|
||||||
case 403:
|
case 403:
|
||||||
setError("Hanya mentor/admin project ini yang bisa generate sertifikat.");
|
setError("Hanya mentor/admin project ini yang bisa generate sertifikat.");
|
||||||
break;
|
break;
|
||||||
@@ -132,7 +139,7 @@ export const useGenerateCertificates = () => {
|
|||||||
} else {
|
} else {
|
||||||
setError("Gagal generate sertifikat.");
|
setError("Gagal generate sertifikat.");
|
||||||
}
|
}
|
||||||
return false;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -159,4 +166,21 @@ export const useUploadCertificateTemplate = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return { upload, loading, error };
|
return { upload, loading, error };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useCertificateVerification = (uuid: string) => {
|
||||||
|
const [verification, setVerification] = useState<CertificateVerifyResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uuid) return;
|
||||||
|
verifyCertificateByUuid(uuid)
|
||||||
|
.then(setVerification)
|
||||||
|
.catch(() => setError("Gagal memverifikasi sertifikat."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [uuid]);
|
||||||
|
|
||||||
|
return { verification, loading, error };
|
||||||
|
|
||||||
|
}
|
||||||
@@ -12,9 +12,10 @@ export const claimCertificate = async (id_project: number): Promise<Certificate>
|
|||||||
return res.data.data;
|
return res.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateCertificate = async (id_project: number, id_users: number[]): Promise<void> => {
|
// POST /certificate-api/generate
|
||||||
|
export const generateCertificate = async (id_project: number, id_users: number[]): Promise<Certificate[]> => {
|
||||||
const res = await api.post("/certificate-api/generate", { id_project, id_users });
|
const res = await api.post("/certificate-api/generate", { id_project, id_users });
|
||||||
return res.data.data;
|
return res.data.data.certificates;
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET /certificate-api/my-certificates
|
// GET /certificate-api/my-certificates
|
||||||
|
|||||||
@@ -32,4 +32,11 @@ export interface CertificateVerification {
|
|||||||
export interface CertificateTemplate {
|
export interface CertificateTemplate {
|
||||||
id_project: number;
|
id_project: number;
|
||||||
certificate_template: string;
|
certificate_template: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertificateVerifyResult {
|
||||||
|
uuid: string;
|
||||||
|
intern_name: string;
|
||||||
|
project_name: string;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user