Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions src/components/blocks/certificates/certificate-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
}
Expand All @@ -214,29 +214,29 @@ 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 {
content: ''; flex: 1; height: 1px; background: var(--pcert-secondary);
}
.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;
}
Expand All @@ -252,24 +252,24 @@ 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);
flex-shrink: 0;
}
.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 {
Expand All @@ -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;
Expand Down
95 changes: 83 additions & 12 deletions src/components/blocks/certificates/certificate-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
"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";
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 = {
Expand All @@ -23,10 +34,11 @@ type CertificateDetailProps = {
const CertificateDetail = ({ certificate }: CertificateDetailProps) => {
const { t } = useTranslations();
const { locale } = useLanguage();
const printRef = useRef<HTMLDivElement>(null);
const exportRef = useRef<HTMLDivElement>(null);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const [linkCopied, setLinkCopied] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);

const verificationUrl = getCertificateUrl(certificate.id);
const profileHref = certificate.user.teamSlug
Expand All @@ -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;
Expand All @@ -50,7 +70,7 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => {
}, [linkCopied]);

const handleDownloadPdf = async () => {
const element = printRef.current?.querySelector<HTMLElement>(
const element = exportRef.current?.querySelector<HTMLElement>(
"[data-certificate-root]",
);

Expand Down Expand Up @@ -84,6 +104,16 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => {

return (
<section className="overflow-hidden py-8 sm:py-16 lg:py-24">
{/* Fixed-size offscreen render used only for PDF — identical on every device */}
<div
ref={exportRef}
aria-hidden="true"
className="pointer-events-none fixed top-0 -left-[14000px]"
style={{ width: CERTIFICATE_CANVAS_WIDTH_PX }}
>
<CertificateCard {...cardProps} />
</div>

<div className="mx-auto max-w-7xl space-y-10 px-4 sm:px-6 lg:space-y-14 lg:px-8">
<div className="space-y-4 text-center">
<MotionPreset
Expand Down Expand Up @@ -171,6 +201,16 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => {
? t("blocks.certificates.linkCopied")
: t("blocks.certificates.copyLink")}
</Button>
<Button
size="lg"
type="button"
variant="outline"
className="rounded-lg text-base shadow-none md:hidden"
onClick={() => setPreviewOpen(true)}
>
<ExpandIcon />
{t("blocks.certificates.viewFull")}
</Button>
</div>
{downloadError ? (
<p className="text-destructive mt-3 text-sm" role="alert">
Expand All @@ -187,14 +227,26 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => {
delay={0.55}
transition={{ duration: 0.55 }}
>
<div ref={printRef} className="mx-auto w-full max-w-5xl">
<CertificateCard
recipientName={certificate.user.name}
role={certificate.role}
verificationUrl={verificationUrl}
certificateId={certificate.id}
profileHref={profileHref}
/>
<div className="mx-auto hidden w-full max-w-5xl md:block">
<CertificateStage>
<CertificateCard {...cardProps} />
</CertificateStage>
</div>

<div className="mx-auto w-full max-w-5xl md:hidden">
<button
type="button"
className="w-full cursor-zoom-in text-left"
onClick={() => setPreviewOpen(true)}
aria-label={t("blocks.certificates.viewFull")}
>
<CertificateStage>
<CertificateCard {...cardProps} />
</CertificateStage>
</button>
<p className="text-muted-foreground mt-3 text-center text-sm">
{t("blocks.certificates.tapToExpand")}
</p>
</div>
</MotionPreset>

Expand All @@ -208,6 +260,25 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => {
<p>{t("blocks.certificates.verificationHint")}</p>
</MotionPreset>
</div>

<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent
className="max-h-[92vh] w-[min(100vw-1rem,72rem)] max-w-none overflow-y-auto p-3 sm:p-5"
showCloseButton
>
<DialogHeader className="sr-only">
<DialogTitle>{t("blocks.certificates.viewFull")}</DialogTitle>
<DialogDescription>
{t("blocks.certificates.tapToExpand")}
</DialogDescription>
</DialogHeader>
{previewOpen ? (
<CertificateStage>
<CertificateCard {...cardProps} />
</CertificateStage>
) : null}
</DialogContent>
</Dialog>
</section>
);
};
Expand Down
63 changes: 63 additions & 0 deletions src/components/blocks/certificates/certificate-stage.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={outerRef}
className={cn("relative w-full", className)}
style={{ height: CANVAS_HEIGHT * scale }}
>
<div
className="origin-top-left"
style={{
width: CERTIFICATE_CANVAS_WIDTH_PX,
height: CANVAS_HEIGHT,
transform: `scale(${scale})`,
}}
>
{children}
</div>
</div>
);
};

export default CertificateStage;
Loading
Loading