|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useCallback, useEffect, useRef, useState } from 'react' |
| 4 | + |
| 5 | +/* ── Particella ── */ |
| 6 | +interface Particle { |
| 7 | + x: number; y: number |
| 8 | + vx: number; vy: number |
| 9 | + color: string |
| 10 | + size: number |
| 11 | + rotation: number |
| 12 | + rotSpeed: number |
| 13 | + shape: 'rect' | 'circle' | 'ribbon' |
| 14 | + alpha: number |
| 15 | +} |
| 16 | + |
| 17 | +const COLORS = ['#00e87a','#4ade80','#facc15','#fb923c','#f472b6','#60a5fa','#a78bfa','#fff'] |
| 18 | + |
| 19 | +function makeParticle(canvasW: number): Particle { |
| 20 | + return { |
| 21 | + x: Math.random() * canvasW, |
| 22 | + y: -10 - Math.random() * 40, |
| 23 | + vx: (Math.random() - 0.5) * 3, |
| 24 | + vy: 2.5 + Math.random() * 3.5, |
| 25 | + color: COLORS[Math.floor(Math.random() * COLORS.length)], |
| 26 | + size: 5 + Math.random() * 8, |
| 27 | + rotation: Math.random() * Math.PI * 2, |
| 28 | + rotSpeed: (Math.random() - 0.5) * 0.2, |
| 29 | + shape: (['rect','rect','circle','ribbon'] as const)[Math.floor(Math.random() * 4)], |
| 30 | + alpha: 1, |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +function drawParticle(ctx: CanvasRenderingContext2D, p: Particle) { |
| 35 | + ctx.save() |
| 36 | + ctx.globalAlpha = p.alpha |
| 37 | + ctx.fillStyle = p.color |
| 38 | + ctx.translate(p.x, p.y) |
| 39 | + ctx.rotate(p.rotation) |
| 40 | + if (p.shape === 'circle') { |
| 41 | + ctx.beginPath(); ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2); ctx.fill() |
| 42 | + } else if (p.shape === 'ribbon') { |
| 43 | + ctx.fillRect(-p.size / 2, -p.size / 6, p.size, p.size / 3) |
| 44 | + } else { |
| 45 | + ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size) |
| 46 | + } |
| 47 | + ctx.restore() |
| 48 | +} |
| 49 | + |
| 50 | +/* ── Canvas component ── */ |
| 51 | +interface ConfettiCanvasProps { onDone: () => void; duration?: number } |
| 52 | + |
| 53 | +function ConfettiCanvas({ onDone, duration = 3000 }: ConfettiCanvasProps) { |
| 54 | + const canvasRef = useRef<HTMLCanvasElement>(null) |
| 55 | + const rafRef = useRef<number>(0) |
| 56 | + const startRef = useRef<number>(0) |
| 57 | + const particles = useRef<Particle[]>([]) |
| 58 | + |
| 59 | + useEffect(() => { |
| 60 | + const canvas = canvasRef.current |
| 61 | + if (!canvas) return |
| 62 | + const ctx = canvas.getContext('2d') |
| 63 | + if (!ctx) return |
| 64 | + |
| 65 | + const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight } |
| 66 | + resize() |
| 67 | + window.addEventListener('resize', resize) |
| 68 | + |
| 69 | + // Burst iniziale: 120 particelle |
| 70 | + for (let i = 0; i < 120; i++) particles.current.push(makeParticle(canvas.width)) |
| 71 | + |
| 72 | + const tick = (now: number) => { |
| 73 | + if (!startRef.current) startRef.current = now |
| 74 | + const elapsed = now - startRef.current |
| 75 | + const fadeStart = duration * 0.55 |
| 76 | + ctx.clearRect(0, 0, canvas.width, canvas.height) |
| 77 | + |
| 78 | + particles.current.forEach(p => { |
| 79 | + p.x += p.vx; p.y += p.vy |
| 80 | + p.vy += 0.08 // gravità |
| 81 | + p.vx *= 0.995 // attrito aria |
| 82 | + p.rotation += p.rotSpeed |
| 83 | + if (elapsed > fadeStart) p.alpha = Math.max(0, 1 - (elapsed - fadeStart) / (duration - fadeStart)) |
| 84 | + drawParticle(ctx, p) |
| 85 | + }) |
| 86 | + |
| 87 | + // Rimuovi particelle uscite dallo schermo |
| 88 | + particles.current = particles.current.filter(p => p.y < canvas.height + 20 && p.alpha > 0.01) |
| 89 | + |
| 90 | + if (elapsed < duration) { |
| 91 | + // Aggiungi nuove particelle nei primi 600ms |
| 92 | + if (elapsed < 600 && Math.random() < 0.6) particles.current.push(makeParticle(canvas.width)) |
| 93 | + rafRef.current = requestAnimationFrame(tick) |
| 94 | + } else { |
| 95 | + onDone() |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + rafRef.current = requestAnimationFrame(tick) |
| 100 | + return () => { |
| 101 | + cancelAnimationFrame(rafRef.current) |
| 102 | + window.removeEventListener('resize', resize) |
| 103 | + } |
| 104 | + }, [duration, onDone]) |
| 105 | + |
| 106 | + return ( |
| 107 | + <canvas |
| 108 | + ref={canvasRef} |
| 109 | + style={{ position: 'fixed', inset: 0, zIndex: 9999, pointerEvents: 'none' }} |
| 110 | + /> |
| 111 | + ) |
| 112 | +} |
| 113 | + |
| 114 | +/* ── Hook ── */ |
| 115 | +export function useConfetti() { |
| 116 | + const [active, setActive] = useState(false) |
| 117 | + const fire = useCallback(() => setActive(true), []) |
| 118 | + const dismiss = useCallback(() => setActive(false), []) |
| 119 | + return { fire, dismiss, active } |
| 120 | +} |
| 121 | + |
| 122 | +/* ── Componente esportabile ── */ |
| 123 | +export interface ConfettiAnimationProps { |
| 124 | + active: boolean |
| 125 | + onDone?: () => void |
| 126 | + duration?: number |
| 127 | +} |
| 128 | + |
| 129 | +export default function ConfettiAnimation({ active, onDone, duration = 3000 }: ConfettiAnimationProps) { |
| 130 | + const handleDone = useCallback(() => { onDone?.() }, [onDone]) |
| 131 | + if (!active) return null |
| 132 | + return <ConfettiCanvas onDone={handleDone} duration={duration} /> |
| 133 | +} |
0 commit comments