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
This commit is contained in:
234
web/src/ScanCashFlourish.tsx
Normal file
234
web/src/ScanCashFlourish.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user