[TEST DONE] Checking Sertification generation for mentor/admin and view on intern
This commit is contained in:
+7
-1
@@ -1,10 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/sec2.png" />
|
<link rel="icon" type="image/svg+xml" href="/sec2.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Internify | Humic Engineering</title>
|
<title>Internify | Humic Engineering</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Great+Vibes&family=Grenze:ital,wght@0,100..900;1,100..900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-froala-wysiwyg": "^4.5.2",
|
"react-froala-wysiwyg": "^4.5.2",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import CertificateAvailable from "./components/CertificateAvailabe";
|
|||||||
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
||||||
import SertificateHistory from "./components/CertificateHistory";
|
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 { useProjectCertificates } from "../../../hooks/useCertificates";
|
import { useMyCertificates } from "../../../hooks/useCertificates";
|
||||||
import type { Project } from "../../../types/project.types";
|
import type { Project } from "../../../types/project.types";
|
||||||
|
|
||||||
export default function InternCertificateDetail () {
|
export default function InternCertificateDetail () {
|
||||||
@@ -17,8 +17,8 @@ export default function InternCertificateDetail () {
|
|||||||
const projectFromList = location.state?.project as Project | undefined;
|
const projectFromList = location.state?.project as Project | undefined;
|
||||||
|
|
||||||
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
|
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
|
||||||
const { certificates, loading: certLoading, refetch: refetchCertificates } = useProjectCertificates(projectDetail?.id ?? 0);
|
const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates();
|
||||||
const { claim, loading: claiming, error: claimError } = useClaimCertificate();
|
// const { claim, loading: claiming, error: claimError } = useClaimCertificate();
|
||||||
|
|
||||||
const myCertificateForProject = useMemo(() => {
|
const myCertificateForProject = useMemo(() => {
|
||||||
if (!projectDetail) return null;
|
if (!projectDetail) return null;
|
||||||
@@ -43,13 +43,13 @@ export default function InternCertificateDetail () {
|
|||||||
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
|
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
|
||||||
const isLoading = projectLoading || certLoading;
|
const isLoading = projectLoading || certLoading;
|
||||||
|
|
||||||
const handleClaim = async () => {
|
// const handleClaim = async () => {
|
||||||
if (!project?.id) return;
|
// if (!project?.id) return;
|
||||||
const result = await claim(project.id);
|
// const result = await claim(project.id);
|
||||||
if (result) {
|
// if (result) {
|
||||||
refetchCertificates();
|
// refetchCertificates();
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -78,9 +78,9 @@ export default function InternCertificateDetail () {
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
remainingTasks={remainingTasks}
|
remainingTasks={remainingTasks}
|
||||||
allTasksDone={allTasksDone}
|
allTasksDone={allTasksDone}
|
||||||
onClaim={handleClaim}
|
// onClaim={handleClaim}
|
||||||
claiming={claiming}
|
// claiming={claiming}
|
||||||
claimError={claimError}
|
// claimError={claimError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!templateUrl) return;
|
if (!templateUrl) return;
|
||||||
|
|
||||||
generateCertificate(resolveImageUrl(templateUrl), certificate.user.full_name)
|
generateCertificate(resolveImageUrl(templateUrl), certificate.user.full_name, certificate.project.project_name, certificate.certificate_no, certificate.uuid)
|
||||||
.then((blob) => {
|
.then((blob) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { History, Loader2, AlertCircle } from "lucide-react";
|
import { History, Loader2, AlertCircle } from "lucide-react";
|
||||||
import type { Certificate } from "../../../../types/certificate.types";
|
import type { Certificate } from "../../../../types/certificate.types";
|
||||||
import { useMyCertificates } from "../../../../hooks/useCertificates";
|
import { useMyCertificates } from "../../../../hooks/useCertificates";
|
||||||
|
|
||||||
export default function SertificateHistory() {
|
export default function SertificateHistory() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { certificates, loading, error } = useMyCertificates();
|
const { certificates, loading, error } = useMyCertificates();
|
||||||
const displayed = certificates.slice(0, 2);
|
const displayed = certificates.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
|
<div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
|
||||||
@@ -56,15 +54,6 @@ export default function SertificateHistory() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-full my-3 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/intern/certificates/all')}
|
|
||||||
className="text-sm text-red-700 font-semibold hover:underline"
|
|
||||||
>
|
|
||||||
See more
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,18 +5,18 @@ interface CertificateNotAvailableProps {
|
|||||||
progress: number;
|
progress: number;
|
||||||
remainingTasks: number;
|
remainingTasks: number;
|
||||||
allTasksDone?: boolean;
|
allTasksDone?: boolean;
|
||||||
onClaim: () => void;
|
// onClaim: () => void;
|
||||||
claiming: boolean;
|
// claiming: boolean;
|
||||||
claimError: string | null;
|
// claimError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CertificateNotAvailable({
|
export default function CertificateNotAvailable({
|
||||||
progress,
|
progress,
|
||||||
remainingTasks,
|
remainingTasks,
|
||||||
allTasksDone = false,
|
allTasksDone = false,
|
||||||
onClaim,
|
// onClaim,
|
||||||
claiming,
|
// claiming,
|
||||||
claimError,
|
// claimError,
|
||||||
}: CertificateNotAvailableProps) {
|
}: CertificateNotAvailableProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
|
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
|
||||||
@@ -54,14 +54,14 @@ export default function CertificateNotAvailable({
|
|||||||
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
|
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{/* <button
|
||||||
onClick={onClaim}
|
onClick={onClaim}
|
||||||
disabled={claiming}
|
disabled={claiming}
|
||||||
className="mt-0 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200"
|
className="mt-0 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200"
|
||||||
>
|
>
|
||||||
{claiming ? "Memproses..." : "Klaim Sertifikat"}
|
{claiming ? "Memproses..." : "Klaim Sertifikat"}
|
||||||
</button>
|
</button>
|
||||||
{claimError && <p className="text-red-500 text-sm mt-2">{claimError}</p>}
|
{claimError && <p className="text-red-500 text-sm mt-2">{claimError}</p>} */}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
|
|||||||
{/* Main nav */}
|
{/* Main nav */}
|
||||||
<nav className="flex flex-col gap-0.5">
|
<nav className="flex flex-col gap-0.5">
|
||||||
{navItems.map(({ label, icon: Icon, path }) => {
|
{navItems.map(({ label, icon: Icon, path }) => {
|
||||||
const isActive = location.pathname === path || location.pathname.startsWith(path + "/");
|
const isActive = location.pathname === path || location.pathname.startsWith(path + "/intern");
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={path}
|
key={path}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ 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
|
// Sudah ada submission dan tidak sedang edit -> tampilkan status
|
||||||
if (submission && !isEditing) {
|
if (submission && !isEditing) {
|
||||||
return (
|
return (
|
||||||
<SubmitStatusTable
|
<SubmitStatusTable
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
|
|||||||
{/* Main nav */}
|
{/* Main nav */}
|
||||||
<nav className="flex flex-col gap-0.5">
|
<nav className="flex flex-col gap-0.5">
|
||||||
{navItems.map(({ label, icon: Icon, path }) => {
|
{navItems.map(({ label, icon: Icon, path }) => {
|
||||||
const isActive = location.pathname === path || location.pathname.startsWith(path + "/");
|
const isActive = location.pathname === path || location.pathname.startsWith(path + "/mentor");
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={path}
|
key={path}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { FileText, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react";
|
import { FileText, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react";
|
||||||
import type { TaskSubmissionData, DisplayStatus } from "../../../../types/task.types";
|
import type { TaskSubmissionData, DisplayStatus } from "../../../../types/task.types";
|
||||||
|
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
|
||||||
|
|
||||||
// ─── StatusBadge ────────────────────────────────────────────────────────────
|
// StatusBadge
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: DisplayStatus }) {
|
export function StatusBadge({ status }: { status: DisplayStatus }) {
|
||||||
if (status === "submitted" || status === "done")
|
if (status === "submitted" || status === "done")
|
||||||
@@ -30,7 +31,7 @@ export function StatusBadge({ status }: { status: DisplayStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── StatusTable ────────────────────────────────────────────────────────────
|
// StatusTable
|
||||||
|
|
||||||
interface StatusTableProps {
|
interface StatusTableProps {
|
||||||
status: DisplayStatus;
|
status: DisplayStatus;
|
||||||
@@ -65,13 +66,18 @@ export function StatusTable({ status, submission }: StatusTableProps) {
|
|||||||
label: "File submission",
|
label: "File submission",
|
||||||
value: hasSubmission && submission?.file_path
|
value: hasSubmission && submission?.file_path
|
||||||
? (
|
? (
|
||||||
<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(submission.file_path)}
|
||||||
|
target="_blank"
|
||||||
|
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>{submission.file_path.split("/").pop()}</span>
|
<span>{submission.file_path.split("/").pop()}</span>
|
||||||
</div>
|
</a>
|
||||||
)
|
)
|
||||||
: hasSubmission && submission?.url_link
|
: hasSubmission && submission?.url_link
|
||||||
? (
|
? (
|
||||||
<a
|
<a
|
||||||
href={submission.url_link}
|
href={submission.url_link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -80,8 +86,8 @@ export function StatusTable({ status, submission }: StatusTableProps) {
|
|||||||
>
|
>
|
||||||
{submission.url_link}
|
{submission.url_link}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
: <span className="text-sm text-gray-400">-</span>,
|
: <span className="text-sm text-gray-400">-</span>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import { generateCertificate, resolveImageUrl } from "./SertificateGenerator";
|
|||||||
// ── 1. Preview di tab baru ────────────────────────────────────────────────────
|
// ── 1. Preview di tab baru ────────────────────────────────────────────────────
|
||||||
export const previewCertificate = async (
|
export const previewCertificate = async (
|
||||||
cert: Certificate,
|
cert: Certificate,
|
||||||
templateUrl: string
|
templateUrl: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Buka tab DULU sebelum await — supaya tidak diblokir popup blocker
|
// Buka tab DULU sebelum await — supaya tidak diblokir popup blocker
|
||||||
const tab = window.open("", "_blank");
|
const tab = window.open("", "_blank");
|
||||||
|
|
||||||
const blob = await generateCertificate(
|
const blob = await generateCertificate(
|
||||||
resolveImageUrl(templateUrl),
|
resolveImageUrl(templateUrl),
|
||||||
cert.user.full_name
|
cert.user.full_name,
|
||||||
|
cert.project.project_name,
|
||||||
|
cert.certificate_no,
|
||||||
|
cert.uuid,
|
||||||
);
|
);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
@@ -68,12 +71,15 @@ export const previewCertificate = async (
|
|||||||
// ── 2. Download PDF ───────────────────────────────────────────────────────────
|
// ── 2. Download PDF ───────────────────────────────────────────────────────────
|
||||||
export const downloadCertificatePdf = async (
|
export const downloadCertificatePdf = async (
|
||||||
cert: Certificate,
|
cert: Certificate,
|
||||||
templateUrl: string
|
templateUrl: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
||||||
const blob = await generateCertificate(
|
const blob = await generateCertificate(
|
||||||
resolveImageUrl(templateUrl),
|
resolveImageUrl(templateUrl),
|
||||||
cert.user.full_name
|
cert.user.full_name,
|
||||||
|
cert.project.project_name,
|
||||||
|
cert.certificate_no,
|
||||||
|
cert.uuid,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dataUrl: string = await new Promise((resolve, reject) => {
|
const dataUrl: string = await new Promise((resolve, reject) => {
|
||||||
@@ -91,7 +97,11 @@ export const downloadCertificatePdf = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const orientation = img.width > img.height ? "landscape" : "portrait";
|
const orientation = img.width > img.height ? "landscape" : "portrait";
|
||||||
const pdf = new jsPDF({ orientation, unit: "px", format: [img.width, img.height] });
|
const pdf = new jsPDF({
|
||||||
|
orientation,
|
||||||
|
unit: "px",
|
||||||
|
format: [img.width, img.height],
|
||||||
|
});
|
||||||
pdf.addImage(dataUrl, "PNG", 0, 0, img.width, img.height);
|
pdf.addImage(dataUrl, "PNG", 0, 0, img.width, img.height);
|
||||||
pdf.save(`${fileName}.pdf`);
|
pdf.save(`${fileName}.pdf`);
|
||||||
};
|
};
|
||||||
@@ -100,12 +110,15 @@ export const downloadCertificatePdf = async (
|
|||||||
export const downloadCertificateImage = async (
|
export const downloadCertificateImage = async (
|
||||||
cert: Certificate,
|
cert: Certificate,
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
format: "png" | "jpg" = "png"
|
format: "png" | "jpg" = "png",
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
||||||
const blob = await generateCertificate(
|
const blob = await generateCertificate(
|
||||||
resolveImageUrl(templateUrl),
|
resolveImageUrl(templateUrl),
|
||||||
cert.user.full_name
|
cert.user.full_name,
|
||||||
|
cert.project.project_name,
|
||||||
|
cert.certificate_no,
|
||||||
|
cert.uuid,
|
||||||
);
|
);
|
||||||
|
|
||||||
let finalBlob = blob;
|
let finalBlob = blob;
|
||||||
@@ -137,7 +150,7 @@ export const downloadCertificateImage = async (
|
|||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
(b) => (b ? resolve(b) : reject(new Error("toBlob gagal"))),
|
(b) => (b ? resolve(b) : reject(new Error("toBlob gagal"))),
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
0.95
|
0.95,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -155,7 +168,7 @@ export const downloadAllCertificatesZip = async (
|
|||||||
certificates: Certificate[],
|
certificates: Certificate[],
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
onProgress?: (done: number, total: number) => void
|
onProgress?: (done: number, total: number) => void,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const total = certificates.length;
|
const total = certificates.length;
|
||||||
@@ -164,11 +177,14 @@ export const downloadAllCertificatesZip = async (
|
|||||||
certificates.map(async (cert, i) => {
|
certificates.map(async (cert, i) => {
|
||||||
const blob = await generateCertificate(
|
const blob = await generateCertificate(
|
||||||
resolveImageUrl(templateUrl),
|
resolveImageUrl(templateUrl),
|
||||||
cert.user.full_name
|
cert.user.full_name,
|
||||||
|
cert.project.project_name,
|
||||||
|
cert.certificate_no,
|
||||||
|
cert.uuid,
|
||||||
);
|
);
|
||||||
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
|
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
|
||||||
onProgress?.(i + 1, total);
|
onProgress?.(i + 1, total);
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import api from "../../lib/api";
|
import api from "../../lib/api";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
// Flag supaya font hanya di-load sekali (tidak berulang tiap generate)
|
||||||
|
let fontsLoaded = false;
|
||||||
|
|
||||||
// src/utils/certificateGenerator.ts
|
|
||||||
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("http://") || path.startsWith("https://")) {
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
@@ -11,7 +15,7 @@ export function resolveImageUrl(path: string): string {
|
|||||||
return `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
|
return `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load Image dari URL ────────────────────────────────────────────────────────
|
// Load Image dari URL
|
||||||
export function loadImage(src: string): Promise<HTMLImageElement> {
|
export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -22,29 +26,74 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load kedua font dari Google Fonts (hanya sekali)
|
||||||
|
export async function ensureFontsLoaded(): Promise<void> {
|
||||||
|
// Trigger & tunggu font yang sudah diload via <link> di index.html
|
||||||
|
await Promise.all([
|
||||||
|
document.fonts.load('105px "Great Vibes"'),
|
||||||
|
document.fonts.load('54px "Grenze"'),
|
||||||
|
]);
|
||||||
|
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,
|
||||||
uuid: string,
|
certificateNo: string,
|
||||||
barcodeUrl: string
|
verifyUrl: string
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const img = await loadImage(templateUrl);
|
// Pastikan font siap sebelum menggambar teks
|
||||||
|
await ensureFontsLoaded();
|
||||||
|
|
||||||
|
const img = await loadImage(templateUrl);
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = img.naturalWidth;
|
canvas.width = img.naturalWidth;
|
||||||
canvas.height = img.naturalHeight;
|
canvas.height = img.naturalHeight;
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
// Tulis nama di tengah horizontal, posisi Y dari konstanta
|
|
||||||
ctx.font = `bold 48px Georgia, serif`;
|
|
||||||
ctx.fillStyle = "#800000";
|
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillText(internName, canvas.width / 2, canvas.height * 0.55);
|
|
||||||
|
// Nama intern — besar (Great Vibes)
|
||||||
|
ctx.font = `105px "Great Vibes", cursive`;
|
||||||
|
ctx.fillStyle = "#800000";
|
||||||
|
ctx.fillText(internName, canvas.width * 0.57, canvas.height * 0.48);
|
||||||
|
|
||||||
|
// Nama intern — versi kecil di pojok bawah
|
||||||
|
ctx.font = `30px "Great Vibes", cursive`;
|
||||||
|
ctx.fillStyle = "#800000";
|
||||||
|
ctx.fillText(internName, canvas.width * 0.825, canvas.height * 0.895);
|
||||||
|
|
||||||
|
// Nama project (Grenze)
|
||||||
|
ctx.font = `400 54px "Grenze", serif`;
|
||||||
|
ctx.fillStyle = "#090909";
|
||||||
|
ctx.fillText(projectName, canvas.width * 0.57, canvas.height * 0.615);
|
||||||
|
|
||||||
|
// Certificate no — besar (Grenze)
|
||||||
|
ctx.font = `50px "Grenze", serif`;
|
||||||
|
ctx.fillStyle = "#090909";
|
||||||
|
ctx.fillText(certificateNo, canvas.width * 0.666, canvas.height * 0.305);
|
||||||
|
|
||||||
|
// Certificate no — kecil di pojok bawah
|
||||||
|
ctx.font = `20px "Grenze", serif`;
|
||||||
|
ctx.fillStyle = "#800000";
|
||||||
|
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
|
||||||
|
|
||||||
|
// QR code — kalau verifyUrl diisi
|
||||||
|
// if (verifyUrl) {
|
||||||
|
// const qrDataUrl = await QRCode.toDataURL(verifyUrl, { width: 200, margin: 1 });
|
||||||
|
// const qrImg = await loadImage(qrDataUrl);
|
||||||
|
// const qrSize = canvas.width * 0.08;
|
||||||
|
// ctx.drawImage(
|
||||||
|
// qrImg,
|
||||||
|
// canvas.width * 0.1 - qrSize / 2, // posisi X — sesuaikan
|
||||||
|
// canvas.height * 0.88 - qrSize / 2, // posisi Y — sesuaikan
|
||||||
|
// qrSize,
|
||||||
|
// qrSize
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
|
|||||||
@@ -1,39 +1,61 @@
|
|||||||
<!-- test-cert.html — buka langsung di browser -->
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&family=Grenze:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="preview"></canvas>
|
<canvas id="preview" style="max-width: 100%;"></canvas>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// GANTI INI UNTUK MENGGUNAKAN TEMPLATE YANG BERBEDA
|
async function draw() {
|
||||||
const templateUrl = "http://localhost:9000/uploads/template.png"; // ganti URL template
|
// Tunggu semua font dari Google Fonts selesai load
|
||||||
|
await document.fonts.ready;
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = "anonymous";
|
img.crossOrigin = "anonymous";
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
const canvas = document.getElementById("preview");
|
const canvas = document.getElementById("preview");
|
||||||
canvas.width = img.naturalWidth;
|
canvas.width = img.naturalWidth;
|
||||||
canvas.height = img.naturalHeight;
|
canvas.height = img.naturalHeight;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
// Nama
|
// Nama intern — pakai Great Vibes (font kaligrafi/script)
|
||||||
ctx.font = "bold 48px Georgia, serif";
|
ctx.font = `105px "Great Vibes", cursive`;
|
||||||
ctx.fillStyle = "#800000";
|
ctx.fillStyle = "#800000";
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillText("Rafi Athallah", canvas.width / 2, canvas.height * 0.55);
|
ctx.fillText("Jonathan Kristina", canvas.width * 0.57, canvas.height * 0.48);
|
||||||
|
|
||||||
// Project
|
// Nama intern — versi kecil
|
||||||
ctx.font = "24px Georgia, serif";
|
ctx.font = `30px "Great Vibes", cursive`;
|
||||||
ctx.fillStyle = "#444444";
|
ctx.fillStyle = "#800000";
|
||||||
ctx.fillText("Internify Platform Dev", canvas.width / 2, canvas.height * 0.65);
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText("Jonathan Kristina", canvas.width * 0.825, canvas.height * 0.895);
|
||||||
|
|
||||||
// UUID
|
// Nama project — pakai Grenze
|
||||||
ctx.font = "16px monospace";
|
ctx.font = `400 54px "Grenze", serif`;
|
||||||
ctx.fillStyle = "#888888";
|
ctx.fillStyle = "#090909";
|
||||||
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width / 2, canvas.height * 0.88);
|
ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615);
|
||||||
};
|
|
||||||
img.src = templateUrl;
|
// Certificate no — monospace biasa
|
||||||
|
ctx.font = `50px Grenze`;
|
||||||
|
ctx.fillStyle = "#090909";
|
||||||
|
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.666, canvas.height * 0.305);
|
||||||
|
|
||||||
|
// Certificate no — monospace biasa
|
||||||
|
ctx.font = `20px Grenze`;
|
||||||
|
ctx.fillStyle = "#800000";
|
||||||
|
ctx.fillText("CERT/20260609/PRJ2/USR5",canvas.width * 0.825, canvas.height * 0.918);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = "http://localhost:9000/uploads/1783080498359-420570422.png"; // ganti URL template kamu
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+1
-1
@@ -31,7 +31,7 @@
|
|||||||
--color-primary-foreground: #ffffff;
|
--color-primary-foreground: #ffffff;
|
||||||
--color-primary-hover: #84281c;
|
--color-primary-hover: #84281c;
|
||||||
|
|
||||||
--color-red: #e82222;
|
--color-red: #9f1e1e;
|
||||||
--color-red-foreground: #fef2f2;
|
--color-red-foreground: #fef2f2;
|
||||||
|
|
||||||
--color-border: #a8a9aa;
|
--color-border: #a8a9aa;
|
||||||
|
|||||||
Reference in New Issue
Block a user