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
1 change: 0 additions & 1 deletion .env

This file was deleted.

4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ NEXT_PUBLIC_GA_ID=G-JE322K8EBK
BASEPATH=
# Local dev only — CI sets this per workflow (prod vs develop).
NEXT_PUBLIC_APP_URL=https://2026.pycon.co
# Certificates registry (build-time only; do not use NEXT_PUBLIC_*).
# Prefer a full URL, or set the Drive file id and the download URL is derived.
# CERTIFICATES_JSON_URL=https://drive.google.com/uc?export=download&id=YOUR_FILE_ID
CERTIFICATES_DRIVE_FILE_ID=
9 changes: 9 additions & 0 deletions .github/workflows/ci-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ jobs:
echo "BASEPATH=${{ vars.BASEPATH }}" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_BASEPATH=${{ vars.BASEPATH }}" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_APP_URL=https://develop.pycon.co" >> "$GITHUB_ENV"
if [ -n "${{ secrets.CERTIFICATES_JSON_URL }}" ]; then
echo "CERTIFICATES_JSON_URL=${{ secrets.CERTIFICATES_JSON_URL }}" >> "$GITHUB_ENV"
elif [ -n "${{ vars.CERTIFICATES_JSON_URL }}" ]; then
echo "CERTIFICATES_JSON_URL=${{ vars.CERTIFICATES_JSON_URL }}" >> "$GITHUB_ENV"
elif [ -n "${{ secrets.CERTIFICATES_DRIVE_FILE_ID }}" ]; then
echo "CERTIFICATES_DRIVE_FILE_ID=${{ secrets.CERTIFICATES_DRIVE_FILE_ID }}" >> "$GITHUB_ENV"
elif [ -n "${{ vars.CERTIFICATES_DRIVE_FILE_ID }}" ]; then
echo "CERTIFICATES_DRIVE_FILE_ID=${{ vars.CERTIFICATES_DRIVE_FILE_ID }}" >> "$GITHUB_ENV"
fi
- name: Build project
run: npm run build
- name: Upload dist folder as artifact
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ jobs:
echo "BASEPATH=${{ vars.BASEPATH }}" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_BASEPATH=${{ vars.BASEPATH }}" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_APP_URL=https://2026.pycon.co" >> "$GITHUB_ENV"
if [ -n "${{ secrets.CERTIFICATES_JSON_URL }}" ]; then
echo "CERTIFICATES_JSON_URL=${{ secrets.CERTIFICATES_JSON_URL }}" >> "$GITHUB_ENV"
elif [ -n "${{ vars.CERTIFICATES_JSON_URL }}" ]; then
echo "CERTIFICATES_JSON_URL=${{ vars.CERTIFICATES_JSON_URL }}" >> "$GITHUB_ENV"
elif [ -n "${{ secrets.CERTIFICATES_DRIVE_FILE_ID }}" ]; then
echo "CERTIFICATES_DRIVE_FILE_ID=${{ secrets.CERTIFICATES_DRIVE_FILE_ID }}" >> "$GITHUB_ENV"
elif [ -n "${{ vars.CERTIFICATES_DRIVE_FILE_ID }}" ]; then
echo "CERTIFICATES_DRIVE_FILE_ID=${{ vars.CERTIFICATES_DRIVE_FILE_ID }}" >> "$GITHUB_ENV"
fi
- name: Build
run: npm run build
- name: Setup Pages
Expand Down
21 changes: 11 additions & 10 deletions src/app/(pages)/certificates/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import { notFound } from "next/navigation";
import CertificateDetail from "@/components/blocks/certificates/certificate-detail";
import CTASection from "@/components/blocks/cta/cta";
import SectionSeparator from "@/components/section-separator";
import { getCertificateHref } from "@/lib/certificates";
import {
getAllCertificateIds,
getCertificateHref,
getCertificateWithUser,
} from "@/lib/certificates";
getResolvedCertificate,
} from "@/lib/certificates-server";
import { STATIC_PRERENDER_LOCALE } from "@/lib/site-locale-constants";
import { siteMessages } from "@/lib/site-messages";
import { getSiteUrl, webPageJsonLd, websiteJsonLd } from "@/lib/site-seo";

export async function generateStaticParams() {
return getAllCertificateIds().map((id) => ({ id }));
const ids = await getAllCertificateIds();
return ids.map((id) => ({ id }));
}

