"use client"; import { useState, useTransition, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; import confetti from "canvas-confetti"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import type { BangStatsDto } from "../server/queries"; import { addBang } from "../server/actions"; // ─── Celebration engine ─────────────────────────────────────────────────────── function launchFirework(originX: number, originY: number) { confetti({ particleCount: 80, startVelocity: 45, spread: 360, origin: { x: originX, y: originY }, colors: ["#ff0", "#f0f", "#0ff", "#f60", "#0f6", "#06f"], shapes: ["circle", "square"], gravity: 0.8, scalar: 1.2, ticks: 200, }); } function launchSideCannon(side: "left" | "right") { confetti({ particleCount: 120, angle: side === "left" ? 60 : 120, spread: 55, origin: { x: side === "left" ? 0 : 1, y: 0.65 }, colors: ["#ff4e50", "#fc913a", "#f9d423", "#ede574", "#e1f5c4"], shapes: ["circle", "square", "star"], scalar: 1.1, ticks: 300, }); } function launchStarBurst() { const defaults = { spread: 360, ticks: 100, gravity: 0, decay: 0.94, startVelocity: 30, shapes: ["star"] as confetti.Shape[], colors: ["FFE400", "FFBD00", "E89400", "FFCA6C", "FDFFB8"], }; function shoot() { confetti({ ...defaults, particleCount: 40, scalar: 1.2, shapes: ["star"] }); confetti({ ...defaults, particleCount: 15, scalar: 0.75, shapes: ["circle"] }); } shoot(); setTimeout(shoot, 100); setTimeout(shoot, 200); } function synthFireworkSound() { try { const ctx = new AudioContext(); // Rising "pew" tone const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.setValueAtTime(200, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.12); gain.gain.setValueAtTime(0.35, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18); osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.18); // Noise "boom" const bufferSize = ctx.sampleRate * 0.4; const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1; const noise = ctx.createBufferSource(); noise.buffer = buffer; const noiseGain = ctx.createGain(); const bpFilter = ctx.createBiquadFilter(); bpFilter.type = "bandpass"; bpFilter.frequency.value = 150; noise.connect(bpFilter); bpFilter.connect(noiseGain); noiseGain.connect(ctx.destination); noiseGain.gain.setValueAtTime(0.6, ctx.currentTime + 0.15); noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6); noise.start(ctx.currentTime + 0.15); noise.stop(ctx.currentTime + 0.6); } catch { // Autoplay policy blocked — silently skip } } type EmojiParticle = { id: number; emoji: string; x: number; y: number; size: number; delay: number; }; const CELEBRATION_EMOJIS = [ "💥", "🎉", "🔥", "✨", "🎊", "🥳", "💦", "😈", "🎆", "⭐", "🍆", "💫", "🌟", "🎇", "🍒", ]; function EmojiOverlay({ particles }: { particles: EmojiParticle[] }) { if (particles.length === 0) return null; return createPortal(
{particles.map((p) => ( {p.emoji} ))}
, document.body, ); } // ─── Component ──────────────────────────────────────────────────────────────── type Props = { stats: BangStatsDto; maxRecentBangs: number; }; export function BangWidget({ stats, maxRecentBangs }: Props) { const [open, setOpen] = useState(false); const [dateValue, setDateValue] = useState(todayValue()); const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); const [emojiParticles, setEmojiParticles] = useState([]); const [countPop, setCountPop] = useState(false); const [shake, setShake] = useState(false); const [flash, setFlash] = useState(false); const particleIdRef = useRef(0); const spawnEmojis = useCallback(() => { const batch: EmojiParticle[] = Array.from({ length: 50 }, () => ({ id: ++particleIdRef.current, emoji: CELEBRATION_EMOJIS[Math.floor(Math.random() * CELEBRATION_EMOJIS.length)]!, x: Math.random() * 100, y: 20 + Math.random() * 70, // keep out of very top/bottom edges so float-up is visible size: 1.5 + Math.random() * 2, delay: Math.random() * 600, // stagger spawning over 600ms so it feels like a cascade })); setEmojiParticles((prev) => [...prev, ...batch]); setTimeout(() => { const ids = new Set(batch.map((p) => p.id)); setEmojiParticles((prev) => prev.filter((p) => !ids.has(p.id))); }, 2400); }, []); const triggerCelebration = useCallback(() => { setFlash(true); setTimeout(() => setFlash(false), 120); setShake(true); setTimeout(() => setShake(false), 350); setCountPop(true); setTimeout(() => setCountPop(false), 400); synthFireworkSound(); spawnEmojis(); launchStarBurst(); launchSideCannon("left"); setTimeout(() => launchSideCannon("right"), 150); setTimeout(() => launchFirework(0.3, 0.7), 200); setTimeout(() => launchFirework(0.7, 0.65), 450); setTimeout(() => launchFirework(0.5, 0.6), 700); setTimeout(() => launchFirework(0.2, 0.75), 950); setTimeout(() => launchFirework(0.8, 0.7), 1100); setTimeout(() => { launchSideCannon("left"); launchSideCannon("right"); }, 1300); setTimeout(() => launchFirework(0.5, 0.5), 1600); }, [spawnEmojis]); function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); startTransition(async () => { try { await addBang({ occurredOn: dateValue }); setOpen(false); triggerCelebration(); } catch { setError("Something went wrong. Try again."); } }); } return (
{flash && (
)}
{stats.total} {stats.total === 1 ? "bang" : "bangs"}
} > 💥 Add Bang Record a bang
setDateValue(e.target.value)} className="input input-sm" required />

Change if you're documenting a bang from a previous day.

{error &&

{error}

}
{stats.recent.length > 0 && (

Last {Math.min(stats.recent.length, maxRecentBangs)}

{stats.recent.map((bang) => (
{formatBangDate(bang.occurredOn)} {bang.recordedByName && ( {bang.recordedByName} )}
))}
)} {stats.total === 0 && (

No bangs yet. Add the first one!

)}
); } function todayValue(): string { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function formatBangDate(iso: string): string { const [year, month, day] = iso.split("-").map(Number); const d = new Date(year!, month! - 1, day!); return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); }