import React, { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import * as THREE from "three"; import { Canvas, extend, useFrame, useThree } from "@react-three/fiber"; import { Environment, Lightformer, useGLTF } from "@react-three/drei"; import { BallCollider, CuboidCollider, Physics, RigidBody, useRopeJoint, useSphericalJoint, } from "@react-three/rapier"; import { MeshLineGeometry, MeshLineMaterial } from "meshline"; import "./pass.css"; extend({ MeshLineGeometry, MeshLineMaterial }); const MODEL_URL = "/static/assets/tag.glb"; useGLTF.preload(MODEL_URL); function fitText(ctx, text, maxWidth, initialSize) { let size = initialSize; while (size > 30) { ctx.font = `600 ${size}px Arial, sans-serif`; if (ctx.measureText(text).width <= maxWidth) break; size -= 2; } return size; } function createBadgeTexture(pass) { const canvas = document.createElement("canvas"); canvas.width = 912; canvas.height = 1288; const ctx = canvas.getContext("2d"); const stateColors = { VALID: "#b9f8d3", "NOT ACTIVE": "#e8c986", EXPIRED: "#df9298", }; const accent = stateColors[pass.status] || "#ffffff"; ctx.fillStyle = "#050505"; ctx.fillRect(0, 0, canvas.width, canvas.height); const sheen = ctx.createLinearGradient(0, 0, 912, 1288); sheen.addColorStop(0, "#ffffff09"); sheen.addColorStop(0.45, "transparent"); sheen.addColorStop(1, "#ffffff05"); ctx.fillStyle = sheen; ctx.fillRect(0, 0, 912, 1288); ctx.strokeStyle = "#ffffff24"; ctx.lineWidth = 1.5; ctx.strokeRect(30, 30, 852, 1228); ctx.fillStyle = "#ffffff"; ctx.font = "700 29px Arial, sans-serif"; ctx.fillText("PASS", 58, 79); ctx.fillStyle = "#8f8f8f"; ctx.fillText("PORT", 140, 79); ctx.font = "500 13px monospace"; ctx.fillText("DIGITAL ACCESS SYSTEM", 58, 105); ctx.textAlign = "right"; ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(700, 77, 6, 0, Math.PI * 2); ctx.fill(); ctx.font = "600 16px monospace"; ctx.fillText(pass.status, 852, 83); ctx.textAlign = "left"; ctx.strokeStyle = "#ffffff2d"; ctx.beginPath(); ctx.moveTo(58, 145); ctx.lineTo(854, 145); ctx.stroke(); ctx.save(); ctx.globalAlpha = 0.075; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 8; ctx.font = "800 350px Arial, sans-serif"; ctx.strokeText("P", 590, 520); for (let row = 0; row < 9; row += 1) { for (let column = 0; column < 9; column += 1) { const radius = Math.max(1.5, 5 - (row + column) * 0.18); ctx.beginPath(); ctx.arc(620 + column * 25, 250 + row * 25, radius, 0, Math.PI * 2); ctx.fillStyle = "#ffffff"; ctx.fill(); } } ctx.restore(); ctx.fillStyle = "#929292"; ctx.font = "500 14px monospace"; ctx.fillText("TEMPORARY / GUEST", 58, 220); ctx.textAlign = "right"; ctx.fillText(`NO. ${pass.credentialId}`, 854, 220); ctx.textAlign = "left"; ctx.fillStyle = "#ffffff"; ctx.font = "700 86px Arial, sans-serif"; ctx.fillText("ACCESS", 52, 350); ctx.font = "300 86px Arial, sans-serif"; ctx.fillText("PASS", 52, 430); ctx.fillStyle = "#858585"; ctx.font = "500 14px monospace"; ctx.fillText("PASS HOLDER", 58, 545); ctx.fillStyle = "#ffffff"; const holderSize = fitText(ctx, pass.holder, 796, 66); ctx.font = `600 ${holderSize}px Arial, sans-serif`; ctx.fillText(pass.holder, 55, 625); ctx.strokeStyle = "#ffffff2d"; ctx.beginPath(); ctx.moveTo(58, 685); ctx.lineTo(854, 685); ctx.stroke(); ctx.fillStyle = "#858585"; ctx.font = "500 14px monospace"; ctx.fillText("AUTHORIZED LOCATION", 58, 738); ctx.fillText("VALID FROM", 520, 738); ctx.fillStyle = "#ffffff"; const locationSize = fitText(ctx, pass.location, 400, 32); ctx.font = `500 ${locationSize}px Arial, sans-serif`; ctx.fillText(pass.location, 58, 782); ctx.font = "500 20px monospace"; ctx.fillText(pass.validFrom, 520, 780); ctx.fillStyle = "#858585"; ctx.font = "500 14px monospace"; ctx.fillText("VALID UNTIL", 520, 842); ctx.fillStyle = "#ffffff"; ctx.font = "500 20px monospace"; ctx.fillText(pass.validUntil, 520, 884); ctx.strokeStyle = "#ffffff2d"; ctx.beginPath(); ctx.moveTo(58, 960); ctx.lineTo(854, 960); ctx.stroke(); ctx.fillStyle = accent; ctx.beginPath(); ctx.arc(70, 1024, 13, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#050505"; ctx.font = "700 14px Arial, sans-serif"; ctx.fillText("✓", 65, 1029); ctx.fillStyle = "#9a9a9a"; ctx.font = "500 13px monospace"; ctx.fillText("DIGITALLY SIGNED ACCESS CREDENTIAL", 100, 1020); ctx.fillText("ISSUER / PASSPORT", 100, 1045); ctx.textAlign = "right"; ctx.fillStyle = "#ffffff"; ctx.fillRect(803, 1003, 51, 51); ctx.fillStyle = "#050505"; ctx.font = "700 26px Arial, sans-serif"; ctx.fillText("P", 838, 1037); ctx.textAlign = "left"; ctx.fillStyle = "#616161"; ctx.font = "500 13px monospace"; ctx.fillText("TAP CARD TO VIEW VERIFICATION DETAILS", 58, 1205); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 16; return texture; } function createBackTexture(pass) { const canvas = document.createElement("canvas"); canvas.width = 912; canvas.height = 1288; const ctx = canvas.getContext("2d"); const accent = pass.status === "VALID" ? "#b9f8d3" : pass.status === "EXPIRED" ? "#df9298" : "#e8c986"; ctx.fillStyle = "#050505"; ctx.fillRect(0, 0, 912, 1288); ctx.strokeStyle = "#ffffff24"; ctx.lineWidth = 1.5; ctx.strokeRect(30, 30, 852, 1228); ctx.fillStyle = "#fff"; ctx.font = "700 27px Arial, sans-serif"; ctx.fillText("PASS PORT", 58, 82); ctx.textAlign = "right"; ctx.fillStyle = "#777"; ctx.font = "500 14px monospace"; ctx.fillText("CREDENTIAL / REVERSE", 854, 82); ctx.textAlign = "left"; ctx.save(); ctx.globalAlpha = 0.09; ctx.strokeStyle = "#fff"; ctx.lineWidth = 8; ctx.font = "800 190px Arial, sans-serif"; ctx.strokeText("SIGNED", 42, 315); ctx.restore(); ctx.fillStyle = "#8c8c8c"; ctx.font = "500 14px monospace"; ctx.fillText("VERIFICATION STATUS", 58, 385); ctx.fillStyle = accent; ctx.font = "600 54px Arial, sans-serif"; ctx.fillText(pass.status, 55, 453); ctx.strokeStyle = "#ffffff2d"; ctx.beginPath(); ctx.moveTo(58, 505); ctx.lineTo(854, 505); ctx.stroke(); const rows = [ ["ISSUER", "PASSPORT"], ["SIGNATURE", "HS256"], ["CREDENTIAL NO.", pass.credentialId], ["PASS HOLDER", pass.holder], ["ACCESS POINT", pass.location], ]; rows.forEach(([label, value], index) => { const y = 570 + index * 92; ctx.fillStyle = "#777"; ctx.font = "500 14px monospace"; ctx.fillText(label, 58, y); ctx.textAlign = "right"; ctx.fillStyle = "#fff"; ctx.font = "500 20px Arial, sans-serif"; ctx.fillText(value, 854, y); ctx.textAlign = "left"; ctx.strokeStyle = "#ffffff17"; ctx.beginPath(); ctx.moveTo(58, y + 34); ctx.lineTo(854, y + 34); ctx.stroke(); }); ctx.fillStyle = "#777"; ctx.font = "500 14px monospace"; ctx.fillText("USAGE", 58, 1060); ctx.fillStyle = "#d5d5d5"; ctx.font = "500 19px Arial, sans-serif"; ctx.fillText("Present this credential at the authorized access point.", 58, 1100); ctx.fillText("Do not share the credential URL with another person.", 58, 1135); ctx.fillStyle = "#666"; ctx.font = "500 13px monospace"; ctx.fillText("TAP CARD TO RETURN TO FRONT", 58, 1210); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 16; return texture; } function createBandTexture() { const canvas = document.createElement("canvas"); canvas.width = 1024; canvas.height = 160; const ctx = canvas.getContext("2d"); ctx.fillStyle = "#090909"; ctx.fillRect(0, 0, 1024, 160); ctx.strokeStyle = "#333"; ctx.strokeRect(0, 4, 1024, 152); ctx.fillStyle = "#eee"; ctx.font = "600 35px Arial, sans-serif"; ctx.textBaseline = "middle"; for (let x = 30; x < 1024; x += 330) { ctx.fillText("PASS PORT", x, 80); ctx.fillStyle = "#555"; ctx.fillText(" / ACCESS", x + 190, 80); ctx.fillStyle = "#eee"; } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.wrapS = texture.wrapT = THREE.RepeatWrapping; return texture; } function Band({ pass, flipped, onFlip, maxSpeed = 50, minSpeed = 10 }) { const band = useRef(); const fixed = useRef(); const joint1 = useRef(); const joint2 = useRef(); const joint3 = useRef(); const card = useRef(); const cardVisual = useRef(); const { nodes, materials } = useGLTF(MODEL_URL); const bandTexture = useMemo(() => createBandTexture(), []); const badgeTexture = useMemo(() => createBadgeTexture(pass), [pass]); const backTexture = useMemo(() => createBackTexture(pass), [pass]); const { width, height } = useThree((state) => state.size); const curve = useMemo( () => new THREE.CatmullRomCurve3([ new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3(), ]), [], ); const vec = useMemo(() => new THREE.Vector3(), []); const angularVelocity = useMemo(() => new THREE.Vector3(), []); const rotation = useMemo(() => new THREE.Vector3(), []); const direction = useMemo(() => new THREE.Vector3(), []); const [dragged, setDragged] = useState(false); const [hovered, setHovered] = useState(false); const segmentProps = { canSleep: true, colliders: false, angularDamping: 2, linearDamping: 2, }; useRopeJoint(fixed, joint1, [[0, 0, 0], [0, 0, 0], 1]); useRopeJoint(joint1, joint2, [[0, 0, 0], [0, 0, 0], 1]); useRopeJoint(joint2, joint3, [[0, 0, 0], [0, 0, 0], 1]); useSphericalJoint(joint3, card, [[0, 0, 0], [0, 1.45, 0]]); useEffect(() => { document.body.style.cursor = hovered ? (dragged ? "grabbing" : "grab") : "auto"; return () => { document.body.style.cursor = "auto"; }; }, [hovered, dragged]); useEffect(() => () => { badgeTexture.dispose(); backTexture.dispose(); bandTexture.dispose(); }, [badgeTexture, backTexture, bandTexture]); useFrame((state, delta) => { if (cardVisual.current) { cardVisual.current.rotation.y = THREE.MathUtils.damp( cardVisual.current.rotation.y, flipped ? Math.PI : 0, 7, delta, ); } if (dragged) { vec.set(state.pointer.x, state.pointer.y, 0.5).unproject(state.camera); direction.copy(vec).sub(state.camera.position).normalize(); vec.add(direction.multiplyScalar(state.camera.position.length())); [card, joint1, joint2, joint3, fixed].forEach((ref) => ref.current?.wakeUp()); card.current?.setNextKinematicTranslation({ x: vec.x - dragged.x, y: vec.y - dragged.y, z: vec.z - dragged.z, }); } if (fixed.current) { [joint1, joint2].forEach((ref) => { if (!ref.current.lerped) { ref.current.lerped = new THREE.Vector3().copy(ref.current.translation()); } const distance = ref.current.lerped.distanceTo(ref.current.translation()); const clampedDistance = Math.max(0.1, Math.min(1, distance)); ref.current.lerped.lerp( ref.current.translation(), delta * (minSpeed + clampedDistance * (maxSpeed - minSpeed)), ); }); curve.points[0].copy(joint3.current.translation()); curve.points[1].copy(joint2.current.lerped); curve.points[2].copy(joint1.current.lerped); curve.points[3].copy(fixed.current.translation()); band.current.geometry.setPoints(curve.getPoints(32)); angularVelocity.copy(card.current.angvel()); rotation.copy(card.current.rotation()); card.current.setAngvel({ x: angularVelocity.x, y: angularVelocity.y - rotation.y * 0.25, z: angularVelocity.z, }, false); } }); curve.curveType = "chordal"; bandTexture.wrapS = bandTexture.wrapT = THREE.RepeatWrapping; return ( <> setHovered(true)} onPointerOut={() => setHovered(false)} onPointerUp={(event) => { event.target.releasePointerCapture(event.pointerId); setDragged(false); }} onPointerDown={(event) => { event.target.setPointerCapture(event.pointerId); setDragged(event.point.clone().sub(vec.copy(card.current.translation()))); }} onClick={(event) => { event.stopPropagation(); if (event.delta < 5) onFlip(); }} > ); } function PassScene({ pass, flipped, onFlip }) { return ( ); } function App() { const element = document.getElementById("pass-root"); const [flipped, setFlipped] = useState(false); const pass = useMemo(() => ({ holder: element.dataset.holder, location: element.dataset.location, status: element.dataset.status, validFrom: element.dataset.validFrom, validUntil: element.dataset.validUntil, credentialId: element.dataset.credentialId, }), [element]); return ( <> Loading credential}> setFlipped((value) => !value)} /> ); } createRoot(document.getElementById("pass-root")).render();