"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, Users, Share2, LogIn, Copy, Trash2, LogOut, MapPin, Calendar, Sparkles, ClipboardCheck, ChevronRight, Lightbulb, Shuffle, } from "lucide-react"; import confetti from "canvas-confetti"; import { getCachedProfile, isRegistered } from "@/lib/userId"; import ShareCardModal from "@/components/ShareCardModal"; import Button from "@/components/Button"; import BlindboxMyIdeas, { type MyIdea } from "@/components/BlindboxMyIdeas"; import BlindboxDrawnHistory, { type DrawnIdea } from "@/components/BlindboxDrawnHistory"; import WeekendTimeSelector from "@/components/WeekendTimeSelector"; import BlindboxPlan from "@/components/BlindboxPlan"; import ContractCompletionModal, { type PendingContract } from "@/components/ContractCompletionModal"; import { useToast } from "@/hooks/useToast"; import { useShare } from "@/hooks/useShare"; import { BlindboxRoomSkeleton } from "@/components/Skeleton"; import type { UserProfile, WeekendPlanData } from "@/types"; interface RoomInfo { id: string; code: string; name: string; creatorId: string; city: string | null; lat: number | null; lng: number | null; poolCount: number; members: { id: string; username: string; avatar: string }[]; } type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal"; const IDEA_INSPIRATIONS = [ "去城市最高楼看日落", "挑战一人做一道菜", "找一家从没去过的店", "逛一个从没去过的街区", "去公园野餐", "看一场午夜电影", "在家做一顿异国料理", "骑车去郊外探险", "去二手市场淘宝", "去博物馆逛半天", "试一项没玩过的运动", "随机坐公交到终点站", "一起画画或做手工", "找个天台看星星", "去一家评分最低的餐厅", "穿最好看的衣服去拍照", "交换手机玩一个小时", "一起去做志愿者", ]; function pickRandom(arr: T[], n: number): T[] { const shuffled = [...arr].sort(() => Math.random() - 0.5); return shuffled.slice(0, n); } 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 [suggestions, setSuggestions] = useState(() => pickRandom(IDEA_INSPIRATIONS, 4)); const [suggestionsLoading, setSuggestionsLoading] = useState(false); const [suggestionsSource, setSuggestionsSource] = useState<"static" | "ai">("static"); const [poolCount, setPoolCount] = useState(0); const [myIdeas, setMyIdeas] = useState([]); 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 [showShareCard, setShowShareCard] = useState(false); const toast = useToast(); const [confirmLeave, setConfirmLeave] = useState(false); const [leaving, setLeaving] = useState(false); const [locating, setLocating] = useState(false); const [planId, setPlanId] = useState(null); const [planDays, setPlanDays] = useState([]); const [planAccepted, setPlanAccepted] = useState(false); const [generating, setGenerating] = useState(false); const [showPlanShareCard, setShowPlanShareCard] = useState(false); const [activeContract, setActiveContract] = useState<{ id: string; days: WeekendPlanData[]; endTime: string | null; } | null>(null); const [pendingContracts, setPendingContracts] = useState([]); const boxControls = useAnimation(); const inputRef = useRef(null); const timersRef = useRef[]>([]); const confettiAliveRef = useRef(false); useEffect(() => { return () => { timersRef.current.forEach(clearTimeout); confettiAliveRef.current = false; }; }, []); 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); setMyIdeas(data.myIdeas ?? []); setDrawnHistory(data.drawn ?? []); } } catch { /* ignore */ } }, [room]); const fetchSuggestions = useCallback(async () => { const p = getCachedProfile(); if (!room || !p) return; setSuggestionsLoading(true); try { const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}&userId=${p.id}`); if (res.ok) { const data = await res.json(); if (data.suggestions?.length > 0) { setSuggestions(data.suggestions); setSuggestionsSource("ai"); setSuggestionsLoading(false); return; } } } catch { /* ignore */ } setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); setSuggestionsSource("static"); setSuggestionsLoading(false); }, [room]); const refreshSuggestions = useCallback(() => { if (suggestionsSource === "ai") { fetchSuggestions(); } else { setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); } }, [suggestionsSource, fetchSuggestions]); const fetchAcceptedPlan = useCallback(async () => { const p = getCachedProfile(); if (!room || !p) return; try { const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${p.id}`); if (!res.ok) return; const data = await res.json(); if (data.plan) { setActiveContract({ id: data.plan.id, days: data.plan.days, endTime: data.plan.endTime ?? null, }); } } catch { /* ignore */ } }, [room]); useEffect(() => { if (isMember && room) { fetchIdeas(); fetchAcceptedPlan(); fetchSuggestions(); } }, [isMember, room, fetchIdeas, fetchAcceptedPlan, fetchSuggestions]); // Check for expired contracts on load useEffect(() => { const p = getCachedProfile(); if (!isMember || !p) return; (async () => { try { const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`); if (!res.ok) return; const data = await res.json(); if (data.pending?.length) setPendingContracts(data.pending); } catch { /* ignore */ } })(); }, [isMember]); // Browser notification timer for active contract useEffect(() => { if (!activeContract?.endTime) return; const end = new Date(activeContract.endTime).getTime(); const now = Date.now(); const ms = end - now; if (ms <= 0) return; if (typeof Notification !== "undefined" && Notification.permission === "default") { Notification.requestPermission(); } const timer = setTimeout(() => { if (typeof Notification !== "undefined" && Notification.permission === "granted") { const n = new Notification("周末契约到期", { body: "你的周末契约已结束,完成了吗?", icon: "/icon-192x192.png", }); n.onclick = () => { window.focus(); n.close(); }; } // Refresh pending contracts const p = getCachedProfile(); if (p) { fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`) .then((r) => r.json()) .then((d) => { if (d.pending?.length) setPendingContracts(d.pending); }) .catch(() => {}); } }, ms); return () => clearTimeout(timer); }, [activeContract?.endTime]); useEffect(() => { if (isMember && inputRef.current) { const t = setTimeout(() => inputRef.current?.focus(), 300); timersRef.current.push(t); } }, [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 handleSetLocation = useCallback(async () => { if (locating || !profile || !room) return; setLocating(true); try { const pos = await new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }), ); const { latitude: lat, longitude: lng } = pos.coords; const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`); const regeo = regeoRes.ok ? await regeoRes.json() : {}; const cityName = regeo.name || "未知位置"; const patchRes = await fetch(`/api/blindbox/room/${room.code}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: profile.id, city: cityName, lat, lng }), }); if (!patchRes.ok) throw new Error("保存位置失败"); setRoom((prev) => prev ? { ...prev, city: cityName, lat, lng } : prev); toast.show("位置已设置"); } catch { toast.show("获取位置失败,请允许定位权限"); } finally { setLocating(false); } }, [locating, profile, room, toast]); const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => { if (generating || !profile || !room) return; setGenerating(true); setPhase("planning"); setError(""); try { const res = await fetch("/api/blindbox/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roomId: room.id, userId: profile.id, availableTime: timeConfig, }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "生成失败"); } const data = await res.json(); setPlanId(data.id); setPlanDays(data.days); setPlanAccepted(false); setPhase("plan_reveal"); fireConfetti(); } catch (e) { setError(e instanceof Error ? e.message : "生成计划失败"); setPhase("pool"); } finally { setGenerating(false); } }, [generating, profile, room]); 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 || "提交失败"); } const data = await res.json(); setInput(""); setPoolCount((c) => c + 1); setMyIdeas((prev) => [{ id: data.id, content: text, createdAt: new Date().toISOString(), ...data.tags && { category: data.tags.category, timeSlot: data.tags.timeSlot, estimatedMinutes: data.tags.estimatedMinutes, outdoor: data.tags.outdoor, searchQuery: data.tags.searchQuery, searchType: data.tags.searchType, }, }, ...prev]); setSubmitFlash(true); timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600)); boxControls.start({ scale: [1, 1.08, 1], rotate: [0, -3, 3, 0], transition: { duration: 0.5 }, }); fetchSuggestions(); } catch (e) { setError(e instanceof Error ? e.message : "提交失败"); } finally { setSubmitting(false); } }; const handleEditIdea = useCallback(async (ideaId: string, newContent: string) => { if (!profile) return; const trimmed = newContent.trim(); if (!trimmed || trimmed.length > 200) return; try { const res = await fetch("/api/blindbox", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ideaId, userId: profile.id, content: trimmed }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "编辑失败"); } const data = await res.json(); setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed, ...data.tags && { category: data.tags.category, timeSlot: data.tags.timeSlot, estimatedMinutes: data.tags.estimatedMinutes, outdoor: data.tags.outdoor, searchQuery: data.tags.searchQuery, searchType: data.tags.searchType, }, } : i))); } catch (e) { toast.show(e instanceof Error ? e.message : "编辑失败"); } }, [profile, toast]); const handleDeleteIdea = useCallback(async (ideaId: string) => { if (!profile) return; try { const res = await fetch("/api/blindbox", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ideaId, userId: profile.id }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "删除失败"); } setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId)); setPoolCount((c) => Math.max(0, c - 1)); } catch (e) { toast.show(e instanceof Error ? e.message : "删除失败"); } }, [profile]); 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 }); confettiAliveRef.current = true; const end = Date.now() + 3000; const frame = () => { if (Date.now() > end || !confettiAliveRef.current) 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); }; timersRef.current.push(setTimeout(frame, 200)); }; const { share, copyToClipboard } = useShare(); const handleCopyCode = useCallback( () => room ? copyToClipboard(room.code, "房间号已复制") : undefined, [room, copyToClipboard], ); const handleShare = useCallback(() => { if (!room) return; const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : ""; share( { title: `周末契约 · ${room.name}`, text: `来和我一起玩周末盲盒吧!房间号:${room.code}`, url }, handleCopyCode, ); }, [room, share, handleCopyCode]); const isCreator = profile?.id === room?.creatorId; const handleLeaveOrDelete = async () => { if (!confirmLeave) { setConfirmLeave(true); timersRef.current.push(setTimeout(() => setConfirmLeave(false), 3000)); return; } if (leaving || !profile || !room) return; setLeaving(true); try { const res = await fetch(`/api/blindbox/room/${room.code}`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: profile.id }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "操作失败"); } router.replace("/blindbox"); } catch (e) { toast.show(e instanceof Error ? e.message : "操作失败"); setConfirmLeave(false); } finally { setLeaving(false); } }; 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}
)}
{/* Active contract indicator */} {activeContract && phase !== "plan_reveal" && ( { setPlanId(activeContract.id); setPlanDays(activeContract.days); setPlanAccepted(true); setPhase("plan_reveal"); }} > 契约进行中 {activeContract.days.map((d) => d.date).join(" + ")} )} {/* Invite panel */} {showInvite && (
房间号 {room.code}
)} {/* Non-member state */} {!isMember ? (

你还不是这个房间的成员

) : ( <> {/* Blind Box Visual — hidden during plan phases */} {phase !== "planning" && phase !== "plan_reveal" && (
盒子里已有{" "} {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" />
{!input && (
{suggestionsSource === "ai" ? ( ) : ( )} {suggestionsLoading ? ( <> {[1, 2, 3].map((i) => (
))} ) : ( suggestions.map((s) => ( )) )}
)}
抽一个 { if (!room?.city) { toast.show("请先点击房间名下方设置位置"); return; } setPhase("time_select"); }} disabled={poolCount < 2} className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-black text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50 disabled:opacity-40 disabled:shadow-none" whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.97 }} > 周末计划
{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} 抽中 )}

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

)} {phase === "planning" && (

AI 正在规划你的周末...

搜索地点 · 优化路线 · 安排时间

)} {phase === "plan_reveal" && planDays.length > 0 && ( { setPlanAccepted(true); fireConfetti(); if (planId && profile) { try { const res = await fetch("/api/blindbox/plan", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ planId, userId: profile.id, action: "accept" }), }); const data = await res.json(); setActiveContract({ id: planId, days: planDays, endTime: data.endTime ?? null, }); } catch { /* best-effort */ } } toast.show("契约已接受!"); timersRef.current.push(setTimeout(() => { setPhase("pool"); setPlanId(null); setPlanDays([]); setPlanAccepted(false); }, 1500)); }} onRegenerate={() => { setPhase("time_select"); }} onShare={() => setShowPlanShareCard(true)} onBack={() => { setPhase("pool"); setPlanId(null); setPlanDays([]); setPlanAccepted(false); }} /> )} {/* Time selector modal */} {phase === "time_select" && ( setPhase("pool")} loading={generating} /> )} {myIdeas.length > 0 && phase === "pool" && ( )} {phase !== "shaking" && phase !== "planning" && ( )} )} {revealedIdea && room && ( setShowShareCard(false)} data={{ type: "blindbox", idea: revealedIdea.content, submitter: revealedIdea.user ?? undefined, drawer: revealedIdea.drawnBy ?? undefined, roomName: room.name, }} /> )} {planDays.length > 0 && room && ( setShowPlanShareCard(false)} data={{ type: "plan", days: planDays, roomName: room.name, }} /> )} {/* Leave / Delete — hidden during plan view */} {isMember && room && phase !== "plan_reveal" && phase !== "planning" && ( )}
{/* Contract expiration check modal */} {pendingContracts.length > 0 && profile && ( { setPendingContracts([]); setActiveContract(null); }} /> )}
); }