// waiv-mascot.jsx — "Tappy" the waiv card. Stays put (no more pacing), can be // dragged anywhere on the page, has little waving hands, eyes follow the cursor, // and it cycles emotes when tapped. Position persists in localStorage. (function () { const { useRef, useEffect, useState } = React; const EMOTES = [ { id: 'happy', say: 'hi there!' }, { id: 'dance', say: "let's dance!" }, { id: 'love', say: 'tap to connect ♥' }, { id: 'angry', say: 'grr — no app needed!' }, { id: 'sad', say: 'paper cards… sigh' }, { id: 'dizzy', say: 'woah, dizzy!' }, ]; const KEY = 'waiv.tappy.pos'; function WaivBuddy() { const rootRef = useRef(null); const faceRef = useRef(null); const pupL = useRef(null), pupR = useRef(null); const [emote, setEmote] = useState('idle'); const [bubble, setBubble] = useState(null); const emoteTimer = useRef(null); const drag = useRef(null); const movedRef = useRef(false); const posRef = useRef({ x: 40, y: 0 }); // place at saved position (or bottom-left) and keep in-bounds on resize useEffect(() => { const W = 86, H = 126; let saved = null; try { saved = JSON.parse(localStorage.getItem(KEY) || 'null'); } catch (e) {} const place = (x, y) => { const maxX = window.innerWidth - W - 6, maxY = window.innerHeight - H - 6; posRef.current = { x: Math.max(6, Math.min(maxX, x)), y: Math.max(6, Math.min(maxY, y)) }; if (rootRef.current) rootRef.current.style.transform = `translate(${posRef.current.x}px, ${posRef.current.y}px)`; }; place(saved ? saved.x : 40, saved ? saved.y : window.innerHeight - H - 24); const onResize = () => place(posRef.current.x, posRef.current.y); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); // drag useEffect(() => { const W = 86, H = 126; const onDown = (e) => { const root = rootRef.current; if (!root || !root.contains(e.target)) return; drag.current = { dx: e.clientX - posRef.current.x, dy: e.clientY - posRef.current.y }; movedRef.current = false; root.classList.add('is-held'); document.body.style.userSelect = 'none'; }; const onMove = (e) => { if (!drag.current) return; const nx = e.clientX - drag.current.dx, ny = e.clientY - drag.current.dy; if (Math.abs(e.movementX) + Math.abs(e.movementY) > 2) movedRef.current = true; const maxX = window.innerWidth - W - 6, maxY = window.innerHeight - H - 6; posRef.current = { x: Math.max(6, Math.min(maxX, nx)), y: Math.max(6, Math.min(maxY, ny)) }; rootRef.current.style.transform = `translate(${posRef.current.x}px, ${posRef.current.y}px)`; }; const onUp = () => { if (!drag.current) return; drag.current = null; rootRef.current && rootRef.current.classList.remove('is-held'); document.body.style.userSelect = ''; try { localStorage.setItem(KEY, JSON.stringify(posRef.current)); } catch (e) {} }; window.addEventListener('pointerdown', onDown); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); return () => { window.removeEventListener('pointerdown', onDown); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); }; }, []); // eyes follow cursor (disabled while dragging) useEffect(() => { const onMove = (e) => { if (drag.current) return; [pupL, pupR].forEach(ref => { const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); const dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2); const a = Math.atan2(dy, dx), d = Math.min(2.6, Math.hypot(dx, dy) / 40); el.style.transform = `translate(calc(-50% + ${Math.cos(a) * d}px), calc(-50% + ${Math.sin(a) * d}px))`; }); }; window.addEventListener('pointermove', onMove); return () => window.removeEventListener('pointermove', onMove); }, []); // intro hint useEffect(() => { const t = setTimeout(() => { setBubble('drag or tap me!'); setTimeout(() => setBubble(null), 2800); }, 2400); return () => clearTimeout(t); }, []); const poke = () => { if (movedRef.current) return; // ignore the click that ends a drag clearTimeout(emoteTimer.current); const order = ['happy', 'dance', 'love', 'angry', 'sad', 'dizzy']; const next = order[(order.indexOf(emote) + 1) % order.length]; const em = EMOTES.find(e => e.id === next); setEmote(em.id); setBubble(em.say); emoteTimer.current = setTimeout(() => { setEmote('idle'); setBubble(null); }, em.id === 'dance' ? 2600 : 1900); }; return (
{bubble}
💢
); } window.WaivBuddy = WaivBuddy; })();