export type ConfettiOrigin = { x: number; y: number; }; export type AttendanceConfettiMode = "checkIn" | "checkOut"; type ConfettiOptions = { origin?: ConfettiOrigin; colors: string[]; particleCount: number; durationMs: number; }; let activeCanvas: HTMLCanvasElement | null = null; let activeAnimationId: number | null = null; const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); const createCanvas = () => { const canvas = document.createElement("canvas"); canvas.style.position = "fixed"; canvas.style.inset = "0"; canvas.style.pointerEvents = "none"; canvas.style.zIndex = "9999"; canvas.width = window.innerWidth; canvas.height = window.innerHeight; document.body.appendChild(canvas); return canvas; }; const teardownCanvas = () => { if (activeAnimationId != null) { cancelAnimationFrame(activeAnimationId); activeAnimationId = null; } if (activeCanvas && activeCanvas.parentElement) { activeCanvas.parentElement.removeChild(activeCanvas); } activeCanvas = null; }; export const getConfettiOriginFromElement = (el: HTMLElement | null): ConfettiOrigin | undefined => { if (!el) return undefined; const rect = el.getBoundingClientRect(); if (!rect.width || !rect.height) return undefined; return { x: clamp01((rect.left + rect.width / 2) / window.innerWidth), y: clamp01((rect.top + rect.height / 2) / window.innerHeight), }; }; export const triggerConfetti = (opts: ConfettiOptions) => { if (typeof window === "undefined" || typeof document === "undefined") return; teardownCanvas(); activeCanvas = createCanvas(); const ctx = activeCanvas.getContext("2d"); if (!ctx) { teardownCanvas(); return; } const origin = { x: clamp01(opts.origin?.x ?? 0.5), y: clamp01(opts.origin?.y ?? 0.35), }; const start = performance.now(); const duration = Math.max(300, opts.durationMs); const resize = () => { if (!activeCanvas) return; activeCanvas.width = window.innerWidth; activeCanvas.height = window.innerHeight; }; window.addEventListener("resize", resize, { passive: true }); const w = () => activeCanvas?.width ?? window.innerWidth; const h = () => activeCanvas?.height ?? window.innerHeight; const startX = origin.x * w(); const startY = origin.y * h(); const colors = opts.colors; const particles = Array.from({ length: Math.max(10, opts.particleCount) }).map(() => { const angle = (-Math.PI / 2) + (Math.random() - 0.5) * (Math.PI / 1.6); const speed = 6 + Math.random() * 10; const size = 4 + Math.random() * 6; const spin = (Math.random() - 0.5) * 0.3; return { x: startX, y: startY, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, g: 0.25 + Math.random() * 0.35, r: Math.random() * Math.PI, vr: spin, size, color: colors[Math.floor(Math.random() * colors.length)], seed: Math.random(), }; }); const loop = (now: number) => { const t = now - start; const p = Math.min(1, t / duration); if (!activeCanvas) return; ctx.clearRect(0, 0, w(), h()); for (const part of particles) { part.vx *= 0.985; part.vy = part.vy * 0.985 + part.g; part.x += part.vx; part.y += part.vy; part.r += part.vr; const alpha = 1 - Math.pow(p, 1.8); if (alpha <= 0) continue; const flutter = Math.sin((now / 80) + part.seed * 10) * 0.6; ctx.save(); ctx.globalAlpha = alpha; ctx.translate(part.x, part.y); ctx.rotate(part.r + flutter); ctx.fillStyle = part.color; ctx.fillRect(-part.size / 2, -part.size / 2, part.size, part.size * 0.6); ctx.restore(); } if (p >= 1) { window.removeEventListener("resize", resize); teardownCanvas(); return; } activeAnimationId = requestAnimationFrame(loop); }; activeAnimationId = requestAnimationFrame(loop); }; export const triggerAttendanceConfetti = ( mode: AttendanceConfettiMode, origin?: ConfettiOrigin ) => { if (mode === "checkIn") { triggerConfetti({ origin, colors: ["#22c55e", "#16a34a", "#3b82f6", "#38bdf8", "#facc15"], particleCount: 90, durationMs: 1200, }); return; } triggerConfetti({ origin, colors: ["#f97316", "#fb7185", "#a855f7", "#60a5fa", "#facc15"], particleCount: 110, durationMs: 1400, }); };