"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 PLAN_STATUS_STEPS = [ "正在分析你们的想法...", "正在搜索地点...", "正在规划路线...", "快好了...", ]; 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 [planStatusMessages, setPlanStatusMessages] = useState([]); const planLogRef = useRef(null); 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 (planLogRef.current) { planLogRef.current.scrollTop = planLogRef.current.scrollHeight; } }, [planStatusMessages]); 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(""); setPlanStatusMessages([PLAN_STATUS_STEPS[0]]); const payload = { roomId: room.id, userId: profile.id, availableTime: timeConfig, }; const stepRef = { current: 0 }; const fallbackTimer = setInterval(() => { stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length; setPlanStatusMessages((prev) => [...prev, PLAN_STATUS_STEPS[stepRef.current]]); }, 2800); try { const res = await fetch("/api/blindbox/plan/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "生成失败"); } const reader = res.body?.getReader(); const decoder = new TextDecoder(); if (!reader) throw new Error("无法读取响应"); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split("\n\n"); buffer = blocks.pop() ?? ""; for (const block of blocks) { let eventType = ""; let data = ""; for (const line of block.split("\n")) { if (line.startsWith("event:")) eventType = line.slice(6).trim(); else if (line.startsWith("data:")) data = line.slice(5).trim(); } if (eventType === "status") setPlanStatusMessages((prev) => [...prev, data]); else if (eventType === "plan") { const parsed = JSON.parse(data); setPlanId(parsed.id); setPlanDays(parsed.days); setPlanAccepted(false); setPhase("plan_reveal"); fireConfetti(); } else if (eventType === "error") { setError(data || "生成计划失败"); setPhase("pool"); } } } } catch (e) { try { const res = await fetch("/api/blindbox/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "生成失败"); } const data = await res.json(); setPlanId(data.id); setPlanDays(data.days); setPlanAccepted(false); setPhase("plan_reveal"); fireConfetti(); } catch (fallbackErr) { setError(fallbackErr instanceof Error ? fallbackErr.message : "生成计划失败"); setPhase("pool"); } } finally { clearInterval(fallbackTimer); 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, costLevel: data.tags.costLevel, intensity: data.tags.intensity, needsBooking: data.tags.needsBooking, 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, costLevel: data.tags.costLevel, intensity: data.tags.intensity, needsBooking: data.tags.needsBooking, 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 handleBackToLobby = useCallback(() => { router.push("/blindbox"); router.refresh(); }, [router]); const handlePlanDaysChange = useCallback(async (newDays: WeekendPlanData[]) => { if (!planId || !profile) return; const prevDays = planDays; setPlanDays(newDays); if (planAccepted) { setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev); } try { const res = await fetch("/api/blindbox/plan", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ planId, userId: profile.id, action: "update_plan", days: newDays }), }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败"); } catch (e) { setPlanDays(prevDays); if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev); toast.show(e instanceof Error ? e.message : "保存失败"); } }, [planId, profile, planDays, planAccepted, toast]); const handleRefine = useCallback(async (instruction: string) => { if (!profile || !planDays.length) return; const prevDays = planDays; try { const res = await fetch("/api/blindbox/plan/refine", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: profile.id, instruction, days: planDays }), }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败"); const data = await res.json(); await handlePlanDaysChange(data.days); } catch (e) { setPlanDays(prevDays); toast.show(e instanceof Error ? e.message : "AI 调整失败"); } }, [profile, planDays, handlePlanDaysChange, toast]); /** Non-creator: leave room (remove membership). Creator: delete room (after confirm). */ 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.push("/blindbox"); router.refresh(); } 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} )} {planDays.length > 0 && !planAccepted && ( setPhase("plan_reveal")} className="flex w-full items-center justify-between rounded-xl bg-purple-600/10 px-4 py-2.5 ring-1 ring-purple-500/30 active:bg-purple-600/20" initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} >
有一个待确认的计划
)} )} {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" && (
{planStatusMessages.map((msg, i) => ( {i === planStatusMessages.length - 1 ? ( ) : ( )} {msg} ))}
)} {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={() => { setPlanId(null); setPlanDays([]); setPlanAccepted(false); setPhase("time_select"); }} onShare={() => setShowPlanShareCard(true)} onBack={() => { setPhase("pool"); }} /> )} {/* 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 / Back — hidden during plan view. Creator: 返回大厅 (no delete) + optional 删除房间. Non-creator: 退出房间. */} {isMember && room && phase !== "plan_reveal" && phase !== "planning" && ( {isCreator ? ( <> ) : ( )} )}
{/* Contract expiration check modal */} {pendingContracts.length > 0 && profile && ( { setPendingContracts([]); setActiveContract(null); }} /> )}
); }