From 14b0aaece43ac6d7212b660247253c5e66a2d3c4 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 12:25:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=B2=E7=9B=92=E6=88=BF=E9=97=B4?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=E9=87=8D=E6=9E=84=20=E2=80=94=20=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E7=99=BB=E5=BD=95=E3=80=81=E7=8B=AC=E7=AB=8B=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E3=80=81=E7=94=A8=E6=88=B7=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 BlindBoxRoom/BlindBoxMember 模型,BlindBoxIdea 增加 userId/drawnById - 新增房间 API(创建/加入/列表/详情),所有盲盒 API 增加认证和成员校验 - 新建盲盒大厅页面(三层引导式设计:未登录氛围页/首次创建引导/房间列表) - 新建盲盒房间页面(成员校验/邀请分享/用户归属展示/自动聚焦) - 首页删除契约画廊和 localStorage 盲盒逻辑,周末契约跳转到 /blindbox - 清理旧路由 /room/[id]/blindbox - 提取共享工具 src/lib/blindbox.ts(错误响应/房间号生成/成员校验) - AuthModal 支持 defaultTab 参数 - 更新项目规范:新项目原则、代码优雅和复用优先 --- .cursor/rules/project-conventions.mdc | 10 + .../migration.sql | 52 ++ prisma/schema.prisma | 35 + src/app/api/blindbox/draw/route.ts | 30 +- src/app/api/blindbox/room/[code]/route.ts | 30 + src/app/api/blindbox/room/join/route.ts | 38 ++ src/app/api/blindbox/room/route.ts | 40 ++ src/app/api/blindbox/rooms/route.ts | 69 ++ src/app/api/blindbox/route.ts | 62 +- src/app/blindbox/[code]/page.tsx | 610 ++++++++++++++++++ src/app/blindbox/page.tsx | 453 +++++++++++++ src/app/page.tsx | 173 ++--- src/app/room/[id]/blindbox/page.tsx | 409 ------------ src/components/AuthModal.tsx | 5 +- src/lib/blindbox.ts | 43 ++ 15 files changed, 1502 insertions(+), 557 deletions(-) create mode 100644 prisma/migrations/20260226040305_add_blindbox_room_system/migration.sql create mode 100644 src/app/api/blindbox/room/[code]/route.ts create mode 100644 src/app/api/blindbox/room/join/route.ts create mode 100644 src/app/api/blindbox/room/route.ts create mode 100644 src/app/api/blindbox/rooms/route.ts create mode 100644 src/app/blindbox/[code]/page.tsx create mode 100644 src/app/blindbox/page.tsx delete mode 100644 src/app/room/[id]/blindbox/page.tsx create mode 100644 src/lib/blindbox.ts diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index bc4d652..695b350 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -5,6 +5,13 @@ alwaysApply: true # NoWhatever Project Conventions +## Philosophy + +- This is a **new project** — never worry about backward compatibility or legacy code migration +- Prioritize **code elegance and reuse** over quick hacks +- Extract shared logic into reusable utilities (`src/lib/`) and components (`src/components/`) +- DRY: if the same pattern appears twice, extract it + ## Tech Stack - **Framework**: Next.js App Router (all pages are `"use client"`) @@ -26,9 +33,12 @@ alwaysApply: true - Use `motion.*` from framer-motion for animated elements - Mobile-first, `min-h-dvh` for full-height pages - `overflow-y-auto scrollbar-none` for scrollable pages +- Extract reusable UI patterns into shared components (modals, cards, empty states) ## API Routes - Located in `src/app/api/` - Return `NextResponse.json()` with appropriate status codes - Always handle errors with try/catch +- Extract common validation (userId, membership) into utility functions in `src/lib/` +- Use consistent error response shape: `{ error: string }` diff --git a/prisma/migrations/20260226040305_add_blindbox_room_system/migration.sql b/prisma/migrations/20260226040305_add_blindbox_room_system/migration.sql new file mode 100644 index 0000000..27bec12 --- /dev/null +++ b/prisma/migrations/20260226040305_add_blindbox_room_system/migration.sql @@ -0,0 +1,52 @@ +/* + Warnings: + + - Added the required column `userId` to the `BlindBoxIdea` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateTable +CREATE TABLE "BlindBoxRoom" ( + "id" TEXT NOT NULL PRIMARY KEY, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "BlindBoxRoom_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "BlindBoxMember" ( + "id" TEXT NOT NULL PRIMARY KEY, + "roomId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "BlindBoxMember_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "BlindBoxRoom" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "BlindBoxMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_BlindBoxIdea" ( + "id" TEXT NOT NULL PRIMARY KEY, + "roomId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'in_pool', + "drawnById" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "BlindBoxIdea_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "BlindBoxRoom" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "BlindBoxIdea_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "BlindBoxIdea_drawnById_fkey" FOREIGN KEY ("drawnById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_BlindBoxIdea" ("content", "createdAt", "id", "roomId", "status") SELECT "content", "createdAt", "id", "roomId", "status" FROM "BlindBoxIdea"; +DROP TABLE "BlindBoxIdea"; +ALTER TABLE "new_BlindBoxIdea" RENAME TO "BlindBoxIdea"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "BlindBoxRoom_code_key" ON "BlindBoxRoom"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "BlindBoxMember_roomId_userId_key" ON "BlindBoxMember"("roomId", "userId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3d95785..6459a79 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,11 @@ model User { createdAt DateTime @default(now()) decisions Decision[] favorites Favorite[] + + createdBlindBoxRooms BlindBoxRoom[] @relation("RoomCreator") + blindBoxMemberships BlindBoxMember[] + submittedIdeas BlindBoxIdea[] @relation("IdeaSubmitter") + drawnIdeas BlindBoxIdea[] @relation("IdeaDrawer") } model Decision { @@ -46,10 +51,40 @@ model Favorite { user User @relation(fields: [userId], references: [id]) } +model BlindBoxRoom { + id String @id @default(cuid()) + code String @unique + name String + creatorId String + createdAt DateTime @default(now()) + + creator User @relation("RoomCreator", fields: [creatorId], references: [id]) + members BlindBoxMember[] + ideas BlindBoxIdea[] +} + +model BlindBoxMember { + id String @id @default(cuid()) + roomId String + userId String + joinedAt DateTime @default(now()) + + room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + + @@unique([roomId, userId]) +} + model BlindBoxIdea { id String @id @default(uuid()) roomId String + userId String content String status String @default("in_pool") + drawnById String? createdAt DateTime @default(now()) + + room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User @relation("IdeaSubmitter", fields: [userId], references: [id]) + drawnBy User? @relation("IdeaDrawer", fields: [drawnById], references: [id]) } diff --git a/src/app/api/blindbox/draw/route.ts b/src/app/api/blindbox/draw/route.ts index 96e7b31..bc14902 100644 --- a/src/app/api/blindbox/draw/route.ts +++ b/src/app/api/blindbox/draw/route.ts @@ -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); } } diff --git a/src/app/api/blindbox/room/[code]/route.ts b/src/app/api/blindbox/room/[code]/route.ts new file mode 100644 index 0000000..8baa90b --- /dev/null +++ b/src/app/api/blindbox/room/[code]/route.ts @@ -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); + } +} diff --git a/src/app/api/blindbox/room/join/route.ts b/src/app/api/blindbox/room/join/route.ts new file mode 100644 index 0000000..ab3fe0b --- /dev/null +++ b/src/app/api/blindbox/room/join/route.ts @@ -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); + } +} diff --git a/src/app/api/blindbox/room/route.ts b/src/app/api/blindbox/room/route.ts new file mode 100644 index 0000000..46f603c --- /dev/null +++ b/src/app/api/blindbox/room/route.ts @@ -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); + } +} diff --git a/src/app/api/blindbox/rooms/route.ts b/src/app/api/blindbox/rooms/route.ts new file mode 100644 index 0000000..616dad0 --- /dev/null +++ b/src/app/api/blindbox/rooms/route.ts @@ -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); + } +} diff --git a/src/app/api/blindbox/route.ts b/src/app/api/blindbox/route.ts index 72a0d42..9c9ef3c 100644 --- a/src/app/api/blindbox/route.ts +++ b/src/app/api/blindbox/route.ts @@ -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); + } } diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx new file mode 100644 index 0000000..1076222 --- /dev/null +++ b/src/app/blindbox/[code]/page.tsx @@ -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(null); + const [room, setRoom] = useState(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([]); + const [phase, setPhase] = useState("pool"); + const [revealedIdea, setRevealedIdea] = useState(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(null); + const confettiCanvasRef = useRef(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 ( +
+ +
+ ); + } + + if (!room) return null; + + return ( +
+ + + {/* Header */} +
+ +
+

{room.name}

+

房间 {room.code}

+
+ + {/* Members */} +
+ {room.members.slice(0, 4).map((m) => ( +
+ {m.avatar} +
+ ))} + {room.members.length > 4 && ( +
+ +{room.members.length - 4} +
+ )} +
+ + +
+ + {/* Invite panel */} + + {showInvite && ( + +
+ 房间号 + + {room.code} + +
+ + +
+ + )} + + + {/* Non-member state */} + {!isMember ? ( + + +

你还不是这个房间的成员

+ +
+ ) : ( + <> + {/* Blind Box Visual */} +
+ +
+
+
+
+
+ + + + + ✨ + +
+ + + + 盒子里已有{" "} + {poolCount}{" "} + 个想法 + +
+ + {/* Pool / Shaking / Reveal phases */} + + {phase === "pool" && ( + +
+ { 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" + /> + +
+ + +
+ + 开启周末盲盒(绝不反悔) + + + {error && ( + + {error} + + )} + + )} + + {phase === "shaking" && ( + +

+ 命运正在决定... +

+
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ )} + + {phase === "reveal" && revealedIdea && ( + +
+
+
+
+
+ +
+

+ ✦ 周末契约 ✦ +

+ + {revealedIdea.content} + +
+ + {/* Attribution */} +
+ {revealedIdea.user && ( + + {revealedIdea.user.avatar} {revealedIdea.user.username} 投入 + + )} + {revealedIdea.drawnBy && ( + <> + · + + {revealedIdea.drawnBy.avatar} {revealedIdea.drawnBy.username} 抽中 + + + )} +
+ +

+ 此契约一旦开启,绝不反悔 +

+
+
+ + { 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 }} + > + 继续投入想法 + + + )} + + + {/* History */} + {drawnHistory.length > 0 && phase !== "shaking" && ( + +
+ +

+ 履约记录 +

+
+
+
+ {drawnHistory.map((item, i) => ( + + 🏆 +
+

+ {item.content} +

+
+ {item.user && ( + {item.user.avatar} {item.user.username} 投入 + )} + {item.drawnBy && ( + <> + · + {item.drawnBy.avatar} {item.drawnBy.username} 抽中 + + )} + · + + {new Date(item.createdAt).toLocaleDateString("zh-CN", { + month: "short", + day: "numeric", + weekday: "short", + })} + +
+
+
+ ))} +
+ + )} + + )} + + {/* Toast */} + + {toast && ( + + {toast} + + )} + + +
+
+ ); +} diff --git a/src/app/blindbox/page.tsx b/src/app/blindbox/page.tsx new file mode 100644 index 0000000..824335f --- /dev/null +++ b/src/app/blindbox/page.tsx @@ -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(null); + const [showAuth, setShowAuth] = useState(false); + const [rooms, setRooms] = useState([]); + 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 ( +
+ {/* Ambient */} +
+
+ + {/* Header */} +
+ +
+

🎁 周末契约

+

+ ADVENTURE ROULETTE +

+
+ {profile && ( +
+ {profile.avatar} + {profile.username} +
+ )} +
+ + + {!loggedIn ? ( + /* ============ Layer 1: Unauthenticated — Feature intro ============ */ + + {/* Hero icon */} + +
+
+ +
+ + ✨ + + + +

+ 和 TA 一起,拆开周末 +

+

+ 平日蓄水,周末开奖。把所有"想做但一直没做"的事, + 交给命运来决定。 +

+ + {/* Steps */} +
+ {[ + { step: "1", icon: Plus, text: "创建专属房间,邀请 TA 加入" }, + { step: "2", icon: Package, text: "平时随时塞入疯狂想法" }, + { step: "3", icon: Sparkles, text: "周末一起盲抽,绝不反悔" }, + ].map((s) => ( +
+
+ {s.step} +
+

{s.text}

+
+ ))} +
+ + {/* CTA */} + 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 }} + > + + 登录 / 注册,开始你的第一个盲盒 + + +

+ 仅需用户名 + 密码,10 秒注册 +

+ + ) : loading ? ( + /* ============ Loading ============ */ + + +

加载中...

+
+ ) : rooms.length === 0 ? ( + /* ============ Layer 2: Logged in, no rooms — Create first ============ */ + + +
+ + + +

还没有盲盒房间

+

+ 创建第一个房间,邀请 TA 一起玩 +

+ + {/* Inline create form */} +
+
+ { + 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" + /> + +
+ + {/* Join alternative */} +
+
+ 或输入房间号加入 +
+
+ +
+ { + 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" + /> + +
+ + {error && ( + + {error} + + )} +
+ + ) : ( + /* ============ Layer 3: Logged in, has rooms — Room list ============ */ + + {/* Create row */} +
+ { + 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" + /> + +
+ + {/* Join row */} +
+ { + 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" + /> + +
+ + {error && ( + + {error} + + )} + + {/* Room list */} +
+ {rooms.map((room, i) => ( + 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 */} +
+ +
+ + {/* Info */} +
+

{room.name}

+
+ + + {room.memberCount} + + + + {room.poolCount} 待抽 + +
+ {room.lastDrawn && ( +

+ 最近抽中:{room.lastDrawn.content} +

+ )} +
+ + {/* Members preview */} +
+ {room.members.slice(0, 3).map((m) => ( +
+ {m.avatar} +
+ ))} + {room.memberCount > 3 && ( +
+ +{room.memberCount - 3} +
+ )} +
+ + +
+ ))} +
+
+ )} + + + {/* Auth Modal */} + setShowAuth(false)} + onAuth={handleAuth} + defaultTab="register" + /> +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3d7076d..eff6e0b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([]); - 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 ( -
+
+ {/* Ambient glow */} +
+
+ {/* Header */} - -
-

+ +
+

NoWhatever

-

+

别说随便 · 亲密关系决策引擎

- 别再说"随便"了。两个模式,覆盖你们所有的选择困难症。 + 别再说"随便"了。 +
+ 两个模式,覆盖你们所有的选择困难症。
{/* Dual Cards */} -
+
{/* Card A: Panic Mode */} 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 }} > -
-
+
+
-
+
-
+

