Files
nfc-pn532-warlord/web/src/ScanCashFlourish.tsx
drjones 8968560565 Add PN532 toolkit firmware, web UI, and embedded SPIFFS assets
Includes ESP-IDF NFC stack (deep capture, 4K Classic geometry, UL write API,
open SoftAP), React dashboard with live tag diagnostics, and docs. README
updated for APIs and lab Wi-Fi defaults.

Made-with: Cursor
2026-03-29 09:32:55 -07:00

235 lines
8.9 KiB
TypeScript

import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { useNfcWs } from "./NfcWsContext";
const SPARKS = 16;
const COINS = 7;
function playChaChing() {
try {
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (!Ctx) {
return;
}
const ctx = new Ctx();
const master = ctx.createGain();
master.gain.value = 0.11;
master.connect(ctx.destination);
const ding = (freq: number, t0: number, dur: number) => {
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = "sine";
o.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(1, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.01, t0 + dur);
o.connect(g);
g.connect(master);
o.start(t0);
o.stop(t0 + dur + 0.05);
};
const now = ctx.currentTime;
ding(523.25, now, 0.11);
ding(659.25, now + 0.055, 0.13);
ding(783.99, now + 0.1, 0.16);
ding(1046.5, now + 0.14, 0.2);
const noise = ctx.createBufferSource();
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.07, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (data.length * 0.32));
}
noise.buffer = buf;
const ng = ctx.createGain();
ng.gain.setValueAtTime(0.055, now + 0.04);
ng.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
noise.connect(ng);
ng.connect(master);
noise.start(now + 0.04);
noise.stop(now + 0.18);
void ctx.resume?.();
setTimeout(() => void ctx.close(), 700);
} catch {
/* autoplay / policy */
}
}
export default function ScanCashFlourish() {
const { cashWave, cashVariant, lastTag } = useNfcWs();
const [visible, setVisible] = useState(false);
const [burstKey, setBurstKey] = useState(0);
useEffect(() => {
if (cashWave === 0) {
return;
}
setBurstKey(cashWave);
setVisible(true);
playChaChing();
const t = window.setTimeout(() => setVisible(false), 1650);
return () => window.clearTimeout(t);
}, [cashWave]);
return (
<div className="pointer-events-none fixed left-2 top-[4.25rem] z-[55] sm:left-4 sm:top-[4.5rem] md:top-[5.25rem]">
<AnimatePresence mode="wait">
{visible ? (
<motion.div
key={burstKey}
className="relative flex flex-col items-start"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, scale: 0.92, filter: "blur(4px)" }}
transition={{ duration: 0.4 }}
>
<motion.div
className="absolute -left-8 -top-8 h-40 w-40 rounded-full bg-gradient-to-br from-amber-400/45 via-yellow-200/30 to-bubble-mint/35 blur-2xl"
initial={{ scale: 0.2, opacity: 0 }}
animate={{ scale: [0.2, 1.35, 1], opacity: [0, 1, 0.7] }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
/>
{Array.from({ length: SPARKS }).map((_, i) => {
const a = (i / SPARKS) * Math.PI * 2;
const dist = 56 + (i % 4) * 10;
return (
<motion.span
key={`s-${i}`}
className="absolute left-8 top-9 h-2 w-2 rounded-full bg-gradient-to-br from-yellow-100 to-amber-500 shadow-[0_0_12px_rgba(250,204,21,1)]"
initial={{ x: 0, y: 0, opacity: 0, scale: 0 }}
animate={{
x: Math.cos(a) * dist,
y: Math.sin(a) * dist,
opacity: [0, 1, 0],
scale: [0, 1.3, 0.3],
}}
transition={{ duration: 0.8, delay: i * 0.018, ease: "easeOut" }}
/>
);
})}
{Array.from({ length: COINS }).map((_, i) => (
<motion.span
key={`c-${i}`}
className="absolute left-8 top-8 text-xl sm:text-2xl"
initial={{ x: 0, y: 0, opacity: 0, rotate: -30, scale: 0 }}
animate={{
x: (i % 2 === 0 ? 1 : -1) * (36 + i * 12),
y: -32 - i * 11,
opacity: [0, 1, 0],
rotate: i * 35,
scale: [0, 1.15, 0.85],
}}
transition={{ duration: 0.9, delay: 0.04 + i * 0.035, ease: [0.22, 1, 0.36, 1] }}
>
🪙
</motion.span>
))}
<motion.div
className="relative flex h-[4.75rem] w-[4.75rem] items-center justify-center sm:h-[5.5rem] sm:w-[5.5rem]"
initial={{ scale: 0, rotate: -40 }}
animate={{
scale: [0, 1.3, 0.92, 1.06, 1],
rotate: [-40, 12, -6, 3, 0],
}}
transition={{ duration: 0.7, ease: [0.34, 1.56, 0.64, 1] }}
>
<motion.div
className="absolute inset-0 rounded-2xl border-2 border-yellow-200/90"
animate={{
boxShadow: [
"0 0 25px rgba(250,204,21,0.55), inset 0 0 22px rgba(254,240,138,0.2)",
"0 0 50px rgba(0,255,157,0.5), inset 0 0 28px rgba(0,229,255,0.15)",
"0 0 28px rgba(250,204,21,0.6), inset 0 0 18px rgba(254,240,138,0.25)",
],
}}
transition={{ duration: 1.1, repeat: 2 }}
/>
<svg
viewBox="0 0 100 100"
className="relative z-[1] h-[72%] w-[72%] drop-shadow-[0_0_14px_rgba(250,204,21,0.9)]"
aria-hidden
>
<defs>
<linearGradient id="cashGold" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#fef9c3" />
<stop offset="40%" stopColor="#facc15" />
<stop offset="100%" stopColor="#a16207" />
</linearGradient>
<filter id="cashGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.2" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<circle cx="50" cy="50" r="44" fill="url(#cashGold)" filter="url(#cashGlow)" />
<circle cx="50" cy="50" r="40" fill="none" stroke="#422006" strokeOpacity="0.28" strokeWidth="2" />
<text
x="50"
y="64"
textAnchor="middle"
fill="#713f12"
fontSize="54"
fontWeight="700"
fontFamily="Audiowide, Orbitron, system-ui, sans-serif"
>
$
</text>
</svg>
<motion.div
className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl"
initial={false}
>
<motion.div
className="absolute inset-y-2 w-2/5 bg-gradient-to-r from-transparent via-white/60 to-transparent skew-x-[-18deg]"
initial={{ left: "-40%" }}
animate={{ left: "140%" }}
transition={{ duration: 0.55, delay: 0.12, ease: "easeInOut" }}
/>
</motion.div>
</motion.div>
<motion.div
className="relative -mt-0.5 max-w-[12rem] pl-0.5"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12, type: "spring", stiffness: 320, damping: 22 }}
>
<motion.p
className="font-display text-[10px] tracking-[0.42em] text-yellow-100 sm:text-[11px]"
animate={{
textShadow: [
"0 0 10px rgba(250,204,21,0.95)",
"0 0 22px rgba(0,255,157,0.75)",
"0 0 12px rgba(250,204,21,0.9)",
],
}}
transition={{ duration: 0.75, repeat: 3 }}
>
{cashVariant === "vault" ? "VAULT · LOCKED" : "CHA-CHING · HIT"}
</motion.p>
{lastTag?.uid ? (
<motion.p
className="mt-1 truncate font-mono text-[10px] font-bold tracking-[0.15em] text-bubble-mint sm:text-xs"
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.22 }}
>
{lastTag.uid}
</motion.p>
) : null}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}