feat: 盲盒房间体系重构 — 强制登录、独立房间、用户归属
- 新增 BlindBoxRoom/BlindBoxMember 模型,BlindBoxIdea 增加 userId/drawnById - 新增房间 API(创建/加入/列表/详情),所有盲盒 API 增加认证和成员校验 - 新建盲盒大厅页面(三层引导式设计:未登录氛围页/首次创建引导/房间列表) - 新建盲盒房间页面(成员校验/邀请分享/用户归属展示/自动聚焦) - 首页删除契约画廊和 localStorage 盲盒逻辑,周末契约跳转到 /blindbox - 清理旧路由 /room/[id]/blindbox - 提取共享工具 src/lib/blindbox.ts(错误响应/房间号生成/成员校验) - AuthModal 支持 defaultTab 参数 - 更新项目规范:新项目原则、代码优雅和复用优先
This commit is contained in:
@@ -1,409 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence, useAnimation } from "framer-motion";
|
||||
import { ArrowLeft, Send, Loader2, Package, Flame, Trophy } from "lucide-react";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
interface DrawnIdea {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type Phase = "pool" | "shaking" | "reveal";
|
||||
|
||||
export default function BlindBoxPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const roomId = params.id;
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [poolCount, setPoolCount] = useState(0);
|
||||
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
|
||||
const [phase, setPhase] = useState<Phase>("pool");
|
||||
const [revealedIdea, setRevealedIdea] = useState<DrawnIdea | null>(null);
|
||||
const [submitFlash, setSubmitFlash] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const boxControls = useAnimation();
|
||||
const confettiCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox?roomId=${roomId}`);
|
||||
const data = await res.json();
|
||||
setPoolCount(data.poolCount ?? 0);
|
||||
setDrawnHistory(data.drawn ?? []);
|
||||
} catch {}
|
||||
}, [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/blindbox", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roomId, content: text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "提交失败");
|
||||
}
|
||||
setInput("");
|
||||
setPoolCount((c) => c + 1);
|
||||
setSubmitFlash(true);
|
||||
setTimeout(() => setSubmitFlash(false), 600);
|
||||
boxControls.start({
|
||||
scale: [1, 1.08, 1],
|
||||
rotate: [0, -3, 3, 0],
|
||||
transition: { duration: 0.5 },
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "提交失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDraw = async () => {
|
||||
if (poolCount === 0) {
|
||||
setError("盒子是空的,先往里面塞点想法吧!");
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase("shaking");
|
||||
setError("");
|
||||
|
||||
await boxControls.start({
|
||||
rotate: [0, -8, 8, -10, 10, -12, 12, -8, 8, -4, 4, 0],
|
||||
scale: [1, 1.05, 0.95, 1.08, 0.92, 1.1, 0.9, 1.05, 0.95, 1],
|
||||
transition: { duration: 2.5, ease: "easeInOut" },
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/draw", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roomId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "抽取失败");
|
||||
}
|
||||
|
||||
const idea = await res.json();
|
||||
setRevealedIdea(idea);
|
||||
setPhase("reveal");
|
||||
setPoolCount((c) => Math.max(0, c - 1));
|
||||
setDrawnHistory((prev) => [idea, ...prev]);
|
||||
|
||||
fireConfetti();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "抽取失败");
|
||||
setPhase("pool");
|
||||
}
|
||||
};
|
||||
|
||||
const fireConfetti = () => {
|
||||
const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"];
|
||||
|
||||
confetti({
|
||||
particleCount: 100,
|
||||
spread: 120,
|
||||
origin: { y: 0.4 },
|
||||
colors,
|
||||
startVelocity: 45,
|
||||
ticks: 250,
|
||||
});
|
||||
|
||||
const end = Date.now() + 3000;
|
||||
const frame = () => {
|
||||
if (Date.now() > end) return;
|
||||
confetti({
|
||||
particleCount: 3,
|
||||
angle: 60,
|
||||
spread: 55,
|
||||
origin: { x: 0, y: 0.6 },
|
||||
colors,
|
||||
startVelocity: 35,
|
||||
ticks: 150,
|
||||
});
|
||||
confetti({
|
||||
particleCount: 3,
|
||||
angle: 120,
|
||||
spread: 55,
|
||||
origin: { x: 1, y: 0.6 },
|
||||
colors,
|
||||
startVelocity: 35,
|
||||
ticks: 150,
|
||||
});
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
setTimeout(frame, 200);
|
||||
};
|
||||
|
||||
const resetToPool = () => {
|
||||
setPhase("pool");
|
||||
setRevealedIdea(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
|
||||
<canvas
|
||||
ref={confettiCanvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-50"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex w-full max-w-sm items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="flex h-8 items-center gap-1 rounded-full bg-surface px-3 text-xs font-medium text-muted ring-1 ring-border transition-colors active:bg-elevated"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
返回
|
||||
</button>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-bold text-purple-300/80">周末契约</p>
|
||||
<p className="text-[10px] text-dim">房间 {roomId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blind Box Visual */}
|
||||
<div className="mt-10 flex flex-col items-center">
|
||||
<motion.div
|
||||
animate={boxControls}
|
||||
className="relative flex h-36 w-36 items-center justify-center"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-3xl bg-linear-to-br from-purple-600/20 to-indigo-600/20 blur-xl" />
|
||||
|
||||
<div className="relative flex h-32 w-32 flex-col items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-700/30">
|
||||
<div className="absolute -top-2 left-1/2 h-5 w-[90%] -translate-x-1/2 rounded-t-xl bg-linear-to-r from-purple-700 to-indigo-700 shadow-md" />
|
||||
<div className="absolute top-3 h-full w-3 bg-linear-to-b from-amber-400/60 to-amber-400/10" />
|
||||
<div className="absolute top-6 h-3 w-full bg-linear-to-r from-amber-400/0 via-amber-400/60 to-amber-400/0" />
|
||||
|
||||
<motion.div
|
||||
animate={submitFlash ? { scale: [1, 1.3, 1] } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Package
|
||||
size={40}
|
||||
className="relative z-10 text-purple-300/60"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="absolute -right-2 -top-2 text-lg"
|
||||
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
|
||||
>
|
||||
✨
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="mt-4 text-sm font-semibold text-muted"
|
||||
key={poolCount}
|
||||
initial={{ scale: 1.2, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
>
|
||||
盒子里已有{" "}
|
||||
<span className="text-lg font-black text-purple-400">{poolCount}</span>{" "}
|
||||
个想法
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Pool Phase: Input + Draw */}
|
||||
<AnimatePresence mode="wait">
|
||||
{phase === "pool" && (
|
||||
<motion.div
|
||||
key="pool"
|
||||
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="丢入一个疯狂的周末想法..."
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
maxLength={200}
|
||||
disabled={submitting}
|
||||
className="h-12 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || submitting}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white transition-colors hover:bg-purple-500 disabled:opacity-30"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
onClick={handleDraw}
|
||||
disabled={poolCount === 0}
|
||||
className="relative flex h-14 w-full items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-base font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
|
||||
<Flame size={20} />
|
||||
开启周末盲盒(绝不反悔)
|
||||
</motion.button>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
className="text-center text-xs font-medium text-rose-400"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === "shaking" && (
|
||||
<motion.div
|
||||
key="shaking"
|
||||
className="mt-8 flex flex-col items-center gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<p className="text-sm font-bold text-purple-300 animate-pulse">
|
||||
命运正在决定...
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="h-2 w-2 rounded-full bg-purple-400"
|
||||
animate={{ y: [0, -8, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.15,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === "reveal" && revealedIdea && (
|
||||
<motion.div
|
||||
key="reveal"
|
||||
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", damping: 15, stiffness: 200 }}
|
||||
>
|
||||
<div className="relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-purple-900 via-indigo-900 to-purple-950 p-6 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-600/30">
|
||||
<div className="absolute left-3 top-3 h-6 w-6 border-l-2 border-t-2 border-purple-400/30 rounded-tl-sm" />
|
||||
<div className="absolute right-3 top-3 h-6 w-6 border-r-2 border-t-2 border-purple-400/30 rounded-tr-sm" />
|
||||
<div className="absolute bottom-3 left-3 h-6 w-6 border-b-2 border-l-2 border-purple-400/30 rounded-bl-sm" />
|
||||
<div className="absolute bottom-3 right-3 h-6 w-6 border-b-2 border-r-2 border-purple-400/30 rounded-br-sm" />
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<p className="text-xs font-bold tracking-[0.3em] text-purple-400/70">
|
||||
✦ 周末契约 ✦
|
||||
</p>
|
||||
<motion.p
|
||||
className="mt-4 text-xl font-black leading-relaxed text-white"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
{revealedIdea.content}
|
||||
</motion.p>
|
||||
<div className="mx-auto mt-4 h-px w-16 bg-linear-to-r from-transparent via-purple-400/50 to-transparent" />
|
||||
<p className="mt-3 text-[10px] font-medium text-purple-400/50">
|
||||
此契约一旦开启,绝不反悔
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
onClick={resetToPool}
|
||||
className="flex h-10 items-center gap-2 rounded-full bg-surface px-5 text-xs font-semibold text-muted ring-1 ring-border transition-colors hover:bg-elevated"
|
||||
whileTap={{ scale: 0.96 }}
|
||||
>
|
||||
继续投入想法
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* History */}
|
||||
{drawnHistory.length > 0 && phase !== "shaking" && (
|
||||
<motion.div
|
||||
className="mt-10 w-full max-w-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Trophy size={13} className="text-amber-400" />
|
||||
<h3 className="text-xs font-bold tracking-wider text-muted">
|
||||
履约记录
|
||||
</h3>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{drawnHistory.map((item, i) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 rounded-xl bg-surface/60 px-4 py-3 ring-1 ring-border/80"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.06 }}
|
||||
>
|
||||
<span className="mt-0.5 text-sm">🏆</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-300">
|
||||
{item.content}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-dim">
|
||||
{new Date(item.createdAt).toLocaleDateString("zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="h-8 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user