From 0a5649c199221dedd11be40274c507a60174f984 Mon Sep 17 00:00:00 2001 From: "Alejandro E. Rendon" Date: Fri, 31 Jul 2026 06:04:57 -0500 Subject: [PATCH] Remove deprecated user data and update certificate handling: Deleted the users data file and refactored certificate logic to eliminate user dependencies. Introduced a new certificate registry for improved data management and updated related components to utilize the new structure. Enhanced CI/CD workflows to support new environment variables for certificate data retrieval. --- .env | 1 - .env.example | 4 + .github/workflows/ci-dev.yml | 9 ++ .github/workflows/ci-prod.yml | 9 ++ src/app/(pages)/certificates/[id]/page.tsx | 21 +-- src/assets/data/certificates.ts | 51 +----- src/assets/data/users.ts | 50 ------ .../certificates/certificate-detail.tsx | 21 +-- src/lib/certificate-registry.ts | 147 ++++++++++++++++++ src/lib/certificates-server.ts | 121 ++++++++++++++ src/lib/certificates.ts | 46 +----- src/lib/users.ts | 9 -- 12 files changed, 322 insertions(+), 167 deletions(-) delete mode 100644 .env delete mode 100644 src/assets/data/users.ts create mode 100644 src/lib/certificate-registry.ts create mode 100644 src/lib/certificates-server.ts delete mode 100644 src/lib/users.ts diff --git a/.env b/.env deleted file mode 100644 index 840d9dc..0000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -VITE_GA_ID=G-JE322K8EBK diff --git a/.env.example b/.env.example index b98673c..7be94ad 100644 --- a/.env.example +++ b/.env.example @@ -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= \ No newline at end of file diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 2309338..e7ca19c 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -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 diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index 7e79067..2eeedcd 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -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 diff --git a/src/app/(pages)/certificates/[id]/page.tsx b/src/app/(pages)/certificates/[id]/page.tsx index e47f38e..12e4195 100644 --- a/src/app/(pages)/certificates/[id]/page.tsx +++ b/src/app/(pages)/certificates/[id]/page.tsx @@ -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({ @@ -23,7 +24,7 @@ export async function generateMetadata({ params: Promise<{ id: string }>; }): Promise { const { id } = await params; - const certificate = getCertificateWithUser(id); + const certificate = await getResolvedCertificate(id); const meta = siteMessages[STATIC_PRERENDER_LOCALE].pageMeta.certificates; if (!certificate) { @@ -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 { @@ -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(); @@ -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 = { diff --git a/src/assets/data/certificates.ts b/src/assets/data/certificates.ts index 6d769c9..170f06c 100644 --- a/src/assets/data/certificates.ts +++ b/src/assets/data/certificates.ts @@ -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" @@ -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; +}; diff --git a/src/assets/data/users.ts b/src/assets/data/users.ts deleted file mode 100644 index 527a108..0000000 --- a/src/assets/data/users.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Conference users / attendees. - * Linked from certificates via `userId`. Only users with a certificate entry - * receive a unique verification page. - */ -export type User = { - id: string; - name: string; - email?: string; - /** Optional link to an existing team member slug. */ - teamSlug?: string; -}; - -export const users: User[] = [ - { - id: "usr_john_roa", - name: "John Roa", - email: "john@pycon.co", - teamSlug: "john-roa", - }, - { - id: "usr_alejandro_rendon", - name: "Alejandro Rendon", - email: "alejandro@pycon.co", - teamSlug: "alejandro-rendon", - }, - { - id: "usr_carlos_sierra", - name: "Carlos Sierra", - email: "carlos@pycon.co", - teamSlug: "carlos-sierra", - }, - { - id: "usr_karen_romo", - name: "Karen Romo", - email: "karen@pycon.co", - teamSlug: "karen-romo", - }, - { - id: "usr_maria_franco", - name: "Maria Franco", - email: "maria@pycon.co", - teamSlug: "maria-franco", - }, - { - id: "usr_test_attendee", - name: "Camila Restrepo", - email: "camila.restrepo@example.com", - }, -]; diff --git a/src/components/blocks/certificates/certificate-detail.tsx b/src/components/blocks/certificates/certificate-detail.tsx index c86eef6..f8494cc 100644 --- a/src/components/blocks/certificates/certificate-detail.tsx +++ b/src/components/blocks/certificates/certificate-detail.tsx @@ -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"; @@ -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) => { @@ -41,11 +37,8 @@ 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, "") @@ -53,11 +46,11 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => { .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(() => { @@ -152,7 +145,7 @@ const CertificateDetail = ({ certificate }: CertificateDetailProps) => {

{t("blocks.certificates.subtitle").replace( "{name}", - certificate.user.name, + certificate.name, )}

diff --git a/src/lib/certificate-registry.ts b/src/lib/certificate-registry.ts new file mode 100644 index 0000000..8e817aa --- /dev/null +++ b/src/lib/certificate-registry.ts @@ -0,0 +1,147 @@ +import type { Certificate, CertificateRole } from "@/assets/data/certificates"; + +type CertificateListEntry = { + name: string; + role: string; +}; + +type CertificateListFile = Record; + +const ROLE_ALIASES: Record = { + attendee: "attendee", + asistente: "attendee", + volunteer: "volunteer", + voluntario: "volunteer", + speaker: "speaker", + ponente: "speaker", + "keynote speaker": "speaker", + keynote: "speaker", + organizer: "organizer", + organizador: "organizer", +}; + +let registryPromise: Promise> | null = null; + +function getCertificatesJsonUrl(): string | undefined { + const explicit = process.env.CERTIFICATES_JSON_URL?.trim(); + if (explicit) { + return explicit; + } + + const fileId = process.env.CERTIFICATES_DRIVE_FILE_ID?.trim(); + if (fileId) { + return `https://drive.google.com/uc?export=download&id=${encodeURIComponent(fileId)}`; + } + + return undefined; +} + +function parseRole(raw: string): CertificateRole | undefined { + return ROLE_ALIASES[raw.trim().toLowerCase()]; +} + +function isCertificateListEntry(value: unknown): value is CertificateListEntry { + if (!value || typeof value !== "object") { + return false; + } + + const entry = value as Record; + return typeof entry.name === "string" && typeof entry.role === "string"; +} + +function parseCertificateList(payload: unknown): Map { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error( + "Certificates JSON must be an object keyed by certificate id.", + ); + } + + const registry = new Map(); + + for (const [id, value] of Object.entries(payload as CertificateListFile)) { + const certificateId = id.trim(); + if (!certificateId || !isCertificateListEntry(value)) { + continue; + } + + const role = parseRole(value.role); + if (!role) { + throw new Error( + `Unknown certificate role "${value.role}" for id "${certificateId}".`, + ); + } + + const name = value.name.trim(); + if (!name) { + continue; + } + + registry.set(certificateId, { + id: certificateId, + name, + role, + }); + } + + return registry; +} + +async function fetchCertificateRegistry(): Promise> { + const url = getCertificatesJsonUrl(); + + if (!url) { + throw new Error( + "Missing certificates source. Set CERTIFICATES_JSON_URL or CERTIFICATES_DRIVE_FILE_ID.", + ); + } + + const response = await fetch(url, { + // Build-time only; avoid Next Data Cache surprises across rebuilds. + cache: "no-store", + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch certificates JSON (${response.status} ${response.statusText}).`, + ); + } + + const contentType = response.headers.get("content-type") ?? ""; + const body = await response.text(); + + if ( + contentType.includes("text/html") || + body.trimStart().startsWith("> { + if (!registryPromise) { + registryPromise = fetchCertificateRegistry(); + } + + return registryPromise; +} + +/** Test helper — clears the in-memory cache between runs. */ +export function resetCertificateRegistryCache(): void { + registryPromise = null; +} diff --git a/src/lib/certificates-server.ts b/src/lib/certificates-server.ts new file mode 100644 index 0000000..f741fb0 --- /dev/null +++ b/src/lib/certificates-server.ts @@ -0,0 +1,121 @@ +import type { + Certificate, + CertificateRole, + ResolvedCertificate, +} from "@/assets/data/certificates"; +import { speakers } from "@/assets/data/speakers"; +import { teamMembers, volunteerMembers } from "@/assets/data/team"; +import { loadCertificateRegistry } from "@/lib/certificate-registry"; +import { getSpeakerProfileHref } from "@/lib/speakers"; +import { getTeamMemberHref } from "@/lib/team"; + +function normalizePersonName(name: string): string { + return name + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function namesMatch(left: string, right: string): boolean { + const a = normalizePersonName(left); + const b = normalizePersonName(right); + + if (!a || !b) { + return false; + } + + if (a === b) { + return true; + } + + const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a]; + const tokens = shorter.split(" ").filter(Boolean); + + // Require at least first + last token so short partials don't false-match. + return tokens.length >= 2 && tokens.every((token) => longer.includes(token)); +} + +function findTeamMemberSlug(name: string): string | undefined { + const member = [...teamMembers, ...volunteerMembers].find((entry) => + namesMatch(entry.name, name), + ); + return member?.slug; +} + +function findSpeakerSlug(name: string): string | undefined { + const exact = speakers.find( + (speaker) => + normalizePersonName(speaker.name) === normalizePersonName(name), + ); + if (exact) { + return exact.slug; + } + + const fuzzy = speakers.find((speaker) => namesMatch(speaker.name, name)); + return fuzzy?.slug; +} + +function resolveProfileHref( + name: string, + role: CertificateRole, +): string | undefined { + if (role === "organizer" || role === "volunteer") { + const teamSlug = findTeamMemberSlug(name); + if (teamSlug) { + return getTeamMemberHref(teamSlug); + } + } + + if (role === "speaker") { + const speakerSlug = findSpeakerSlug(name); + if (speakerSlug) { + return getSpeakerProfileHref(speakerSlug); + } + } + + const speakerSlug = findSpeakerSlug(name); + if (speakerSlug) { + return getSpeakerProfileHref(speakerSlug); + } + + const teamSlug = findTeamMemberSlug(name); + if (teamSlug) { + return getTeamMemberHref(teamSlug); + } + + return undefined; +} + +function resolveCertificate(certificate: Certificate): ResolvedCertificate { + return { + ...certificate, + profileHref: resolveProfileHref(certificate.name, certificate.role), + }; +} + +export async function getAllCertificateIds(): Promise { + const registry = await loadCertificateRegistry(); + return [...registry.keys()]; +} + +export async function getCertificateById( + id: string, +): Promise { + const registry = await loadCertificateRegistry(); + return registry.get(id); +} + +export async function getResolvedCertificate( + id: string, +): Promise { + const certificate = await getCertificateById(id); + + if (!certificate) { + return undefined; + } + + return resolveCertificate(certificate); +} diff --git a/src/lib/certificates.ts b/src/lib/certificates.ts index dd9fb5b..4501418 100644 --- a/src/lib/certificates.ts +++ b/src/lib/certificates.ts @@ -1,41 +1,11 @@ -import { - type Certificate, - type CertificateRole, - certificates, -} from "@/assets/data/certificates"; -import type { User } from "@/assets/data/users"; +import type { CertificateRole } from "@/assets/data/certificates"; import { PRODUCTION_SITE_URL } from "@/lib/site-seo"; -import { getUserById } from "@/lib/users"; - -export type CertificateWithUser = Certificate & { - user: User; -}; - -export function getAllCertificateIds(): string[] { - return certificates.map((certificate) => certificate.id); -} - -export function getCertificateById(id: string): Certificate | undefined { - return certificates.find((certificate) => certificate.id === id); -} - -export function getCertificateWithUser( - id: string, -): CertificateWithUser | undefined { - const certificate = getCertificateById(id); - if (!certificate) { - return undefined; - } - - const user = getUserById(certificate.userId); - - if (!user) { - return undefined; - } - - return { ...certificate, user }; -} +export type { + Certificate, + CertificateRole, + ResolvedCertificate, +} from "@/assets/data/certificates"; export function getCertificateHref(id: string): string { return `/certificates/${id}/`; @@ -46,10 +16,6 @@ export function getCertificateUrl(id: string): string { return `${PRODUCTION_SITE_URL}${getCertificateHref(id)}`; } -export function getCertificatesForUser(userId: string): Certificate[] { - return certificates.filter((certificate) => certificate.userId === userId); -} - export const certificateRoleOrder: CertificateRole[] = [ "attendee", "volunteer", diff --git a/src/lib/users.ts b/src/lib/users.ts deleted file mode 100644 index 996454d..0000000 --- a/src/lib/users.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type User, users } from "@/assets/data/users"; - -export function getAllUsers(): User[] { - return users; -} - -export function getUserById(id: string): User | undefined { - return users.find((user) => user.id === id); -}