From def787e6d4445b2cdc8879af3fb25f5f6f95a4d0 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Sun, 15 Feb 2026 20:25:23 +0100 Subject: [PATCH] feat: add confetti fireworks effect on tour completion --- .../features/tour/components/guided-tour.tsx | 2 + .../features/tour/lib/confetti-fireworks.ts | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 studio/frontend/src/features/tour/lib/confetti-fireworks.ts diff --git a/studio/frontend/src/features/tour/components/guided-tour.tsx b/studio/frontend/src/features/tour/components/guided-tour.tsx index 8e1bcd2a6b..e818d06e51 100644 --- a/studio/frontend/src/features/tour/components/guided-tour.tsx +++ b/studio/frontend/src/features/tour/components/guided-tour.tsx @@ -18,6 +18,7 @@ import { useState, } from "react"; import { cssEscape, toRect } from "../lib/dom"; +import { fireConfettiFireworks } from "../lib/confetti-fireworks"; import { computeCardPos, padded, pickPlacement } from "../lib/layout"; import type { Placement, Rect, TourStep } from "../types"; @@ -230,6 +231,7 @@ export function GuidedTour({ function requestClose(reason: "skip" | "complete") { if (closeLockRef.current) return; closeLockRef.current = true; + void fireConfettiFireworks(); if (reason === "skip") onSkip(); else onComplete(); onOpenChange(false); diff --git a/studio/frontend/src/features/tour/lib/confetti-fireworks.ts b/studio/frontend/src/features/tour/lib/confetti-fireworks.ts new file mode 100644 index 0000000000..b6366d8c5b --- /dev/null +++ b/studio/frontend/src/features/tour/lib/confetti-fireworks.ts @@ -0,0 +1,59 @@ +"use client"; + +type FireworksOpts = { + durationMs?: number; + intervalMs?: number; + zIndex?: number; +}; + +export async function fireConfettiFireworks(opts: FireworksOpts = {}) { + try { + if (typeof window === "undefined") return; + + const prefersReduce = + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; + if (prefersReduce) return; + + const confetti = (await import("canvas-confetti")).default; + + const duration = opts.durationMs ?? 1200; + const intervalMs = opts.intervalMs ?? 240; + const animationEnd = Date.now() + duration; + const defaults = { + startVelocity: 28, + spread: 360, + ticks: 58, + zIndex: opts.zIndex ?? 99999, + disableForReducedMotion: true, + } as const; + + const randomInRange = (min: number, max: number) => + Math.random() * (max - min) + min; + + const interval = window.setInterval(() => { + const timeLeft = animationEnd - Date.now(); + if (timeLeft <= 0) { + window.clearInterval(interval); + return; + } + + const particleCount = Math.max( + 10, + Math.floor(36 * (timeLeft / duration)), + ); + + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.12, 0.3), y: Math.random() - 0.2 }, + }); + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.7, 0.88), y: Math.random() - 0.2 }, + }); + }, intervalMs); + } catch { + // best-effort + } +}