From 801e922bb68eec2f961849b81b4cadaa7893dc31 Mon Sep 17 00:00:00 2001 From: kurihada Date: Tue, 24 Feb 2026 22:03:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E5=91=98=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=97=B6=E5=A2=9E=E5=8A=A0=20confetti=20=E7=B2=92=E5=AD=90?= =?UTF-8?q?=E7=89=B9=E6=95=88=E3=80=81=E5=BA=86=E7=A5=9D=E9=9F=B3=E6=95=88?= =?UTF-8?q?=E5=92=8C=E9=BB=98=E5=A5=91=E5=BA=A6=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 19 +++++++ package.json | 2 + src/components/MatchResult.tsx | 47 ++++++++++++++-- src/components/QrInviteModal.tsx | 6 -- src/lib/celebrate.ts | 96 ++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 src/lib/celebrate.ts diff --git a/package-lock.json b/package-lock.json index 4c41f2f..19ad09e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@prisma/client": "^6.19.2", + "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", "lucide-react": "^0.575.0", "next": "16.1.6", @@ -20,6 +21,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1616,6 +1618,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2706,6 +2715,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/package.json b/package.json index 025c530..482f7c3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@prisma/client": "^6.19.2", + "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", "lucide-react": "^0.575.0", "next": "16.1.6", @@ -21,6 +22,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index 0174638..3c837e1 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { useRouter } from "next/navigation"; import { @@ -18,8 +18,10 @@ import { Swords, RefreshCw, Share2, + Zap, } from "lucide-react"; import { Restaurant, MatchType, RunnerUp } from "@/types"; +import { fireCelebration, playChime } from "@/lib/celebrate"; interface MatchResultProps { restaurant: Restaurant; @@ -173,20 +175,37 @@ export default function MatchResult({ const router = useRouter(); const [showRunnerUps, setShowRunnerUps] = useState(false); const [toast, setToast] = useState(""); + const celebratedRef = useRef(false); + const isUnanimous = matchType === "unanimous"; const showToast = useCallback((msg: string) => { setToast(msg); setTimeout(() => setToast(""), 2200); }, []); + useEffect(() => { + if (isUnanimous && !celebratedRef.current) { + const timer = setTimeout(() => { + celebratedRef.current = true; + fireCelebration(); + playChime(); + }, 500); + return () => clearTimeout(timer); + } + }, [isUnanimous]); + const handleShare = useCallback(async () => { const lines = [ - `🎉 我们用 NoWhatever 选好了!`, + isUnanimous + ? `🎉 默契度 100%!${userCount} 人全员一致选了同一家!` + : `🎉 我们用 NoWhatever 选好了!`, ``, `📍 ${restaurant.name}`, restaurant.rating ? `⭐ ${restaurant.rating}` : "", restaurant.price && restaurant.price !== "未知" ? `💰 人均${restaurant.price}` : "", restaurant.address ? `📮 ${restaurant.address}` : "", + ``, + isUnanimous ? `✨ 这就是心有灵犀吧~` : "", ].filter(Boolean); const text = lines.join("\n"); @@ -213,14 +232,12 @@ export default function MatchResult({ } catch { showToast("复制失败,请手动复制"); } - }, [restaurant, showToast]); + }, [restaurant, showToast, isUnanimous, userCount]); if (matchType === "no_match") { return ; } - const isUnanimous = matchType === "unanimous"; - const runnerUpRestaurants = runnerUps .map((ru) => { const r = allRestaurants.find((rest) => rest.id === ru.id); @@ -277,6 +294,26 @@ export default function MatchResult({ : `${matchLikes}/${userCount} 人想去这家`} + {isUnanimous && ( + + + + 默契度 100% · {userCount} 人全员一致 + + + + )} + diff --git a/src/lib/celebrate.ts b/src/lib/celebrate.ts new file mode 100644 index 0000000..decd438 --- /dev/null +++ b/src/lib/celebrate.ts @@ -0,0 +1,96 @@ +import confetti from "canvas-confetti"; + +type ConfettiFn = confetti.CreateTypes; + +let _cannon: ConfettiFn | null = null; + +function getCannon(): ConfettiFn { + if (_cannon) return _cannon; + + const canvas = document.createElement("canvas"); + canvas.style.cssText = + "position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647;pointer-events:none"; + document.body.appendChild(canvas); + + canvas.width = canvas.offsetWidth * (window.devicePixelRatio || 1); + canvas.height = canvas.offsetHeight * (window.devicePixelRatio || 1); + + _cannon = confetti.create(canvas, { resize: true }); + return _cannon; +} + +export function fireCelebration() { + const fire = getCannon(); + const colors = ["#10b981", "#f59e0b", "#ef4444", "#6366f1", "#ec4899"]; + + fire({ + particleCount: 80, + spread: 100, + origin: { y: 0.35 }, + colors, + startVelocity: 45, + ticks: 200, + }); + + const end = Date.now() + 2500; + + const frame = () => { + if (Date.now() > end) return; + + fire({ + particleCount: 3, + angle: 60, + spread: 55, + origin: { x: 0, y: 0.6 }, + colors, + startVelocity: 35, + ticks: 150, + }); + fire({ + particleCount: 3, + angle: 120, + spread: 55, + origin: { x: 1, y: 0.6 }, + colors, + startVelocity: 35, + ticks: 150, + }); + + requestAnimationFrame(frame); + }; + + setTimeout(frame, 300); +} + +export function playChime() { + try { + const ctx = new AudioContext(); + const gain = ctx.createGain(); + gain.connect(ctx.destination); + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.2); + + const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6 + notes.forEach((freq, i) => { + const osc = ctx.createOscillator(); + osc.type = "sine"; + osc.frequency.value = freq; + + const noteGain = ctx.createGain(); + noteGain.connect(gain); + + const start = ctx.currentTime + i * 0.12; + noteGain.gain.setValueAtTime(0, start); + noteGain.gain.linearRampToValueAtTime(0.3, start + 0.03); + noteGain.gain.exponentialRampToValueAtTime(0.001, start + 0.6); + + osc.connect(noteGain); + osc.start(start); + osc.stop(start + 0.6); + }); + + setTimeout(() => ctx.close(), 2000); + } catch { + // Audio not available — silent fallback + } +}