From 22f7401bbe1b2a06f91c1ce8c2fb7aee7a45c77f Mon Sep 17 00:00:00 2001 From: "Alejandro E. Rendon" Date: Tue, 28 Jul 2026 10:43:05 -0500 Subject: [PATCH] Refactor certificate components for improved layout and functionality: Introduced CertificateStage for consistent scaling of certificate display across devices. Enhanced CertificateDetail with a preview feature and updated PDF generation logic. Adjusted styles and localization for better user experience in both English and Spanish. --- .../blocks/certificates/certificate-card.tsx | 44 ++++----- .../certificates/certificate-detail.tsx | 95 ++++++++++++++++--- .../blocks/certificates/certificate-stage.tsx | 63 ++++++++++++ src/lib/download-certificate-pdf.ts | 27 +++--- src/lib/i18n/blocks-en.ts | 2 + src/lib/i18n/blocks-es.ts | 2 + 6 files changed, 187 insertions(+), 46 deletions(-) create mode 100644 src/components/blocks/certificates/certificate-stage.tsx diff --git a/src/components/blocks/certificates/certificate-card.tsx b/src/components/blocks/certificates/certificate-card.tsx index 8cfab20..08c6576 100644 --- a/src/components/blocks/certificates/certificate-card.tsx +++ b/src/components/blocks/certificates/certificate-card.tsx @@ -170,32 +170,32 @@ const CertificateCard = ({ flex-shrink: 0; } .pcert-brand { display: flex; align-items: center; gap: 0.75rem; } - .pcert-logo-img { width: clamp(36px, 6%, 54px); height: auto; object-fit: contain; } + .pcert-logo-img { width: 52px; height: auto; object-fit: contain; } .pcert-brand-name { font-family: var(--font-display), var(--font-button), sans-serif; - font-size: clamp(16px, 2.9vw, 26px); + font-size: 26px; letter-spacing: 0.06em; line-height: 1; color: var(--pcert-fg); } .pcert-brand-name span { color: var(--pcert-primary); } .pcert-brand-sub { font-family: var(--font-button), sans-serif; - font-size: clamp(9px, 1.25vw, 12px); + font-size: 12px; color: var(--pcert-muted); letter-spacing: 0.14em; } .pcert-edition-num { font-family: var(--font-display), var(--font-button), sans-serif; - font-size: clamp(28px, 5.4vw, 48px); + font-size: 48px; color: var(--pcert-primary); line-height: 1; letter-spacing: 0.04em; text-align: right; } .pcert-edition-label { font-family: var(--font-button), sans-serif; - font-size: clamp(9px, 1.25vw, 12px); + font-size: 12px; color: var(--pcert-muted); letter-spacing: 0.14em; text-align: right; } .pcert-hero { position: relative; text-align: center; flex-shrink: 0; line-height: 1; } .pcert-pycon { font-family: var(--font-display), var(--font-button), sans-serif; - font-size: clamp(44px, 9vw, 82px); + font-size: 82px; letter-spacing: 0.06em; line-height: 0.88; background: linear-gradient(to bottom, #BCC4F4, #EEEEF7 50%, #BCC4F4); -webkit-background-clip: text; background-clip: text; @@ -205,7 +205,7 @@ const CertificateCard = ({ .pcert-float-logo { position: absolute; top: 50%; left: 50%; transform: translate(-50%, calc(-58% + 20px)); - width: clamp(38px, 6.2%, 56px); height: auto; + width: 56px; height: auto; object-fit: contain; filter: drop-shadow(0 12px 20px rgba(97,85,245,0.22)); } @@ -214,21 +214,21 @@ const CertificateCard = ({ display: inline-block; background: var(--pcert-primary); color: var(--pcert-primary-fg); font-family: var(--font-button), sans-serif; - font-size: clamp(8px, 1.05vw, 10px); letter-spacing: 0.14em; - border-radius: 999px; padding: 4px 16px; + font-size: 11px; letter-spacing: 0.14em; + border-radius: 999px; padding: 5px 18px; } .pcert-eyebrow { - font-size: clamp(10px, 1.25vw, 12px); letter-spacing: 0.18em; + font-size: 13px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--pcert-muted); - text-align: center; margin-top: 4.5%; flex-shrink: 0; + text-align: center; margin-top: 3.5%; flex-shrink: 0; } .pcert-body { - font-size: clamp(13px, 1.8vw, 16px); color: var(--pcert-muted); - line-height: 1.65; text-align: center; flex-shrink: 0; + font-size: 16px; color: var(--pcert-muted); + line-height: 1.55; text-align: center; flex-shrink: 0; } .pcert-body strong { color: var(--pcert-fg); font-weight: 600; } .pcert-sep { - display: flex; align-items: center; gap: 10px; + display: flex; align-items: center; gap: 12px; margin: 1.2% auto; max-width: 56%; width: 100%; flex-shrink: 0; } .pcert-sep::before, .pcert-sep::after { @@ -236,7 +236,7 @@ const CertificateCard = ({ } .pcert-name { font-family: var(--font-display), var(--font-button), sans-serif; - font-size: clamp(26px, 5vw, 46px); letter-spacing: 0.04em; + font-size: 46px; letter-spacing: 0.04em; color: var(--pcert-primary); line-height: 1.1; text-align: center; flex-shrink: 0; } @@ -252,11 +252,11 @@ const CertificateCard = ({ display: inline-block; background: var(--pcert-secondary); color: var(--pcert-secondary-fg); font-family: var(--font-button), sans-serif; - font-size: clamp(14px, 1.9vw, 17px); letter-spacing: 0.06em; + font-size: 17px; letter-spacing: 0.06em; border-radius: 6px; padding: 2px 14px; margin: 0 4px; } .pcert-meta { - display: flex; justify-content: center; gap: clamp(1.2rem, 4.5vw, 2.8rem); + display: flex; justify-content: center; gap: 2.5rem; margin-top: auto; padding: 1.6% 0; border-top: 1px solid var(--pcert-border); border-bottom: 1px solid var(--pcert-border); @@ -264,12 +264,12 @@ const CertificateCard = ({ } .pcert-meta-item { display: flex; flex-direction: column; align-items: center; gap: 3px; } .pcert-meta-label { - font-size: clamp(9px, 1.15vw, 11px); text-transform: uppercase; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--pcert-muted); } .pcert-meta-value { font-family: var(--font-button), sans-serif; - font-size: clamp(13px, 1.7vw, 15px); letter-spacing: 0.06em; + font-size: 15px; letter-spacing: 0.06em; color: var(--pcert-fg); } .pcert-footer { @@ -278,18 +278,18 @@ const CertificateCard = ({ flex-shrink: 0; } .pcert-verify-label { - font-size: clamp(9px, 1.15vw, 11px); color: var(--pcert-muted); + font-size: 11px; color: var(--pcert-muted); margin-top: 2px; letter-spacing: 0.1em; text-transform: uppercase; font-family: var(--font-button), sans-serif; text-align: center; } .pcert-id-label { - font-size: clamp(9px, 1.15vw, 11px); text-transform: uppercase; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--pcert-muted); margin-bottom: 4px; text-align: right; } .pcert-id-value { font-family: var(--font-mono), ui-monospace, monospace; - font-size: clamp(12px, 1.55vw, 14px); font-weight: 600; + font-size: 14px; font-weight: 600; color: var(--pcert-primary); background: var(--pcert-secondary); border: 1px solid rgb(97 85 245 / 0.3); border-radius: 6px; padding: 5px 14px; letter-spacing: 0.06em; diff --git a/src/components/blocks/certificates/certificate-detail.tsx b/src/components/blocks/certificates/certificate-detail.tsx index c835c75..c86eef6 100644 --- a/src/components/blocks/certificates/certificate-detail.tsx +++ b/src/components/blocks/certificates/certificate-detail.tsx @@ -1,11 +1,19 @@ "use client"; -import { DownloadIcon, Link2Icon } from "lucide-react"; +import { DownloadIcon, ExpandIcon, Link2Icon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import CertificateCard from "@/components/blocks/certificates/certificate-card"; +import CertificateStage from "@/components/blocks/certificates/certificate-stage"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { PrimaryFlowButton } from "@/components/ui/flow-button"; import { MotionPreset } from "@/components/ui/motion-preset"; import { useLanguage, useTranslations } from "@/contexts/language-context"; @@ -13,7 +21,10 @@ import { type CertificateWithUser, getCertificateUrl, } from "@/lib/certificates"; -import { downloadCertificatePdf } from "@/lib/download-certificate-pdf"; +import { + CERTIFICATE_CANVAS_WIDTH_PX, + downloadCertificatePdf, +} from "@/lib/download-certificate-pdf"; import { getTeamMemberHref } from "@/lib/team"; type CertificateDetailProps = { @@ -23,10 +34,11 @@ type CertificateDetailProps = { const CertificateDetail = ({ certificate }: CertificateDetailProps) => { const { t } = useTranslations(); const { locale } = useLanguage(); - const printRef = useRef(null); + const exportRef = useRef(null); const [isDownloading, setIsDownloading] = useState(false); const [downloadError, setDownloadError] = useState(null); const [linkCopied, setLinkCopied] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); const verificationUrl = getCertificateUrl(certificate.id); const profileHref = certificate.user.teamSlug @@ -40,6 +52,14 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => { .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); + const cardProps = { + recipientName: certificate.user.name, + role: certificate.role, + verificationUrl, + certificateId: certificate.id, + profileHref, + } as const; + useEffect(() => { if (!linkCopied) { return; @@ -50,7 +70,7 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => { }, [linkCopied]); const handleDownloadPdf = async () => { - const element = printRef.current?.querySelector( + const element = exportRef.current?.querySelector( "[data-certificate-root]", ); @@ -84,6 +104,16 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => { return (
+ {/* Fixed-size offscreen render used only for PDF — identical on every device */} + +
{ ? t("blocks.certificates.linkCopied") : t("blocks.certificates.copyLink")} +
{downloadError ? (

@@ -187,14 +227,26 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => { delay={0.55} transition={{ duration: 0.55 }} > -

- +
+ + + +
+ +
+ +

+ {t("blocks.certificates.tapToExpand")} +

@@ -208,6 +260,25 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => {

{t("blocks.certificates.verificationHint")}

+ + + + + {t("blocks.certificates.viewFull")} + + {t("blocks.certificates.tapToExpand")} + + + {previewOpen ? ( + + + + ) : null} + +
); }; diff --git a/src/components/blocks/certificates/certificate-stage.tsx b/src/components/blocks/certificates/certificate-stage.tsx new file mode 100644 index 0000000..3e695f1 --- /dev/null +++ b/src/components/blocks/certificates/certificate-stage.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { type ReactNode, useEffect, useRef, useState } from "react"; + +import { CERTIFICATE_CANVAS_WIDTH_PX } from "@/lib/download-certificate-pdf"; +import { cn } from "@/lib/utils"; + +const ASPECT = 22 / 17; +const CANVAS_HEIGHT = CERTIFICATE_CANVAS_WIDTH_PX / ASPECT; + +type CertificateStageProps = { + children: ReactNode; + className?: string; +}; + +/** + * Lays the certificate out at a fixed design width, then scales it to fit + * the available space. Layout/fonts stay identical at every viewport size. + */ +const CertificateStage = ({ children, className }: CertificateStageProps) => { + const outerRef = useRef(null); + const [scale, setScale] = useState(1); + + useEffect(() => { + const outer = outerRef.current; + if (!outer) { + return; + } + + const update = () => { + const width = outer.clientWidth; + if (width > 0) { + setScale(Math.min(1, width / CERTIFICATE_CANVAS_WIDTH_PX)); + } + }; + + update(); + const observer = new ResizeObserver(update); + observer.observe(outer); + return () => observer.disconnect(); + }, []); + + return ( +
+
+ {children} +
+
+ ); +}; + +export default CertificateStage; diff --git a/src/lib/download-certificate-pdf.ts b/src/lib/download-certificate-pdf.ts index 4fc8348..ae6f27b 100644 --- a/src/lib/download-certificate-pdf.ts +++ b/src/lib/download-certificate-pdf.ts @@ -3,6 +3,12 @@ import { domToPng, waitUntilLoad } from "modern-screenshot"; import { PRODUCTION_SITE_URL } from "@/lib/site-seo"; +/** Fixed design width used for on-screen layout and PDF capture. */ +export const CERTIFICATE_CANVAS_WIDTH_PX = 1100; +const CERTIFICATE_ASPECT = 22 / 17; +const CERTIFICATE_CANVAS_HEIGHT_PX = + CERTIFICATE_CANVAS_WIDTH_PX / CERTIFICATE_ASPECT; + async function waitForImages(element: HTMLElement) { const images = Array.from(element.querySelectorAll("img")); @@ -62,9 +68,8 @@ function getPdfLinkBounds(args: { } /** - * Rasterize the live certificate DOM exactly as shown (fonts, colors, QR), - * then embed that bitmap into a PDF page matching the certificate aspect ratio. - * If the recipient name links to a profile, add a matching PDF link annotation. + * Capture a certificate node that is already laid out at the fixed canvas + * size (not CSS-scaled). PDF output is identical on every device. */ export async function downloadCertificatePdf(args: { element: HTMLElement; @@ -79,18 +84,19 @@ export async function downloadCertificatePdf(args: { // Brief pause so webfonts / QR paint settle before capture. await new Promise((resolve) => window.setTimeout(resolve, 80)); - const scale = Math.min(3, Math.max(2, window.devicePixelRatio || 2)); - const dataUrl = await domToPng(element, { - scale, + width: CERTIFICATE_CANVAS_WIDTH_PX, + height: CERTIFICATE_CANVAS_HEIGHT_PX, + scale: 2, backgroundColor: "#ffffff", quality: 1, timeout: 30_000, - // Capture the node as fully painted (ignore parent motion transforms). style: { transform: "none", opacity: "1", filter: "none", + width: `${CERTIFICATE_CANVAS_WIDTH_PX}px`, + height: `${CERTIFICATE_CANVAS_HEIGHT_PX}px`, }, fetch: { requestInit: { @@ -100,12 +106,9 @@ export async function downloadCertificatePdf(args: { }, }); - // Page follows certificate aspect ratio (22:17), with a white margin around it. - const rect = element.getBoundingClientRect(); - const aspect = rect.height > 0 ? rect.width / rect.height : 22 / 17; const margin = 28; // pt on each side - const contentWidth = 842; // ~A4 landscape width for the certificate itself - const contentHeight = contentWidth / aspect; + const contentWidth = 842; + const contentHeight = contentWidth / CERTIFICATE_ASPECT; const pageWidth = contentWidth + margin * 2; const pageHeight = contentHeight + margin * 2; diff --git a/src/lib/i18n/blocks-en.ts b/src/lib/i18n/blocks-en.ts index 84374d1..445d8a3 100644 --- a/src/lib/i18n/blocks-en.ts +++ b/src/lib/i18n/blocks-en.ts @@ -465,6 +465,8 @@ export const blocksEn = { downloadError: "Could not generate the PDF. Please try again.", copyLink: "Copy verification link", linkCopied: "Link copied!", + viewFull: "View full certificate", + tapToExpand: "Tap the certificate to view it full size.", verificationHint: "Scan the QR code on the certificate to open this verification page. Share the link only with people who should verify attendance.", roles: { diff --git a/src/lib/i18n/blocks-es.ts b/src/lib/i18n/blocks-es.ts index 7e6af57..a3766b3 100644 --- a/src/lib/i18n/blocks-es.ts +++ b/src/lib/i18n/blocks-es.ts @@ -470,6 +470,8 @@ export const blocksEs: BlocksMessages = { downloadError: "No se pudo generar el PDF. Inténtalo de nuevo.", copyLink: "Copiar enlace de verificación", linkCopied: "¡Enlace copiado!", + viewFull: "Ver certificado completo", + tapToExpand: "Toca el certificado para verlo a tamaño completo.", verificationHint: "Escanea el código QR del certificado para abrir esta página de verificación. Comparte el enlace solo con quienes deban comprobar la asistencia.", roles: {