diff --git a/web/app/globals.css b/web/app/globals.css index 0b3b28e..41eea20 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -241,3 +241,32 @@ body { /* Dossier folder card: motion now lives in components/hq/deck.tsx (framer-motion springs ported from the Framer "Document Folder"). */ + +/* My Passport: each visa stamps down with a slight overshoot as the book + opens (components/hq/passport.tsx). */ +@keyframes pp-stamp-in { + 0% { + transform: scale(1.7); + opacity: 0; + } + 55% { + opacity: 1; + } + 72% { + transform: scale(0.95); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.pp-stamp { + animation: pp-stamp-in 0.5s cubic-bezier(0.2, 0.85, 0.25, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .pp-stamp { + animation: none; + } +} diff --git a/web/components/hq/my-client.tsx b/web/components/hq/my-client.tsx index 0d12004..ebff83e 100644 --- a/web/components/hq/my-client.tsx +++ b/web/components/hq/my-client.tsx @@ -4,6 +4,7 @@ import { UserButton, useAuth, useUser } from "@clerk/nextjs"; import Link from "next/link"; import type { Hackathon } from "@/lib/types-hq"; import { PageShell } from "./page-shell"; +import { Passport } from "./passport"; import { Tracker } from "./tracker"; export function MyClient({ @@ -20,6 +21,7 @@ export function MyClient({ ) : ( <> + )} @@ -44,6 +46,7 @@ function GatedHub({ hackathons }: { hackathons: Hackathon[] }) { return ( <> + ); diff --git a/web/components/hq/passport.tsx b/web/components/hq/passport.tsx new file mode 100644 index 0000000..3f6daf0 --- /dev/null +++ b/web/components/hq/passport.tsx @@ -0,0 +1,831 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/* --------------------------------------------------------------------------- + My Passport — Members · Pillar 03 + + A React port of the "My Passport.dc.html" Claude Design component. A + collectible hacker passport that flips open in 3D to reveal ink-stamped + visas for every hackathon you've hacked. The original was authored in + Claude Design's DCLogic format; this keeps the artwork pixel-faithful + (leather cover, foil crest, guilloché pages, turbulence-warped stamps) + while adapting it to the app: project font tokens, reduced-motion support, + and a responsively scaled stage that lives inside a page shell rather than + a fixed full-screen overlay. +--------------------------------------------------------------------------- */ + +// Natural size of the passport spread (two 340px pages). The stage scales this +// down to fit narrow viewports; see the ResizeObserver below. +const BOOK_W = 680; +const BOOK_H = 470; + +type Stamp = { + id: string; + top: string; // arc text along the top + sub: string; // arc text along the bottom + year: string; + mono: string; // big monogram in the middle + label: string; // HACKED / VISA / ADMITTED + color: string; + pos: { left: number; top: number; size: number }; + rotate: number; + delay: number; // stamp-in animation delay, ms + ringOpacity?: number; + inkOpacity?: number; + topSize?: number; + topLS?: number; + subSize?: number; + subLS?: number; + monoSize?: number; +}; + +// Inside-left page (revealed under the opening cover). +const STAMPS_LEFT: Stamp[] = [ + { + id: "htn", + top: "HACK THE NORTH", + sub: "TORONTO · ON", + year: "2024", + mono: "HTN", + label: "HACKED", + color: "#ed5b29", + pos: { left: -6, top: 8, size: 196 }, + rotate: -8, + delay: 0, + topSize: 13, + topLS: 1.2, + }, + { + id: "pennapps", + top: "PENNAPPS", + sub: "PHILADELPHIA · PA", + year: "2024", + mono: "PA", + label: "VISA", + color: "#7a4bd0", + pos: { left: 150, top: 30, size: 190 }, + rotate: 9, + delay: 160, + ringOpacity: 0.9, + inkOpacity: 0.88, + subSize: 10, + subLS: 1.8, + }, + { + id: "mhacks", + top: "MHACKS", + sub: "ANN ARBOR · MI", + year: "2024", + mono: "MH", + label: "ADMITTED", + color: "#17b26a", + pos: { left: 22, top: 205, size: 190 }, + rotate: 6, + delay: 320, + }, + { + id: "h6ix", + top: "HACK THE 6IX", + sub: "TORONTO · ON", + year: "2025", + mono: "H6", + label: "HACKED", + color: "#ff7a45", + pos: { left: 140, top: 238, size: 190 }, + rotate: -13, + delay: 480, + ringOpacity: 0.9, + topSize: 14, + topLS: 1.4, + }, +]; + +// Right page (the base spread). +const STAMPS_RIGHT: Stamp[] = [ + { + id: "lahacks", + top: "LA HACKS", + sub: "LOS ANGELES · CA", + year: "2025", + mono: "LA", + label: "HACKED", + color: "#3b6bf0", + pos: { left: 12, top: 14, size: 196 }, + rotate: -10, + delay: 80, + }, + { + id: "bitcamp", + top: "BITCAMP", + sub: "COLLEGE PARK · MD", + year: "2025", + mono: "BC", + label: "VISA", + color: "#c98a2b", + pos: { left: 150, top: 44, size: 190 }, + rotate: 8, + delay: 240, + }, + { + id: "cuhacking", + top: "CUHACKING", + sub: "OTTAWA · ON", + year: "2025", + mono: "cuH", + label: "ADMITTED", + color: "#17b26a", + pos: { left: -4, top: 232, size: 190 }, + rotate: 12, + delay: 400, + topSize: 14, + topLS: 1.6, + monoSize: 30, + }, + { + id: "hackumass", + top: "HACKUMASS", + sub: "AMHERST · MA", + year: "2023", + mono: "UM", + label: "HACKED", + color: "#b0552f", + pos: { left: 150, top: 250, size: 190 }, + rotate: -6, + delay: 560, + ringOpacity: 0.85, + inkOpacity: 0.82, + topSize: 14, + topLS: 1.6, + }, +]; + +const MONO = "var(--font-mono)"; +const DISPLAY = "var(--font-display)"; + +/* Shared turbulence filters that give every stamp its rough, hand-inked edge. + Rendered once, referenced by url(#…) from all stamp SVGs. */ +function InkFilters() { + return ( + + + + + + + + + + + + + + + + ); +} + +function StampMark({ stamp, index }: { stamp: Stamp; index: number }) { + const { + top, + sub, + year, + mono, + label, + color, + ringOpacity = 0.92, + inkOpacity = 0.9, + topSize = 15, + topLS = 2.2, + subSize = 11, + subLS = 2.4, + monoSize = 34, + } = stamp; + const tA = `pp-arc-a-${index}`; + const tB = `pp-arc-b-${index}`; + + return ( + + + + + + + + + + + + + + + + + {top} + + + + + {sub} + + + + {year} + + + {mono} + + + {label} + + + + ); +} + +function StampLayer({ + stamps, + indexOffset, + visible, +}: { + stamps: Stamp[]; + indexOffset: number; + visible: boolean; +}) { + if (!visible) return null; + return ( +
+ {stamps.map((s, i) => ( +
+
+ +
+
+ ))} +
+ ); +} + +/* The foil crest on the cover: a trench-coated "incognito hacker" seal. */ +function CoverCrest() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +type Phase = "closed" | "flash" | "open"; + +export function Passport({ + stampCount = "08", + cities = "07", +}: { + stampCount?: string; + cities?: string; +}) { + const [phase, setPhase] = useState("closed"); + const [opened, setOpened] = useState(false); + const [scale, setScale] = useState(1); + const [reduced, setReduced] = useState(false); + const timers = useRef[]>([]); + const stageRef = useRef(null); + + // Respect the OS "reduce motion" setting — skip the flip/wobble choreography. + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const apply = () => setReduced(mq.matches); + apply(); + mq.addEventListener("change", apply); + return () => mq.removeEventListener("change", apply); + }, []); + + // Scale the fixed-size spread down to fit the available width. + useEffect(() => { + const el = stageRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + setScale(Math.min(1, el.clientWidth / BOOK_W)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const clearTimers = () => { + timers.current.forEach(clearTimeout); + timers.current = []; + }; + useEffect(() => () => clearTimers(), []); + + const open = () => { + if (phase !== "closed") return; + clearTimers(); + if (reduced) { + setPhase("open"); + setOpened(true); + return; + } + setPhase("flash"); + timers.current.push(setTimeout(() => setPhase("open"), 420)); + timers.current.push(setTimeout(() => setOpened(true), 420 + 720)); + }; + + const close = () => { + clearTimers(); + setOpened(false); + setPhase("closed"); + }; + + const closed = phase === "closed"; + const bookTransform = + phase === "open" + ? "translateX(0px) translateZ(0.01px) rotateZ(0deg) scale(1)" + : phase === "flash" + ? "translateX(-170px) translateZ(0.01px) rotateZ(0deg) scale(1)" + : "translateX(-170px) translateZ(0.01px) rotateZ(-4deg) scale(0.94)"; + const coverRotate = phase === "open" ? "-168deg" : "0deg"; + const bookTransition = reduced + ? "none" + : "transform 820ms cubic-bezier(.22,1,.32,1)"; + const coverTransition = reduced + ? "none" + : "transform 1100ms cubic-bezier(.5,.05,.2,1)"; + + return ( +
+
+ + + {/* Header chrome */} +
+
Members · Pillar 03
+

+ My Passport +

+
+ {stampCount} stamps · {cities} cities +
+
+ + {/* Stage */} +
+
+
+
+ {/* Contact shadow */} +
+ + {/* RIGHT PAGE (base) */} +
+
+
+
+ HQ +
+
+
+
+ Visas · Stamps +
+
+
+
+ P. 04 +
+ + +
+ + {/* COVER (rotates open) */} +
+ {/* FRONT = cover art */} +
+
+
+
+
+ +
+
+ Global Hackathon Union +
+ +
+
+
+ +
+
+
+ HACKER +
+
+ PASSPORT +
+
+
+ +
+
+
+
+
+
+
+ TYPE H · HQ · CODE 2026 +
+
+
+
+ + {/* BACK = inside-left page */} +
+
+
+
+ Holder · Hacks +
+
+
+
+ P. 03 +
+ + +
+
+ + {/* Spine shadow */} +
+
+
+
+
+ + {/* Open / close control */} +
+ +
+
+
+ ); +}