export async function generateMetadata({
Expand All @@ -23,7 +24,7 @@ export async function generateMetadata({
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const certificate = getCertificateWithUser(id);
const certificate = await getResolvedCertificate(id);
const meta = siteMessages[STATIC_PRERENDER_LOCALE].pageMeta.certificates;

if (!certificate) {
Expand All @@ -33,10 +34,10 @@ export async function generateMetadata({
};
}

const title = meta.detailTitle.replace("{name}", certificate.user.name);
const title = meta.detailTitle.replace("{name}", certificate.name);
const description = meta.detailDescription.replace(
"{name}",
certificate.user.name,
certificate.name,
);

return {
Expand All @@ -60,7 +61,7 @@ const CertificatePage = async ({
params: Promise<{ id: string }>;
}) => {
const { id } = await params;
const certificate = getCertificateWithUser(id);
const certificate = await getResolvedCertificate(id);

if (!certificate) {
notFound();
Expand All @@ -70,11 +71,11 @@ const CertificatePage = async ({
const pageUrl = `${getSiteUrl()}${getCertificateHref(id)}`;
const title = messages.pageMeta.certificates.detailTitle.replace(
"{name}",
certificate.user.name,
certificate.name,
);
const description = messages.pageMeta.certificates.detailDescription.replace(
"{name}",
certificate.user.name,
certificate.name,
);

const jsonLd = {
Expand Down
51 changes: 8 additions & 43 deletions src/assets/data/certificates.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Attendance certificates. Each entry has a unique public hash `id` used in
* `/certificates/[id]`. Only users listed here are considered attendees who
* receive a certificate.
* Attendance certificates. Public verification pages live at
* `/certificates/[id]`. Records are loaded at build time from the
* certificates JSON configured via env (see `loadCertificateRegistry`).
*/
export type CertificateRole =
| "attendee"
Expand All @@ -12,46 +12,11 @@ export type CertificateRole =
export type Certificate = {
/** Public unique hash used in the certificate URL. */
id: string;
userId: string;
name: string;
role: CertificateRole;
issuedAt: string;
};

export const certificates: Certificate[] = [
{
id: "b2e9f1a84c7d5036e91a",
userId: "usr_john_roa",
role: "organizer",
issuedAt: "2026-07-26",
},
{
id: "7c4a8d9e2f1b6a308c4e",
userId: "usr_alejandro_rendon",
role: "organizer",
issuedAt: "2026-07-26",
},
{
id: "3f8a1c6e9b2d7045a1f2",
userId: "usr_carlos_sierra",
role: "organizer",
issuedAt: "2026-07-26",
},
{
id: "9d2e7a4f1c8b6035d7e1",
userId: "usr_karen_romo",
role: "organizer",
issuedAt: "2026-07-26",
},
{
id: "5a1c8e3f7b2d9046c3a8",
userId: "usr_maria_franco",
role: "volunteer",
issuedAt: "2026-07-26",
},
{
id: "e4f7b2c9a1d68305f8e2",
userId: "usr_test_attendee",
role: "attendee",
issuedAt: "2026-07-26",
},
];
export type ResolvedCertificate = Certificate & {
/** Profile link when the recipient matches team or speakers data. */
profileHref?: string;
};
50 changes: 0 additions & 50 deletions src/assets/data/users.ts

This file was deleted.

21 changes: 7 additions & 14 deletions src/components/blocks/certificates/certificate-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { DownloadIcon, ExpandIcon, Link2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";

import type { ResolvedCertificate } from "@/assets/data/certificates";
import CertificateCard from "@/components/blocks/certificates/certificate-card";
import CertificateStage from "@/components/blocks/certificates/certificate-stage";
import { Badge } from "@/components/ui/badge";
Expand All @@ -17,18 +17,14 @@ import {
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 { getCertificateUrl } from "@/lib/certificates";
import {
CERTIFICATE_CANVAS_WIDTH_PX,
downloadCertificatePdf,
} from "@/lib/download-certificate-pdf";
import { getTeamMemberHref } from "@/lib/team";

type CertificateDetailProps = {
certificate: CertificateWithUser;
certificate: ResolvedCertificate;
};

const CertificateDetail = ({ certificate }: CertificateDetailProps) => {
Expand All @@ -41,23 +37,20 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => {
const [previewOpen, setPreviewOpen] = useState(false);

const verificationUrl = getCertificateUrl(certificate.id);
const profileHref = certificate.user.teamSlug
? getTeamMemberHref(certificate.user.teamSlug)
: undefined;
const roleLabel = t(`blocks.certificates.roles.${certificate.role}`);
const fileSlug = certificate.user.name
const fileSlug = certificate.name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");

const cardProps = {
recipientName: certificate.user.name,
recipientName: certificate.name,
role: certificate.role,
verificationUrl,
certificateId: certificate.id,
profileHref,
profileHref: certificate.profileHref,
} as const;

useEffect(() => {
Expand Down Expand Up @@ -152,7 +145,7 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => {
<p className="text-muted-foreground mx-auto max-w-2xl text-lg sm:text-xl">
{t("blocks.certificates.subtitle").replace(
"{name}",
certificate.user.name,
certificate.name,
)}
</p>
</MotionPreset>
Expand Down
Loading
Loading