From 371477715ba595aeb45c77f9c4ac31dcd0845334 Mon Sep 17 00:00:00 2001 From: marcus-oss Date: Mon, 7 Sep 2026 20:06:42 -0300 Subject: [PATCH 1/2] feat: add CertificateModelPage and route for selecting certificate models --- src/App.tsx | 4 + src/pages/CertificateModel.tsx | 617 +++++++++++++++++++++++++++++++++ 2 files changed, 621 insertions(+) create mode 100644 src/pages/CertificateModel.tsx diff --git a/src/App.tsx b/src/App.tsx index 305e877..613d429 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,9 @@ const ProfilePage = lazy(() => })) ); +const CertificateModelPage = lazy( + () => import("./pages/CertificateModel") +); function App() { return ( }> @@ -71,6 +74,7 @@ function App() { } /> } /> } /> + } /> }> }> diff --git a/src/pages/CertificateModel.tsx b/src/pages/CertificateModel.tsx new file mode 100644 index 0000000..1bb087a --- /dev/null +++ b/src/pages/CertificateModel.tsx @@ -0,0 +1,617 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import MedalCertificate from "../assets/MedalCertificate.svg"; +import Logo from "@/assets/Logo.svg"; + +interface CertificateModel { + id: string; + name: string; + image: string; +} + +const certificateModels: CertificateModel[] = [ + { + id: "modern", + name: "Certificado Moderno", + image: MedalCertificate, + }, + { + id: "classic", + name: "Certificado Clássico", + image: MedalCertificate, + }, + { + id: "borderless", + name: "Certificado Sem Borda", + image: MedalCertificate, + }, + { + id: "ornamental", + name: "Certificado Ornamental", + image: MedalCertificate, + }, +]; + +interface CertificateModelCardProps { + model: CertificateModel; + selected: boolean; + onSelect: (id: string) => void; +} + +const CertificateModelCard = ({ + model, + selected, + onSelect, +}: CertificateModelCardProps) => { + return ( + + ); +}; + +const CertificateModelPage = () => { + const navigate = useNavigate(); + + const [selectedModel, setSelectedModel] = + useState(null); + + const [isModalOpen, setIsModalOpen] = + useState(false); + + const handleOpenModal = () => { + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + }; + + const handleSelectModel = (modelId: string) => { + setSelectedModel(modelId); + }; + + const handleContinue = () => { + if (!selectedModel) { + return; + } + + navigate("/proxima-etapa", { + state: { + certificateModel: selectedModel, + }, + }); + }; + + const handleBack = () => { + navigate(-1); + }; + + const selectedModelData = certificateModels.find( + (model) => model.id === selectedModel + ); + + return ( +
+ + +
+
+
+ Certify Logo + + +
+ +
+

+ Área da empresa +

+ +

+ Certificados +

+
+ +
+ AB +
+
+ +
+
+

+ Certificados +

+
+
+
+ + {isModalOpen && ( +
{ + if (event.target === event.currentTarget) { + handleCloseModal(); + } + }} + > +
+
+
+

+ Certificados +

+ +

+ Escolha o modelo do certificado +

+ +

+ Selecione o layout que será utilizado na emissão do certificado. +

+
+ + +
+ +
+
+ {certificateModels.map((model) => ( +
+ +
+ ))} +
+ + {!selectedModel && ( +
+ A seleção de um modelo é obrigatória para continuar. +
+ )} +
+ +
+
+ {selectedModelData ? ( +

+ Modelo selecionado:{" "} + + {selectedModelData.name} + +

+ ) : ( +

+ Nenhum modelo selecionado +

+ )} +
+ +
+ + + +
+
+
+
+ )} +
+ ); +}; + +export default CertificateModelPage; From 08bbf0607eb0d736e3258ade953a80f89321af4a Mon Sep 17 00:00:00 2001 From: marcus-oss Date: Thu, 10 Sep 2026 17:31:45 -0300 Subject: [PATCH 2/2] feat: add CertificateDetails page and route for viewing certificate details --- src/App.tsx | 23 + src/pages/CertificateDetails.tsx | 786 +++++++++++++++++++++++++++++++ 2 files changed, 809 insertions(+) create mode 100644 src/pages/CertificateDetails.tsx diff --git a/src/App.tsx b/src/App.tsx index 613d429..f57c6b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,6 +64,25 @@ const ProfilePage = lazy(() => const CertificateModelPage = lazy( () => import("./pages/CertificateModel") ); + +const CertificateDetails = lazy(() => + import("./pages/Certificatedetails").then((m) => ({ + default: m.CertificateDetails, + })) +); + +const mockCertificate = { + id: "1", + studentName: "MARIA DA SILVA", + issueDate: "2026-03-08", + courseName: "Desenvolvimento Front-end", + workload: "40 horas", + authenticityCode: "DJFEJ338-94320", + title: "Certificado de Conclusão", + institution: "Certify", + signature: "", +}; + function App() { return ( }> @@ -75,6 +94,10 @@ function App() { } /> } /> } /> + } +/> }> }> diff --git a/src/pages/CertificateDetails.tsx b/src/pages/CertificateDetails.tsx new file mode 100644 index 0000000..89750f0 --- /dev/null +++ b/src/pages/CertificateDetails.tsx @@ -0,0 +1,786 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + FiAlertCircle, + FiCheck, + FiDownload, + FiLinkedin, + FiMail, + FiX, +} from "react-icons/fi"; +import Logo from "@/assets/Logo.svg"; + +interface Certificate { + id: string; + studentName: string; + issueDate: string; + courseName: string; + workload: string; + authenticityCode: string; + title: string; + institution: string; + signature?: string; + acceptedDate?: string; + lastUpdated?: string; +} + +interface CertificateDetailsProps { + certificate: Certificate; +} + +type VerificationStatus = "success" | "loading" | "error"; + +interface VerificationStep { + id: string; + label: string; + status: VerificationStatus; + critical?: boolean; +} + +interface VerificationModalProps { + isOpen: boolean; + onClose: () => void; + steps: VerificationStep[]; +} + +function VerificationModal({ + isOpen, + onClose, + steps, +}: VerificationModalProps) { + const closeButtonRef = useRef(null); + const modalRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const previousActiveElement = + document.activeElement as HTMLElement | null; + + document.body.style.overflow = "hidden"; + + setTimeout(() => { + closeButtonRef.current?.focus(); + }, 0); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + + if (event.key !== "Tab") return; + + const modal = modalRef.current; + + if (!modal) return; + + const focusableElements = + modal.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + + if (!focusableElements.length) return; + + const firstElement = focusableElements[0]; + const lastElement = + focusableElements[focusableElements.length - 1]; + + if ( + event.shiftKey && + document.activeElement === firstElement + ) { + event.preventDefault(); + lastElement.focus(); + } + + if ( + !event.shiftKey && + document.activeElement === lastElement + ) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.body.style.overflow = ""; + + document.removeEventListener( + "keydown", + handleKeyDown + ); + + previousActiveElement?.focus(); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const hasError = steps.some( + (step) => + step.critical && step.status === "error" + ); + + const allSuccess = + steps.length > 0 && + steps.every( + (step) => step.status === "success" + ); + + const overallStatus = hasError + ? "Falha na verificação" + : allSuccess + ? "VERIFICADO" + : "Verificação em andamento"; + + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
+
+

+ Verificação +

+ + +
+ +
+
+

+ Status da autenticidade do certificado +

+ +

+ {overallStatus} +

+
+ +
+ {steps.map((step) => ( +
+
+ {step.status === "success" && ( + + + )} + + {step.status === "loading" && ( + + )} + + {step.status === "error" && ( + + + )} +
+ +

+ {step.label} +

+
+ ))} +
+ + {hasError && ( +
+
+ )} +
+ +
+ +
+
+
+ ); +} + +export function CertificateDetails({ + certificate, +}: CertificateDetailsProps) { + const [isDownloading, setIsDownloading] = + useState(false); + + const [isVerificationOpen, setIsVerificationOpen] = + useState(false); + + const [verificationSteps, setVerificationSteps] = + useState([]); + + const initials = useMemo(() => { + return certificate.studentName + .trim() + .split(/\s+/) + .slice(0, 2) + .map((name) => name.charAt(0)) + .join("") + .toUpperCase(); + }, [certificate.studentName]); + + const formattedDate = useMemo(() => { + return new Intl.DateTimeFormat( + navigator.language || "pt-BR", + { + day: "2-digit", + month: "long", + year: "numeric", + } + ).format(new Date(certificate.issueDate)); + }, [certificate.issueDate]); + + const acceptedDate = useMemo(() => { + if (!certificate.acceptedDate) { + return formattedDate; + } + + return new Intl.DateTimeFormat( + navigator.language || "pt-BR", + { + day: "2-digit", + month: "long", + year: "numeric", + } + ).format(new Date(certificate.acceptedDate)); + }, [ + certificate.acceptedDate, + formattedDate, + ]); + + const lastUpdated = useMemo(() => { + if (!certificate.lastUpdated) { + return formattedDate; + } + + return new Intl.DateTimeFormat( + navigator.language || "pt-BR", + { + day: "2-digit", + month: "long", + year: "numeric", + } + ).format(new Date(certificate.lastUpdated)); + }, [ + certificate.lastUpdated, + formattedDate, + ]); + + const createVerificationSteps = + (): VerificationStep[] => [ + { + id: "issue-date", + label: `Emitido em ${formattedDate}`, + status: "loading", + critical: true, + }, + { + id: "issuer", + label: "Emitido usando Certify", + status: "loading", + critical: true, + }, + { + id: "recipient", + label: `Emitido para ${certificate.studentName}`, + status: "loading", + critical: true, + }, + { + id: "accepted-date", + label: `Aceitar em ${acceptedDate}`, + status: "loading", + critical: true, + }, + { + id: "last-updated", + label: `Última atualização ${lastUpdated}`, + status: "loading", + critical: true, + }, + { + id: "verified", + label: "VERIFICADO", + status: "loading", + critical: true, + }, + ]; + + const updateStep = ( + stepId: string, + status: VerificationStatus, + label?: string + ) => { + setVerificationSteps((currentSteps) => + currentSteps.map((step) => + step.id === stepId + ? { + ...step, + status, + label: label ?? step.label, + } + : step + ) + ); + }; + + const verifyCertificate = async () => { + setVerificationSteps(createVerificationSteps()); + setIsVerificationOpen(true); + + try { + await new Promise((resolve) => + setTimeout(resolve, 600) + ); + + updateStep("issue-date", "success"); + + await new Promise((resolve) => + setTimeout(resolve, 400) + ); + + updateStep("issuer", "success"); + + await new Promise((resolve) => + setTimeout(resolve, 400) + ); + + updateStep("recipient", "success"); + + await new Promise((resolve) => + setTimeout(resolve, 400) + ); + + updateStep( + "accepted-date", + "success" + ); + + await new Promise((resolve) => + setTimeout(resolve, 400) + ); + + updateStep( + "last-updated", + "success" + ); + + await new Promise((resolve) => + setTimeout(resolve, 400) + ); + + updateStep( + "verified", + "success" + ); + } catch (error) { + console.error( + "Erro durante a verificação:", + error + ); + + setVerificationSteps((currentSteps) => + currentSteps.map((step) => + step.status === "loading" + ? { + ...step, + status: "error", + label: "Erro ao verificar", + } + : step + ) + ); + } + }; + + const handleDownload = async () => { + if (isDownloading) return; + + try { + setIsDownloading(true); + + const response = await fetch( + `/api/certificates/${certificate.id}/pdf` + ); + + if (!response.ok) { + throw new Error( + "Não foi possível baixar o certificado." + ); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + const link = document.createElement("a"); + + link.href = url; + link.download = "certificado.pdf"; + + document.body.appendChild(link); + link.click(); + link.remove(); + + window.URL.revokeObjectURL(url); + } catch (error) { + console.error( + "Erro ao baixar certificado:", + error + ); + } finally { + setIsDownloading(false); + } + }; + + const handleSendEmail = () => { + console.log( + "Enviar certificado por e-mail:", + certificate.id + ); + }; + + const handleLinkedIn = () => { + console.log( + "Compartilhar certificado no LinkedIn:", + certificate.id + ); + }; + + return ( +
+
+ + Certify Logo + + +
+ {initials} +
+
+ +
+
+
+
+
+ + +
+

+ Informações do certificado +

+ +

+ Este certificado foi emitido para{" "} + + {certificate.studentName} + +

+ +

+ Data da emissão:{" "} + + {formattedDate} + +

+
+
+ + +
+
+
+ +
+
+
+
+

+ Cursos +

+
+ +
+
+
+ Certify Logo +
+ +
+

+ {certificate.title} +

+ +

+ {certificate.studentName} +

+ +

+ Certificamos que +

+ +

+ concluiu o curso +

+ +

+ {certificate.courseName} +

+ +
+

+ Carga horária:{" "} + + {certificate.workload} + +

+ +

+ Data:{" "} + + {formattedDate} + +

+
+
+ +
+
+ {certificate.signature ? ( + Assinatura + ) : ( +
+ )} + + + Assinatura + +
+ +
+

+ Código de autenticidade +

+ +

+ {certificate.authenticityCode} +

+
+
+
+
+ +
+ +
+ + +
+
+
+
+ + + setIsVerificationOpen(false) + } + steps={verificationSteps} + /> +
+ ); +}