⚡️ 极速救场

-

+

PANIC MODE

-

+

10秒内出结果,立刻闭嘴,听天由命

-
- - 即时决策 · 转盘匹配 +
+
+ + 即时决策 · 转盘匹配 +
+
+ 进入 + +
@@ -124,93 +96,56 @@ export default function LandingPage() { {/* Card B: Adventure Roulette */} 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 }} > -
-
+
+
-
+
-
+

🎁 周末契约

-

+

ADVENTURE ROULETTE

-

+

丢入疯狂想法,周末盲盒开奖,绝不反悔

-
- - 盲盒蓄水 · 仪式开奖 +
+
+ + 盲盒蓄水 · 仪式开奖 +
+
+ 进入 + +
- {/* Trophy Wall */} - {drawnHistory.length > 0 && ( - -
- -

- 契约画廊 -

-
-
-
- {drawnHistory.map((item, i) => ( - - 🏆 -
-

- {item.content} -

-

- {new Date(item.createdAt).toLocaleDateString("zh-CN", { - month: "short", - day: "numeric", - weekday: "short", - })} -

-
-
- ))} -
- - )} - {/* Footer */} - NoWhatever — 拒绝"随便",从今天开始 + NOWHATEVER — 拒绝随便,从今天开始
); diff --git a/src/app/room/[id]/blindbox/page.tsx b/src/app/room/[id]/blindbox/page.tsx deleted file mode 100644 index 3a4da1e..0000000 --- a/src/app/room/[id]/blindbox/page.tsx +++ /dev/null @@ -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([]); - const [phase, setPhase] = useState("pool"); - const [revealedIdea, setRevealedIdea] = useState(null); - const [submitFlash, setSubmitFlash] = useState(false); - const [error, setError] = useState(""); - const boxControls = useAnimation(); - const confettiCanvasRef = useRef(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 ( -
- - - {/* Header */} -
- -
-

周末契约

-

房间 {roomId}

-
-
- - {/* Blind Box Visual */} -
- -
- -
-
-
-
- - - - - - - ✨ - -
- - - - 盒子里已有{" "} - {poolCount}{" "} - 个想法 - -
- - {/* Pool Phase: Input + Draw */} - - {phase === "pool" && ( - -
- { - 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" - /> - -
- - -
- - 开启周末盲盒(绝不反悔) - - - {error && ( - - {error} - - )} - - )} - - {phase === "shaking" && ( - -

- 命运正在决定... -

-
- {[0, 1, 2].map((i) => ( - - ))} -
-
- )} - - {phase === "reveal" && revealedIdea && ( - -
-
-
-
-
- -
-

- ✦ 周末契约 ✦ -

- - {revealedIdea.content} - -
-

- 此契约一旦开启,绝不反悔 -

-
-
- - - 继续投入想法 - - - )} - - - {/* History */} - {drawnHistory.length > 0 && phase !== "shaking" && ( - -
- -

- 履约记录 -

-
-
-
- {drawnHistory.map((item, i) => ( - - 🏆 -
-

- {item.content} -

-

- {new Date(item.createdAt).toLocaleDateString("zh-CN", { - month: "short", - day: "numeric", - weekday: "short", - })} -

-
-
- ))} -
- - )} - -
-
- ); -} diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 3bcaf1b..2201d2b 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -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(null); - const [tab, setTab] = useState("login"); + const [tab, setTab] = useState(defaultTab); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); diff --git a/src/lib/blindbox.ts b/src/lib/blindbox.ts new file mode 100644 index 0000000..3105e85 --- /dev/null +++ b/src/lib/blindbox.ts @@ -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 { + 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" } } } }, + }, + }); +}