diff --git a/frontend/pass.css b/frontend/pass.css
index a5cbf9c..b2b866e 100644
--- a/frontend/pass.css
+++ b/frontend/pass.css
@@ -32,3 +32,37 @@ canvas {
text-transform: uppercase;
place-items: center;
}
+
+.flip-control {
+ position: fixed;
+ z-index: 5;
+ right: 50%;
+ bottom: 28px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 11px 16px;
+ border: 1px solid #ffffff2b;
+ border-radius: 999px;
+ color: #d7d7d7;
+ background: #0b0b0be6;
+ box-shadow: 0 10px 30px #0008;
+ font: 500 11px monospace;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ cursor: pointer;
+ transform: translateX(50%);
+ transition: color 160ms ease, border-color 160ms ease, background 160ms ease;
+}
+
+.flip-control:hover,
+.flip-control:focus-visible {
+ border-color: #ffffff70;
+ color: #fff;
+ background: #151515f2;
+ outline: none;
+}
+
+.flip-control span {
+ font: 17px Arial, sans-serif;
+}
diff --git a/frontend/pass.jsx b/frontend/pass.jsx
index aee87be..1cb3d33 100644
--- a/frontend/pass.jsx
+++ b/frontend/pass.jsx
@@ -45,31 +45,24 @@ function createBadgeTexture(pass) {
ctx.fillStyle = "#050505";
ctx.fillRect(0, 0, canvas.width, canvas.height);
- const sheen = ctx.createLinearGradient(0, 0, 912, 970);
+ 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, 970);
+ ctx.fillRect(0, 0, 912, 1288);
ctx.strokeStyle = "#ffffff24";
ctx.lineWidth = 1.5;
- ctx.strokeRect(30, 30, 852, 925);
+ ctx.strokeRect(30, 30, 852, 1228);
ctx.fillStyle = "#ffffff";
- ctx.beginPath();
- ctx.moveTo(58, 88);
- ctx.lineTo(79, 52);
- ctx.lineTo(100, 88);
- ctx.closePath();
- ctx.fill();
-
ctx.font = "700 29px Arial, sans-serif";
- ctx.fillText("PASS", 122, 79);
+ ctx.fillText("PASS", 58, 79);
ctx.fillStyle = "#8f8f8f";
- ctx.fillText("PORT", 204, 79);
+ ctx.fillText("PORT", 140, 79);
ctx.font = "500 13px monospace";
- ctx.fillText("DIGITAL ACCESS SYSTEM", 122, 105);
+ ctx.fillText("DIGITAL ACCESS SYSTEM", 58, 105);
ctx.textAlign = "right";
ctx.fillStyle = accent;
@@ -91,12 +84,12 @@ function createBadgeTexture(pass) {
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 8;
ctx.font = "800 350px Arial, sans-serif";
- ctx.strokeText("P", 590, 465);
+ 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, 220 + row * 25, radius, 0, Math.PI * 2);
+ ctx.arc(620 + column * 25, 250 + row * 25, radius, 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
}
@@ -105,91 +98,205 @@ function createBadgeTexture(pass) {
ctx.fillStyle = "#929292";
ctx.font = "500 14px monospace";
- ctx.fillText("TEMPORARY / GUEST", 58, 208);
+ ctx.fillText("TEMPORARY / GUEST", 58, 220);
ctx.textAlign = "right";
- ctx.fillText(`NO. ${pass.credentialId}`, 854, 208);
+ ctx.fillText(`NO. ${pass.credentialId}`, 854, 220);
ctx.textAlign = "left";
ctx.fillStyle = "#ffffff";
ctx.font = "700 86px Arial, sans-serif";
- ctx.fillText("ACCESS", 52, 322);
+ ctx.fillText("ACCESS", 52, 350);
ctx.font = "300 86px Arial, sans-serif";
- ctx.fillText("PASS", 52, 402);
+ ctx.fillText("PASS", 52, 430);
ctx.fillStyle = "#858585";
ctx.font = "500 14px monospace";
- ctx.fillText("PASS HOLDER", 58, 493);
+ 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, 560);
+ ctx.fillText(pass.holder, 55, 625);
ctx.strokeStyle = "#ffffff2d";
ctx.beginPath();
- ctx.moveTo(58, 610);
- ctx.lineTo(854, 610);
+ ctx.moveTo(58, 685);
+ ctx.lineTo(854, 685);
ctx.stroke();
ctx.fillStyle = "#858585";
ctx.font = "500 14px monospace";
- ctx.fillText("AUTHORIZED LOCATION", 58, 657);
- ctx.fillText("VALID FROM", 520, 657);
+ 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, 699);
+ ctx.fillText(pass.location, 58, 782);
ctx.font = "500 20px monospace";
- ctx.fillText(pass.validFrom, 520, 697);
+ ctx.fillText(pass.validFrom, 520, 780);
ctx.fillStyle = "#858585";
ctx.font = "500 14px monospace";
- ctx.fillText("VALID UNTIL", 520, 757);
+ ctx.fillText("VALID UNTIL", 520, 842);
ctx.fillStyle = "#ffffff";
ctx.font = "500 20px monospace";
- ctx.fillText(pass.validUntil, 520, 797);
+ ctx.fillText(pass.validUntil, 520, 884);
ctx.strokeStyle = "#ffffff2d";
ctx.beginPath();
- ctx.moveTo(58, 840);
- ctx.lineTo(854, 840);
+ ctx.moveTo(58, 960);
+ ctx.lineTo(854, 960);
ctx.stroke();
ctx.fillStyle = accent;
ctx.beginPath();
- ctx.arc(70, 893, 13, 0, Math.PI * 2);
+ ctx.arc(70, 1024, 13, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#050505";
ctx.font = "700 14px Arial, sans-serif";
- ctx.fillText("✓", 65, 898);
+ ctx.fillText("✓", 65, 1029);
ctx.fillStyle = "#9a9a9a";
ctx.font = "500 13px monospace";
- ctx.fillText("DIGITALLY SIGNED ACCESS CREDENTIAL", 100, 890);
- ctx.fillText("ISSUER / PASSPORT", 100, 915);
+ ctx.fillText("DIGITALLY SIGNED ACCESS CREDENTIAL", 100, 1020);
+ ctx.fillText("ISSUER / PASSPORT", 100, 1045);
ctx.textAlign = "right";
ctx.fillStyle = "#ffffff";
- ctx.fillRect(803, 876, 51, 51);
+ ctx.fillRect(803, 1003, 51, 51);
ctx.fillStyle = "#050505";
ctx.font = "700 26px Arial, sans-serif";
- ctx.fillText("P", 838, 910);
+ 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;
- texture.flipY = false;
return texture;
}
-function Band({ pass, maxSpeed = 50, minSpeed = 10 }) {
+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 = useTexture(BAND_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([
@@ -224,9 +331,22 @@ function Band({ pass, maxSpeed = 50, minSpeed = 10 }) {
return () => { document.body.style.cursor = "auto"; };
}, [hovered, dragged]);
- useEffect(() => () => badgeTexture.dispose(), [badgeTexture]);
+ 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();
@@ -292,6 +412,7 @@ function Band({ pass, maxSpeed = 50, minSpeed = 10 }) {
>
setHovered(true)}
@@ -304,11 +425,14 @@ function Band({ pass, maxSpeed = 50, minSpeed = 10 }) {
event.target.setPointerCapture(event.pointerId);
setDragged(event.point.clone().sub(vec.copy(card.current.translation())));
}}
+ onClick={(event) => {
+ event.stopPropagation();
+ if (event.delta < 5) onFlip();
+ }}
>
+
+
+
+
+
+
+
+
@@ -339,12 +486,12 @@ function Band({ pass, maxSpeed = 50, minSpeed = 10 }) {
);
}
-function PassScene({ pass }) {
+function PassScene({ pass, flipped, onFlip }) {
return (