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,39 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, validateMembership } from "@/lib/blindbox";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { roomId } = await req.json();
|
||||
const { roomId, userId } = await req.json();
|
||||
|
||||
if (!userId || typeof userId !== "string") {
|
||||
return errorResponse("请先登录", 401);
|
||||
}
|
||||
if (!roomId || typeof roomId !== "string") {
|
||||
return NextResponse.json({ error: "roomId 不能为空" }, { status: 400 });
|
||||
return errorResponse("roomId 不能为空", 400);
|
||||
}
|
||||
|
||||
const isMember = await validateMembership(roomId, userId);
|
||||
if (!isMember) {
|
||||
return errorResponse("你不是这个房间的成员", 403);
|
||||
}
|
||||
|
||||
const pool = await prisma.blindBoxIdea.findMany({
|
||||
where: { roomId: roomId.trim(), status: "in_pool" },
|
||||
where: { roomId, status: "in_pool" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (pool.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "盒子是空的,先往里面塞点想法吧!" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return errorResponse("盒子是空的,先往里面塞点想法吧!", 404);
|
||||
}
|
||||
|
||||
const picked = pool[Math.floor(Math.random() * pool.length)];
|
||||
|
||||
const idea = await prisma.blindBoxIdea.update({
|
||||
where: { id: picked.id },
|
||||
data: { status: "drawn" },
|
||||
data: { status: "drawn", drawnById: userId },
|
||||
include: {
|
||||
user: { select: { id: true, username: true, avatar: true } },
|
||||
drawnBy: { select: { id: true, username: true, avatar: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: idea.id,
|
||||
content: idea.content,
|
||||
createdAt: idea.createdAt,
|
||||
submitter: idea.user,
|
||||
drawnBy: idea.drawnBy,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "抽取失败" }, { status: 500 });
|
||||
return errorResponse("抽取失败", 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { errorResponse, getRoomByCode } from "@/lib/blindbox";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ code: string }> },
|
||||
) {
|
||||
try {
|
||||
const { code } = await params;
|
||||
const room = await getRoomByCode(code.toUpperCase());
|
||||
|
||||
if (!room) {
|
||||
return errorResponse("房间不存在", 404);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: room.id,
|
||||
code: room.code,
|
||||
name: room.name,
|
||||
creatorId: room.creatorId,
|
||||
poolCount: room._count.ideas,
|
||||
members: room.members.map((m) => ({
|
||||
...m.user,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
return errorResponse("获取房间信息失败", 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse } from "@/lib/blindbox";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { userId, code } = await req.json();
|
||||
|
||||
if (!userId || typeof userId !== "string") {
|
||||
return errorResponse("请先登录", 401);
|
||||
}
|
||||
if (!code || typeof code !== "string") {
|
||||
return errorResponse("请输入房间号", 400);
|
||||
}
|
||||
|
||||
const room = await prisma.blindBoxRoom.findUnique({
|
||||
where: { code: code.trim().toUpperCase() },
|
||||
});
|
||||
if (!room) {
|
||||
return errorResponse("房间不存在,请检查房间号", 404);
|
||||
}
|
||||
|
||||
const existing = await prisma.blindBoxMember.findUnique({
|
||||
where: { roomId_userId: { roomId: room.id, userId } },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json({ id: room.id, code: room.code, name: room.name, alreadyMember: true });
|
||||
}
|
||||
|
||||
await prisma.blindBoxMember.create({
|
||||
data: { roomId: room.id, userId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: room.id, code: room.code, name: room.name }, { status: 201 });
|
||||
} catch {
|
||||
return errorResponse("加入房间失败", 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, generateUniqueRoomCode } from "@/lib/blindbox";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { userId, name } = await req.json();
|
||||
|
||||
if (!userId || typeof userId !== "string") {
|
||||
return errorResponse("请先登录", 401);
|
||||
}
|
||||
|
||||
const roomName = (name || "").trim() || "我们的周末";
|
||||
if (roomName.length > 30) {
|
||||
return errorResponse("房间名不能超过 30 个字", 400);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
return errorResponse("用户不存在", 404);
|
||||
}
|
||||
|
||||
const code = await generateUniqueRoomCode();
|
||||
|
||||
const room = await prisma.blindBoxRoom.create({
|
||||
data: {
|
||||
code,
|
||||
name: roomName,
|
||||
creatorId: userId,
|
||||
members: {
|
||||
create: { userId },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: room.id, code: room.code, name: room.name }, { status: 201 });
|
||||
} catch {
|
||||
return errorResponse("创建房间失败", 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse } from "@/lib/blindbox";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const userId = req.nextUrl.searchParams.get("userId");
|
||||
|
||||
if (!userId) {
|
||||
return errorResponse("请先登录", 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const memberships = await prisma.blindBoxMember.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
room: {
|
||||
include: {
|
||||
members: {
|
||||
include: { user: { select: { id: true, username: true, avatar: true } } },
|
||||
orderBy: { joinedAt: "asc" },
|
||||
take: 5,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
ideas: true,
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
ideas: {
|
||||
where: { status: "drawn" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: { content: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: "desc" },
|
||||
});
|
||||
|
||||
const rooms = memberships.map((m) => ({
|
||||
id: m.room.id,
|
||||
code: m.room.code,
|
||||
name: m.room.name,
|
||||
memberCount: m.room._count.members,
|
||||
ideaCount: m.room._count.ideas,
|
||||
poolCount: 0,
|
||||
members: m.room.members.map((mb) => mb.user),
|
||||
lastDrawn: m.room.ideas[0] ?? null,
|
||||
joinedAt: m.joinedAt,
|
||||
}));
|
||||
|
||||
const roomIds = rooms.map((r) => r.id);
|
||||
const poolCounts = await prisma.blindBoxIdea.groupBy({
|
||||
by: ["roomId"],
|
||||
where: { roomId: { in: roomIds }, status: "in_pool" },
|
||||
_count: true,
|
||||
});
|
||||
const poolMap = new Map(poolCounts.map((p) => [p.roomId, p._count]));
|
||||
|
||||
for (const room of rooms) {
|
||||
room.poolCount = poolMap.get(room.id) ?? 0;
|
||||
}
|
||||
|
||||
return NextResponse.json({ rooms });
|
||||
} catch {
|
||||
return errorResponse("获取房间列表失败", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, validateMembership } from "@/lib/blindbox";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { roomId, content } = await req.json();
|
||||
const { roomId, userId, content } = await req.json();
|
||||
|
||||
if (!userId || typeof userId !== "string") {
|
||||
return errorResponse("请先登录", 401);
|
||||
}
|
||||
if (!roomId || typeof roomId !== "string") {
|
||||
return NextResponse.json({ error: "roomId 不能为空" }, { status: 400 });
|
||||
return errorResponse("roomId 不能为空", 400);
|
||||
}
|
||||
if (!content || typeof content !== "string" || content.trim().length === 0) {
|
||||
return NextResponse.json({ error: "内容不能为空" }, { status: 400 });
|
||||
return errorResponse("内容不能为空", 400);
|
||||
}
|
||||
if (content.trim().length > 200) {
|
||||
return NextResponse.json({ error: "内容不能超过 200 字" }, { status: 400 });
|
||||
return errorResponse("内容不能超过 200 字", 400);
|
||||
}
|
||||
|
||||
const isMember = await validateMembership(roomId, userId);
|
||||
if (!isMember) {
|
||||
return errorResponse("你不是这个房间的成员", 403);
|
||||
}
|
||||
|
||||
const idea = await prisma.blindBoxIdea.create({
|
||||
data: {
|
||||
roomId: roomId.trim(),
|
||||
roomId,
|
||||
userId,
|
||||
content: content.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: idea.id }, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "提交失败" }, { status: 500 });
|
||||
return errorResponse("提交失败", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const roomId = req.nextUrl.searchParams.get("roomId");
|
||||
const userId = req.nextUrl.searchParams.get("userId");
|
||||
|
||||
if (!roomId) {
|
||||
return NextResponse.json({ error: "缺少 roomId" }, { status: 400 });
|
||||
return errorResponse("缺少 roomId", 400);
|
||||
}
|
||||
if (!userId) {
|
||||
return errorResponse("请先登录", 401);
|
||||
}
|
||||
|
||||
const [poolCount, drawn] = await Promise.all([
|
||||
prisma.blindBoxIdea.count({
|
||||
where: { roomId, status: "in_pool" },
|
||||
}),
|
||||
prisma.blindBoxIdea.findMany({
|
||||
where: { roomId, status: "drawn" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, content: true, createdAt: true },
|
||||
}),
|
||||
]);
|
||||
const isMember = await validateMembership(roomId, userId);
|
||||
if (!isMember) {
|
||||
return errorResponse("你不是这个房间的成员", 403);
|
||||
}
|
||||
|
||||
return NextResponse.json({ poolCount, drawn });
|
||||
try {
|
||||
const [poolCount, drawn] = await Promise.all([
|
||||
prisma.blindBoxIdea.count({
|
||||
where: { roomId, status: "in_pool" },
|
||||
}),
|
||||
prisma.blindBoxIdea.findMany({
|
||||
where: { roomId, status: "drawn" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
user: { select: { id: true, username: true, avatar: true } },
|
||||
drawnBy: { select: { id: true, username: true, avatar: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ poolCount, drawn });
|
||||
} catch {
|
||||
return errorResponse("查询失败", 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
"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,
|
||||
Users,
|
||||
Share2,
|
||||
LogIn,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import confetti from "canvas-confetti";
|
||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
interface RoomInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
creatorId: string;
|
||||
poolCount: number;
|
||||
members: { id: string; username: string; avatar: string }[];
|
||||
}
|
||||
|
||||
interface DrawnIdea {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
user?: { id: string; username: string; avatar: string };
|
||||
drawnBy?: { id: string; username: string; avatar: string } | null;
|
||||
}
|
||||
|
||||
type Phase = "pool" | "shaking" | "reveal";
|
||||
|
||||
export default function BlindboxRoomPage() {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const router = useRouter();
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [room, setRoom] = useState<RoomInfo | null>(null);
|
||||
const [isMember, setIsMember] = useState(false);
|
||||
const [joiningRoom, setJoiningRoom] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
|
||||
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 [showInvite, setShowInvite] = useState(false);
|
||||
const [toast, setToast] = useState("");
|
||||
|
||||
const boxControls = useAnimation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const confettiCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRegistered()) {
|
||||
router.replace("/blindbox");
|
||||
return;
|
||||
}
|
||||
setProfile(getCachedProfile());
|
||||
}, [router]);
|
||||
|
||||
const fetchRoom = useCallback(async () => {
|
||||
if (!code) return;
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/room/${code}`);
|
||||
if (!res.ok) {
|
||||
router.replace("/blindbox");
|
||||
return;
|
||||
}
|
||||
const data: RoomInfo = await res.json();
|
||||
setRoom(data);
|
||||
|
||||
const p = getCachedProfile();
|
||||
const memberCheck = data.members.some((m) => m.id === p?.id);
|
||||
setIsMember(memberCheck);
|
||||
setPoolCount(data.poolCount);
|
||||
} catch {
|
||||
router.replace("/blindbox");
|
||||
} finally {
|
||||
setPageLoading(false);
|
||||
}
|
||||
}, [code, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoom();
|
||||
}, [fetchRoom]);
|
||||
|
||||
const fetchIdeas = useCallback(async () => {
|
||||
const p = getCachedProfile();
|
||||
if (!room || !p) return;
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${p.id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPoolCount(data.poolCount ?? 0);
|
||||
setDrawnHistory(data.drawn ?? []);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [room]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMember && room) fetchIdeas();
|
||||
}, [isMember, room, fetchIdeas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMember && inputRef.current) {
|
||||
setTimeout(() => inputRef.current?.focus(), 300);
|
||||
}
|
||||
}, [isMember]);
|
||||
|
||||
const handleJoinRoom = async () => {
|
||||
if (joiningRoom || !profile || !room) return;
|
||||
setJoiningRoom(true);
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/room/join", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, code }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setIsMember(true);
|
||||
fetchRoom();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setJoiningRoom(false); }
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || submitting || !profile || !room) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/blindbox", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roomId: room.id, userId: profile.id, 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 || !profile || !room) {
|
||||
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: room.id, userId: profile.id }),
|
||||
});
|
||||
|
||||
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 handleCopyCode = async () => {
|
||||
if (!room) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(room.code);
|
||||
setToast("房间号已复制");
|
||||
setTimeout(() => setToast(""), 2000);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!room) return;
|
||||
const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : "";
|
||||
const shareData = {
|
||||
title: `周末契约 · ${room.name}`,
|
||||
text: `来和我一起玩周末盲盒吧!房间号:${room.code}`,
|
||||
url,
|
||||
};
|
||||
try {
|
||||
if (navigator.share && navigator.canShare?.(shareData)) {
|
||||
await navigator.share(shareData);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === "AbortError") return;
|
||||
}
|
||||
handleCopyCode();
|
||||
};
|
||||
|
||||
if (pageLoading) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background">
|
||||
<Loader2 size={24} className="animate-spin text-purple-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!room) return 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 gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/blindbox")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
|
||||
>
|
||||
<ArrowLeft size={16} className="text-muted" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-white">{room.name}</p>
|
||||
<p className="text-[10px] text-dim">房间 {room.code}</p>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div className="flex -space-x-1.5">
|
||||
{room.members.slice(0, 4).map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-xs ring-2 ring-background"
|
||||
title={m.username}
|
||||
>
|
||||
{m.avatar}
|
||||
</div>
|
||||
))}
|
||||
{room.members.length > 4 && (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-[10px] font-bold text-muted ring-2 ring-background">
|
||||
+{room.members.length - 4}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowInvite(!showInvite)}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-600/15 text-purple-400 ring-1 ring-purple-500/20 transition-colors active:bg-purple-600/25"
|
||||
>
|
||||
<Share2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Invite panel */}
|
||||
<AnimatePresence>
|
||||
{showInvite && (
|
||||
<motion.div
|
||||
className="mt-3 w-full max-w-sm overflow-hidden rounded-xl bg-surface ring-1 ring-border"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<span className="text-xs text-muted">房间号</span>
|
||||
<span className="font-mono text-base font-bold tracking-[0.2em] text-purple-400">
|
||||
{room.code}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={handleCopyCode}
|
||||
className="flex h-8 items-center gap-1 rounded-lg bg-elevated px-3 text-xs font-medium text-gray-300 ring-1 ring-border transition-colors active:bg-subtle"
|
||||
>
|
||||
<Copy size={12} /> 复制
|
||||
</button>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex h-8 items-center gap-1 rounded-lg bg-purple-600 px-3 text-xs font-medium text-white transition-colors active:bg-purple-500"
|
||||
>
|
||||
<Share2 size={12} /> 分享
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Non-member state */}
|
||||
{!isMember ? (
|
||||
<motion.div
|
||||
className="mt-16 flex flex-col items-center gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Package size={40} className="text-purple-400/50" strokeWidth={1.5} />
|
||||
<p className="text-sm text-gray-400">你还不是这个房间的成员</p>
|
||||
<button
|
||||
onClick={handleJoinRoom}
|
||||
disabled={joiningRoom}
|
||||
className="flex h-11 items-center gap-2 rounded-xl bg-purple-600 px-6 text-sm font-bold text-white transition-colors hover:bg-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{joiningRoom ? <Loader2 size={16} className="animate-spin" /> : <LogIn size={16} />}
|
||||
加入房间
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
{/* Blind Box Visual */}
|
||||
<div className="mt-8 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 / Shaking / Reveal phases */}
|
||||
<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
|
||||
ref={inputRef}
|
||||
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" />
|
||||
|
||||
{/* Attribution */}
|
||||
<div className="mt-3 flex items-center justify-center gap-2 text-[11px] text-purple-400/50">
|
||||
{revealedIdea.user && (
|
||||
<span>
|
||||
{revealedIdea.user.avatar} {revealedIdea.user.username} 投入
|
||||
</span>
|
||||
)}
|
||||
{revealedIdea.drawnBy && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{revealedIdea.drawnBy.avatar} {revealedIdea.drawnBy.username} 抽中
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[10px] font-medium text-purple-400/40">
|
||||
此契约一旦开启,绝不反悔
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
onClick={() => { setPhase("pool"); setRevealedIdea(null); }}
|
||||
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>
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-dim">
|
||||
{item.user && (
|
||||
<span>{item.user.avatar} {item.user.username} 投入</span>
|
||||
)}
|
||||
{item.drawnBy && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{item.drawnBy.avatar} {item.drawnBy.username} 抽中</span>
|
||||
</>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>
|
||||
{new Date(item.createdAt).toLocaleDateString("zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
className="fixed bottom-8 left-1/2 -translate-x-1/2 rounded-full bg-surface px-4 py-2 text-xs font-semibold text-gray-300 shadow-xl ring-1 ring-border"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
>
|
||||
{toast}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="h-8 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Package,
|
||||
Plus,
|
||||
LogIn,
|
||||
Users,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||
import AuthModal from "@/components/AuthModal";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
interface RoomSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
poolCount: number;
|
||||
members: { id: string; username: string; avatar: string }[];
|
||||
lastDrawn: { content: string; createdAt: string } | null;
|
||||
}
|
||||
|
||||
export default function BlindboxLobbyPage() {
|
||||
const router = useRouter();
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [showAuth, setShowAuth] = useState(false);
|
||||
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [joinCode, setJoinCode] = useState("");
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const registered = isRegistered();
|
||||
setLoggedIn(registered);
|
||||
if (registered) {
|
||||
setProfile(getCachedProfile());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRooms = useCallback(async () => {
|
||||
const p = getCachedProfile();
|
||||
if (!p) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`);
|
||||
const data = await res.json();
|
||||
setRooms(data.rooms ?? []);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn) fetchRooms();
|
||||
else setLoading(false);
|
||||
}, [loggedIn, fetchRooms]);
|
||||
|
||||
const handleAuth = (p: UserProfile) => {
|
||||
setProfile(p);
|
||||
setLoggedIn(true);
|
||||
setShowAuth(false);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (creating || !profile) return;
|
||||
setCreating(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/room", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, name: createName.trim() || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
router.push(`/blindbox/${data.code}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "创建失败");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (joining || !profile || !joinCode.trim()) return;
|
||||
setJoining(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/room/join", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, code: joinCode.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
router.push(`/blindbox/${data.code}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "加入失败");
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
|
||||
{/* Ambient */}
|
||||
<div className="pointer-events-none fixed left-1/3 top-0 h-80 w-80 -translate-y-1/3 rounded-full bg-purple-600/8 blur-3xl" />
|
||||
<div className="pointer-events-none fixed right-0 top-1/2 h-60 w-60 rounded-full bg-indigo-500/5 blur-3xl" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex w-full max-w-sm items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
|
||||
>
|
||||
<ArrowLeft size={16} className="text-muted" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-black text-white">🎁 周末契约</h1>
|
||||
<p className="text-[10px] font-medium tracking-wider text-purple-400/60">
|
||||
ADVENTURE ROULETTE
|
||||
</p>
|
||||
</div>
|
||||
{profile && (
|
||||
<div className="flex items-center gap-2 rounded-full bg-surface px-3 py-1.5 ring-1 ring-border">
|
||||
<span className="text-sm">{profile.avatar}</span>
|
||||
<span className="text-xs font-semibold text-gray-300">{profile.username}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{!loggedIn ? (
|
||||
/* ============ Layer 1: Unauthenticated — Feature intro ============ */
|
||||
<motion.div
|
||||
key="intro"
|
||||
className="mt-10 flex flex-1 flex-col items-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
{/* Hero icon */}
|
||||
<motion.div
|
||||
className="relative flex h-28 w-28 items-center justify-center"
|
||||
animate={{ y: [0, -6, 0] }}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-3xl bg-purple-600/20 blur-xl" />
|
||||
<div className="relative flex h-24 w-24 items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl ring-1 ring-purple-700/30">
|
||||
<Package size={36} className="text-purple-300/70" strokeWidth={1.5} />
|
||||
</div>
|
||||
<motion.span
|
||||
className="absolute -right-1 -top-1 text-lg"
|
||||
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
|
||||
>
|
||||
✨
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
|
||||
<h2 className="mt-6 text-xl font-black text-white">
|
||||
和 TA 一起,拆开周末
|
||||
</h2>
|
||||
<p className="mt-2 max-w-72 text-center text-sm leading-relaxed text-gray-400">
|
||||
平日蓄水,周末开奖。把所有"想做但一直没做"的事,
|
||||
交给命运来决定。
|
||||
</p>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="mt-8 flex w-full max-w-xs flex-col gap-4">
|
||||
{[
|
||||
{ step: "1", icon: Plus, text: "创建专属房间,邀请 TA 加入" },
|
||||
{ step: "2", icon: Package, text: "平时随时塞入疯狂想法" },
|
||||
{ step: "3", icon: Sparkles, text: "周末一起盲抽,绝不反悔" },
|
||||
].map((s) => (
|
||||
<div key={s.step} className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-purple-600/15 text-sm font-black text-purple-400 ring-1 ring-purple-500/20">
|
||||
{s.step}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-300">{s.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<motion.button
|
||||
onClick={() => setShowAuth(true)}
|
||||
className="mt-10 flex h-12 w-full max-w-xs items-center justify-center gap-2 rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-bold text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<LogIn size={18} />
|
||||
登录 / 注册,开始你的第一个盲盒
|
||||
</motion.button>
|
||||
|
||||
<p className="mt-3 text-[11px] text-dim">
|
||||
仅需用户名 + 密码,10 秒注册
|
||||
</p>
|
||||
</motion.div>
|
||||
) : loading ? (
|
||||
/* ============ Loading ============ */
|
||||
<motion.div
|
||||
key="loading"
|
||||
className="mt-20 flex flex-col items-center gap-3"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<Loader2 size={24} className="animate-spin text-purple-400" />
|
||||
<p className="text-xs text-muted">加载中...</p>
|
||||
</motion.div>
|
||||
) : rooms.length === 0 ? (
|
||||
/* ============ Layer 2: Logged in, no rooms — Create first ============ */
|
||||
<motion.div
|
||||
key="empty"
|
||||
className="mt-10 flex flex-1 flex-col items-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative flex h-20 w-20 items-center justify-center"
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-2xl bg-purple-600/15 blur-lg" />
|
||||
<Package size={32} className="relative text-purple-400/60" strokeWidth={1.5} />
|
||||
</motion.div>
|
||||
|
||||
<h2 className="mt-5 text-lg font-bold text-white">还没有盲盒房间</h2>
|
||||
<p className="mt-1.5 text-sm text-gray-400">
|
||||
创建第一个房间,邀请 TA 一起玩
|
||||
</p>
|
||||
|
||||
{/* Inline create form */}
|
||||
<div className="mt-7 w-full max-w-xs">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="我们的周末"
|
||||
value={createName}
|
||||
onChange={(e) => {
|
||||
setCreateName(e.target.value.slice(0, 30));
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
|
||||
maxLength={30}
|
||||
className="h-11 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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="flex h-11 items-center gap-1.5 rounded-xl bg-purple-600 px-4 text-sm font-bold text-white transition-colors hover:bg-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{creating ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />}
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Join alternative */}
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-[10px] font-medium text-dim">或输入房间号加入</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="6 位房间号"
|
||||
value={joinCode}
|
||||
onChange={(e) => {
|
||||
setJoinCode(e.target.value.toUpperCase().slice(0, 6));
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }}
|
||||
maxLength={6}
|
||||
className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={joining || joinCode.trim().length < 6}
|
||||
className="flex h-11 items-center gap-1.5 rounded-xl bg-surface px-4 text-sm font-semibold text-gray-300 ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-40"
|
||||
>
|
||||
{joining ? <Loader2 size={16} className="animate-spin" /> : <LogIn size={16} />}
|
||||
加入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
className="mt-3 text-center text-xs font-medium text-rose-400"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
/* ============ Layer 3: Logged in, has rooms — Room list ============ */
|
||||
<motion.div
|
||||
key="rooms"
|
||||
className="mt-6 flex w-full max-w-sm flex-1 flex-col"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
{/* Create row */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="新房间名称"
|
||||
value={createName}
|
||||
onChange={(e) => {
|
||||
setCreateName(e.target.value.slice(0, 30));
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
|
||||
maxLength={30}
|
||||
className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="flex h-10 items-center gap-1.5 rounded-xl bg-purple-600 px-4 text-xs font-bold text-white transition-colors hover:bg-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Join row */}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入 6 位房间号加入"
|
||||
value={joinCode}
|
||||
onChange={(e) => {
|
||||
setJoinCode(e.target.value.toUpperCase().slice(0, 6));
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }}
|
||||
maxLength={6}
|
||||
className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:font-sans placeholder:tracking-normal placeholder:text-dim focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={joining || joinCode.trim().length < 6}
|
||||
className="flex h-10 items-center gap-1.5 rounded-xl bg-surface px-4 text-xs font-semibold text-gray-300 ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-40"
|
||||
>
|
||||
{joining ? <Loader2 size={14} className="animate-spin" /> : <LogIn size={14} />}
|
||||
加入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
className="mt-2 text-center text-xs font-medium text-rose-400"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
{/* Room list */}
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
{rooms.map((room, i) => (
|
||||
<motion.button
|
||||
key={room.id}
|
||||
onClick={() => router.push(`/blindbox/${room.code}`)}
|
||||
className="group flex w-full items-center gap-3 rounded-2xl bg-surface p-4 text-left ring-1 ring-border transition-all hover:bg-elevated hover:ring-purple-500/30"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.06 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-purple-600/15 ring-1 ring-purple-500/20">
|
||||
<Package size={20} className="text-purple-400" strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-white">{room.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-[11px] text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={11} />
|
||||
{room.memberCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Package size={11} />
|
||||
{room.poolCount} 待抽
|
||||
</span>
|
||||
</div>
|
||||
{room.lastDrawn && (
|
||||
<p className="mt-1 truncate text-[11px] text-purple-400/60">
|
||||
最近抽中:{room.lastDrawn.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members preview */}
|
||||
<div className="flex shrink-0 -space-x-1.5">
|
||||
{room.members.slice(0, 3).map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-xs ring-2 ring-surface"
|
||||
title={m.username}
|
||||
>
|
||||
{m.avatar}
|
||||
</div>
|
||||
))}
|
||||
{room.memberCount > 3 && (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-[10px] font-bold text-muted ring-2 ring-surface">
|
||||
+{room.memberCount - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronRight size={16} className="shrink-0 text-muted/50 transition-colors group-hover:text-purple-400" />
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Auth Modal */}
|
||||
<AuthModal
|
||||
open={showAuth}
|
||||
onClose={() => setShowAuth(false)}
|
||||
onAuth={handleAuth}
|
||||
defaultTab="register"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+54
-119
@@ -1,115 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Zap, Gift, Clock, Trophy } from "lucide-react";
|
||||
import { Zap, Gift, Clock, ChevronRight } from "lucide-react";
|
||||
import BrandLogo from "@/components/BrandLogo";
|
||||
|
||||
function generateRoomCode() {
|
||||
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
}
|
||||
|
||||
interface DrawnIdea {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
|
||||
const [blindboxRoom, setBlindboxRoom] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("nw_blindbox_room");
|
||||
if (saved) {
|
||||
setBlindboxRoom(saved);
|
||||
fetch(`/api/blindbox?roomId=${saved}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.drawn) setDrawnHistory(data.drawn);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePanicMode = () => {
|
||||
router.push("/panic");
|
||||
};
|
||||
|
||||
const handleAdventureMode = () => {
|
||||
let room = blindboxRoom;
|
||||
if (!room) {
|
||||
room = generateRoomCode();
|
||||
localStorage.setItem("nw_blindbox_room", room);
|
||||
setBlindboxRoom(room);
|
||||
}
|
||||
router.push(`/room/${room}/blindbox`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-8 overflow-y-auto scrollbar-none">
|
||||
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-10 overflow-y-auto scrollbar-none">
|
||||
{/* Ambient glow */}
|
||||
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[420px] w-[420px] rounded-full bg-orange-500/8 blur-3xl" />
|
||||
<div className="pointer-events-none fixed left-1/4 top-1/2 h-[300px] w-[300px] rounded-full bg-purple-500/5 blur-3xl" />
|
||||
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="flex items-center gap-3"
|
||||
className="flex flex-col items-center gap-4"
|
||||
initial={{ y: -30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
>
|
||||
<BrandLogo size={40} />
|
||||
<div>
|
||||
<h1 className="text-xl font-black tracking-tight text-white">
|
||||
<BrandLogo size={48} />
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-black tracking-tight text-white">
|
||||
NoWhatever
|
||||
</h1>
|
||||
<p className="text-[10px] font-medium tracking-[0.2em] text-muted">
|
||||
<p className="mt-1 text-[11px] font-medium tracking-[0.2em] text-gray-500">
|
||||
别说随便 · 亲密关系决策引擎
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="mt-3 max-w-xs text-center text-xs leading-relaxed text-muted"
|
||||
className="mt-4 max-w-68 text-center text-sm leading-relaxed text-gray-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
别再说"随便"了。两个模式,覆盖你们所有的选择困难症。
|
||||
别再说"随便"了。
|
||||
<br />
|
||||
两个模式,覆盖你们所有的选择困难症。
|
||||
</motion.p>
|
||||
|
||||
{/* Dual Cards */}
|
||||
<div className="mt-8 flex w-full max-w-sm flex-col gap-5">
|
||||
<div className="mt-9 flex w-full max-w-sm flex-col gap-4">
|
||||
{/* Card A: Panic Mode */}
|
||||
<motion.button
|
||||
onClick={handlePanicMode}
|
||||
className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-yellow-400 to-orange-500 p-6 text-left shadow-lg shadow-orange-500/20 transition-shadow hover:shadow-xl hover:shadow-orange-500/30"
|
||||
onClick={() => router.push("/panic")}
|
||||
className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-yellow-400 to-orange-500 p-5 pb-4 text-left shadow-xl shadow-orange-500/25 ring-1 ring-white/15 transition-shadow hover:shadow-2xl hover:shadow-orange-500/35"
|
||||
initial={{ x: -40, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
whileHover={{ scale: 1.02, rotate: -0.5 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<div className="absolute -right-4 -top-4 h-24 w-24 rounded-full bg-white/10 blur-2xl" />
|
||||
<div className="absolute -bottom-6 -left-6 h-20 w-20 rounded-full bg-white/10 blur-xl" />
|
||||
<div className="absolute -right-4 -top-4 h-28 w-28 rounded-full bg-white/15 blur-2xl" />
|
||||
<div className="absolute -bottom-6 -left-6 h-20 w-20 rounded-full bg-yellow-300/20 blur-xl" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black/10">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black/15 ring-1 ring-white/10">
|
||||
<Zap size={22} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-black text-white">⚡️ 极速救场</h2>
|
||||
<p className="text-[10px] font-semibold tracking-wider text-white/70">
|
||||
<p className="text-[10px] font-semibold tracking-wider text-white/60">
|
||||
PANIC MODE
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium leading-relaxed text-white/90">
|
||||
<p className="mt-3.5 text-sm font-medium leading-relaxed text-white/90">
|
||||
10秒内出结果,立刻闭嘴,听天由命
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs font-bold text-white/60">
|
||||
<Clock size={12} />
|
||||
<span>即时决策 · 转盘匹配</span>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-white/50">
|
||||
<Clock size={12} />
|
||||
<span>即时决策 · 转盘匹配</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 text-xs font-semibold text-white/40 transition-colors group-hover:text-white/70">
|
||||
进入
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,93 +96,56 @@ export default function LandingPage() {
|
||||
|
||||
{/* Card B: Adventure Roulette */}
|
||||
<motion.button
|
||||
onClick={handleAdventureMode}
|
||||
className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-indigo-900 to-purple-800 p-6 text-left shadow-lg shadow-purple-900/30 transition-shadow hover:shadow-xl hover:shadow-purple-500/30"
|
||||
onClick={() => router.push("/blindbox")}
|
||||
className="group relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-indigo-900 to-purple-800 p-5 pb-4 text-left shadow-xl shadow-purple-600/20 ring-1 ring-purple-400/15 transition-shadow hover:shadow-2xl hover:shadow-purple-500/30"
|
||||
initial={{ x: 40, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
whileHover={{ scale: 1.02, rotate: 0.5 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<div className="absolute -right-6 -top-6 h-28 w-28 rounded-full bg-purple-500/20 blur-2xl transition-all group-hover:bg-purple-400/30 group-hover:blur-3xl" />
|
||||
<div className="absolute -bottom-4 -left-4 h-20 w-20 rounded-full bg-indigo-400/15 blur-xl transition-all group-hover:bg-indigo-300/25" />
|
||||
<div className="absolute -right-6 -top-6 h-32 w-32 rounded-full bg-purple-500/20 blur-2xl transition-all group-hover:bg-purple-400/30 group-hover:blur-3xl" />
|
||||
<div className="absolute -bottom-4 -left-4 h-24 w-24 rounded-full bg-indigo-400/15 blur-xl transition-all group-hover:bg-indigo-300/25" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/10">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/10 ring-1 ring-purple-300/15">
|
||||
<Gift size={22} className="text-purple-200" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-black text-white drop-shadow-[0_0_12px_rgba(192,132,252,0.5)]">
|
||||
🎁 周末契约
|
||||
</h2>
|
||||
<p className="text-[10px] font-semibold tracking-wider text-purple-300/70">
|
||||
<p className="text-[10px] font-semibold tracking-wider text-purple-300/60">
|
||||
ADVENTURE ROULETTE
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium leading-relaxed text-purple-100/90">
|
||||
<p className="mt-3.5 text-sm font-medium leading-relaxed text-purple-100/90">
|
||||
丢入疯狂想法,周末盲盒开奖,绝不反悔
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs font-bold text-purple-300/60">
|
||||
<Gift size={12} />
|
||||
<span>盲盒蓄水 · 仪式开奖</span>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-purple-300/50">
|
||||
<Gift size={12} />
|
||||
<span>盲盒蓄水 · 仪式开奖</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 text-xs font-semibold text-purple-200/40 transition-colors group-hover:text-purple-200/70">
|
||||
进入
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Trophy Wall */}
|
||||
{drawnHistory.length > 0 && (
|
||||
<motion.div
|
||||
className="mt-10 w-full max-w-sm"
|
||||
initial={{ y: 30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Trophy size={14} 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/80 px-4 py-3 ring-1 ring-border"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.7 + i * 0.08 }}
|
||||
>
|
||||
<span className="mt-0.5 text-base">🏆</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<motion.p
|
||||
className="mt-auto pt-8 text-center text-[10px] text-dim"
|
||||
className="mt-auto pt-10 text-center text-[10px] font-medium tracking-widest text-muted"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
NoWhatever — 拒绝"随便",从今天开始
|
||||
NOWHATEVER — 拒绝随便,从今天开始
|
||||
</motion.p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,12 @@ interface AuthModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAuth: (profile: UserProfile) => void;
|
||||
defaultTab?: Tab;
|
||||
}
|
||||
|
||||
export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
||||
export default function AuthModal({ open, onClose, onAuth, defaultTab = "login" }: AuthModalProps) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const [tab, setTab] = useState<Tab>("login");
|
||||
const [tab, setTab] = useState<Tab>(defaultTab);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function errorResponse(message: string, status: number) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
export function generateRoomCode(): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function generateUniqueRoomCode(): Promise<string> {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const code = generateRoomCode();
|
||||
const existing = await prisma.blindBoxRoom.findUnique({ where: { code } });
|
||||
if (!existing) return code;
|
||||
}
|
||||
throw new Error("无法生成唯一房间号");
|
||||
}
|
||||
|
||||
export async function validateMembership(roomId: string, userId: string) {
|
||||
const member = await prisma.blindBoxMember.findUnique({
|
||||
where: { roomId_userId: { roomId, userId } },
|
||||
});
|
||||
return !!member;
|
||||
}
|
||||
|
||||
export async function getRoomByCode(code: string) {
|
||||
return prisma.blindBoxRoom.findUnique({
|
||||
where: { code },
|
||||
include: {
|
||||
members: {
|
||||
include: { user: { select: { id: true, username: true, avatar: true } } },
|
||||
},
|
||||
_count: { select: { ideas: { where: { status: "in_pool" } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user