Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run
Replace hero pan animations with gentle opacity breathing, add bottom-right consent modal with accept/decline, condense privacy policy with contact for full details, and ensure ticket card descriptions read green on white cards in the tickets section. Co-authored-by: Cursor <cursoragent@cursor.com>
116 lines
3.1 KiB
TypeScript
116 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
const MAX_PARTICLES = 40;
|
|
|
|
type Particle = {
|
|
x: number;
|
|
y: number;
|
|
vy: number;
|
|
vx: number;
|
|
size: number;
|
|
alpha: number;
|
|
glow: number;
|
|
};
|
|
|
|
type Props = {
|
|
active: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
export function HeroRiftParticles({ active, className }: Props) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
let raf = 0;
|
|
let particles: Particle[] = [];
|
|
|
|
const resize = () => {
|
|
const parent = canvas.parentElement;
|
|
if (!parent) return;
|
|
const dpr = Math.min(window.devicePixelRatio ?? 1, 2);
|
|
const w = parent.clientWidth;
|
|
const h = parent.clientHeight;
|
|
canvas.width = w * dpr;
|
|
canvas.height = h * dpr;
|
|
canvas.style.width = `${w}px`;
|
|
canvas.style.height = `${h}px`;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
const count = Math.min(MAX_PARTICLES, Math.floor((w * h) / 8500));
|
|
particles = Array.from({ length: count }, () => {
|
|
const large = Math.random() < 0.18;
|
|
return {
|
|
x: w * (0.28 + Math.random() * 0.44),
|
|
y: h * (0.38 + Math.random() * 0.42),
|
|
vy: -0.12 - Math.random() * (large ? 0.22 : 0.14),
|
|
vx: (Math.random() - 0.5) * 0.04,
|
|
size: large ? 2.2 + Math.random() * 3.2 : 1.2 + Math.random() * 2.4,
|
|
alpha: 0.35 + Math.random() * 0.45,
|
|
glow: large ? 14 + Math.random() * 10 : 8 + Math.random() * 6,
|
|
};
|
|
});
|
|
};
|
|
|
|
const tick = () => {
|
|
const w = canvas.clientWidth;
|
|
const h = canvas.clientHeight;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
for (const p of particles) {
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
|
|
if (p.y < h * 0.18) {
|
|
p.y = h * (0.52 + Math.random() * 0.35);
|
|
p.x = w * (0.28 + Math.random() * 0.44);
|
|
}
|
|
if (p.x < w * 0.2) p.vx += 0.008;
|
|
if (p.x > w * 0.8) p.vx -= 0.008;
|
|
|
|
ctx.save();
|
|
ctx.shadowBlur = p.glow;
|
|
ctx.shadowColor = "rgba(255, 200, 90, 0.85)";
|
|
ctx.beginPath();
|
|
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 1.8);
|
|
g.addColorStop(0, `rgba(255, 220, 140, ${p.alpha})`);
|
|
g.addColorStop(0.45, `rgba(255, 191, 80, ${p.alpha * 0.75})`);
|
|
g.addColorStop(1, `rgba(45, 122, 82, 0)`);
|
|
ctx.fillStyle = g;
|
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.shadowBlur = 0;
|
|
ctx.strokeStyle = `rgba(255, 235, 180, ${p.alpha * 0.55})`;
|
|
ctx.lineWidth = 0.6;
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
|
|
resize();
|
|
window.addEventListener("resize", resize);
|
|
raf = requestAnimationFrame(tick);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
window.removeEventListener("resize", resize);
|
|
};
|
|
}, [active]);
|
|
|
|
if (!active) return null;
|
|
|
|
return <canvas ref={canvasRef} className={className} aria-hidden />;
|
|
}
|