df2e373beb
- 生成中状态改为滚动日志列表,底部新增消息自动滚动 - 返回想法池不再清空计划,pool 页面保留"待确认计划"横幅 - 换方案时才清空旧计划 - 提示词补充午餐/晚餐时间窗口约束(午餐11:30-13:00,晚餐17:30-19:30) - get_travel_time 从驾车改为公共交通,阈值从30分钟调整为45分钟
1256 lines
47 KiB
TypeScript
1256 lines
47 KiB
TypeScript
"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<T>(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<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 [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<MyIdea[]>([]);
|
||
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 [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<string | null>(null);
|
||
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
|
||
const [planAccepted, setPlanAccepted] = useState(false);
|
||
const [generating, setGenerating] = useState(false);
|
||
const [planStatusMessages, setPlanStatusMessages] = useState<string[]>([]);
|
||
const planLogRef = useRef<HTMLDivElement>(null);
|
||
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
||
const [activeContract, setActiveContract] = useState<{
|
||
id: string;
|
||
days: WeekendPlanData[];
|
||
endTime: string | null;
|
||
} | null>(null);
|
||
const [pendingContracts, setPendingContracts] = useState<PendingContract[]>([]);
|
||
|
||
const boxControls = useAnimation();
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||
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<GeolocationPosition>((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]);
|
||
|
||
/** 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 <BlindboxRoomSkeleton />;
|
||
}
|
||
|
||
if (!room) return null;
|
||
|
||
return (
|
||
<div className={`relative flex h-dvh flex-col items-center bg-background px-5 py-6 scrollbar-none ${
|
||
phase === "plan_reveal" ? "overflow-hidden" : "overflow-y-auto"
|
||
}`}>
|
||
|
||
{/* Header */}
|
||
<div className="flex w-full max-w-sm items-center gap-3">
|
||
<button
|
||
onClick={() => router.push("/blindbox")}
|
||
aria-label="返回"
|
||
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-heading">{room.name}</p>
|
||
<div className="mt-0.5 flex items-center gap-2">
|
||
<p className="text-[10px] text-dim">房间 {room.code}</p>
|
||
<button
|
||
onClick={handleSetLocation}
|
||
disabled={locating}
|
||
className="flex items-center gap-0.5 text-[10px] font-medium text-purple-400/70 transition-colors active:text-purple-400"
|
||
>
|
||
{locating ? (
|
||
<Loader2 size={9} className="animate-spin" />
|
||
) : (
|
||
<MapPin size={9} />
|
||
)}
|
||
{room.city || "设置位置"}
|
||
</button>
|
||
</div>
|
||
</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)}
|
||
aria-label="邀请成员"
|
||
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>
|
||
|
||
{/* Active contract indicator */}
|
||
{activeContract && phase !== "plan_reveal" && (
|
||
<motion.button
|
||
className="mt-3 flex w-full max-w-sm items-center gap-2 rounded-xl bg-purple-600/10 px-3 py-2 ring-1 ring-purple-500/20 transition-colors active:bg-purple-600/20"
|
||
initial={{ opacity: 0, y: -8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
onClick={() => {
|
||
setPlanId(activeContract.id);
|
||
setPlanDays(activeContract.days);
|
||
setPlanAccepted(true);
|
||
setPhase("plan_reveal");
|
||
}}
|
||
>
|
||
<ClipboardCheck size={14} className="shrink-0 text-purple-400" />
|
||
<span className="text-xs font-medium text-purple-300">契约进行中</span>
|
||
<span className="text-[10px] text-purple-400/50">
|
||
{activeContract.days.map((d) => d.date).join(" + ")}
|
||
</span>
|
||
<ChevronRight size={12} className="ml-auto text-purple-400/40" />
|
||
</motion.button>
|
||
)}
|
||
|
||
{/* 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-secondary 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-tertiary">你还不是这个房间的成员</p>
|
||
<Button
|
||
onClick={handleJoinRoom}
|
||
variant="purple"
|
||
size="lg"
|
||
loading={joiningRoom}
|
||
icon={<LogIn size={16} />}
|
||
>
|
||
加入房间
|
||
</Button>
|
||
</motion.div>
|
||
) : (
|
||
<>
|
||
{/* Blind Box Visual — hidden during plan phases */}
|
||
{phase !== "planning" && phase !== "plan_reveal" && (
|
||
<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}
|
||
aria-label="提交想法"
|
||
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>
|
||
|
||
{!input && (
|
||
<div className="flex w-full flex-wrap items-center gap-1.5">
|
||
{suggestionsSource === "ai" ? (
|
||
<Sparkles size={13} className="mr-0.5 shrink-0 text-purple-400/80" />
|
||
) : (
|
||
<Lightbulb size={13} className="mr-0.5 shrink-0 text-amber-500/80" />
|
||
)}
|
||
{suggestionsLoading ? (
|
||
<>
|
||
{[1, 2, 3].map((i) => (
|
||
<div key={i} className="h-6 w-20 animate-pulse rounded-full bg-surface/60 ring-1 ring-border/40" />
|
||
))}
|
||
</>
|
||
) : (
|
||
suggestions.map((s) => (
|
||
<button
|
||
key={s}
|
||
onClick={() => { setInput(s); inputRef.current?.focus(); }}
|
||
className="rounded-full bg-surface/80 px-2.5 py-1 text-xs text-secondary ring-1 ring-border/60 transition-all hover:bg-purple-600/10 hover:text-purple-400 hover:ring-purple-600/30 active:scale-95"
|
||
>
|
||
{s}
|
||
</button>
|
||
))
|
||
)}
|
||
<button
|
||
onClick={refreshSuggestions}
|
||
disabled={suggestionsLoading}
|
||
aria-label="换一批灵感"
|
||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-muted transition-colors hover:bg-surface hover:text-secondary disabled:opacity-30"
|
||
>
|
||
{suggestionsLoading ? <Loader2 size={12} className="animate-spin" /> : <Shuffle size={12} />}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex w-full gap-2">
|
||
<motion.button
|
||
onClick={handleDraw}
|
||
disabled={poolCount === 0}
|
||
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-sm 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={18} />
|
||
抽一个
|
||
</motion.button>
|
||
<motion.button
|
||
onClick={() => {
|
||
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 }}
|
||
>
|
||
<Calendar size={18} />
|
||
周末计划
|
||
</motion.button>
|
||
</div>
|
||
|
||
{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>
|
||
)}
|
||
|
||
{planDays.length > 0 && !planAccepted && (
|
||
<motion.button
|
||
onClick={() => 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 }}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Sparkles size={14} className="text-purple-400" />
|
||
<span className="text-xs font-bold text-purple-300">有一个待确认的计划</span>
|
||
</div>
|
||
<ChevronRight size={14} className="text-purple-400" />
|
||
</motion.button>
|
||
)}
|
||
</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>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button
|
||
onClick={() => setShowShareCard(true)}
|
||
variant="purple"
|
||
shape="pill"
|
||
icon={<Share2 size={14} />}
|
||
>
|
||
分享契约
|
||
</Button>
|
||
<Button
|
||
onClick={() => { setPhase("pool"); setRevealedIdea(null); setShowShareCard(false); }}
|
||
variant="secondary"
|
||
shape="pill"
|
||
>
|
||
继续投入想法
|
||
</Button>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{phase === "planning" && (
|
||
<motion.div
|
||
key="planning"
|
||
className="mt-8 flex flex-col items-center gap-4 w-full px-4"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
>
|
||
<motion.div
|
||
className="relative flex h-16 w-16 items-center justify-center"
|
||
animate={{ rotate: [0, 360] }}
|
||
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
||
>
|
||
<div className="absolute inset-0 rounded-full bg-purple-600/15 blur-lg" />
|
||
<Sparkles size={24} className="relative text-purple-400" />
|
||
</motion.div>
|
||
<div className="w-full max-w-sm rounded-xl bg-surface/60 ring-1 ring-border/60 overflow-hidden">
|
||
<div
|
||
ref={planLogRef}
|
||
className="h-40 overflow-y-auto scrollbar-none p-3 flex flex-col gap-1.5"
|
||
>
|
||
{planStatusMessages.map((msg, i) => (
|
||
<motion.div
|
||
key={i}
|
||
initial={{ opacity: 0, y: 6 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
className={`flex items-center gap-2 text-xs ${i === planStatusMessages.length - 1 ? "text-purple-300 font-medium" : "text-dim"}`}
|
||
>
|
||
{i === planStatusMessages.length - 1 ? (
|
||
<Loader2 size={11} className="shrink-0 animate-spin text-purple-400" />
|
||
) : (
|
||
<span className="shrink-0 text-[10px] text-purple-500">✓</span>
|
||
)}
|
||
{msg}
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{phase === "plan_reveal" && planDays.length > 0 && (
|
||
<motion.div
|
||
key="plan_reveal"
|
||
className="mt-4 flex min-h-0 flex-1 w-full"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
>
|
||
<BlindboxPlan
|
||
days={planDays}
|
||
accepted={planAccepted}
|
||
regenerating={generating}
|
||
onAccept={async () => {
|
||
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");
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* Time selector modal */}
|
||
<AnimatePresence>
|
||
{phase === "time_select" && (
|
||
<WeekendTimeSelector
|
||
onConfirm={handleGeneratePlan}
|
||
onClose={() => setPhase("pool")}
|
||
loading={generating}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{myIdeas.length > 0 && phase === "pool" && (
|
||
<BlindboxMyIdeas
|
||
ideas={myIdeas}
|
||
onEdit={handleEditIdea}
|
||
onDelete={handleDeleteIdea}
|
||
/>
|
||
)}
|
||
|
||
{phase !== "shaking" && phase !== "planning" && (
|
||
<BlindboxDrawnHistory items={drawnHistory} />
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{revealedIdea && room && (
|
||
<ShareCardModal
|
||
open={showShareCard}
|
||
onClose={() => setShowShareCard(false)}
|
||
data={{
|
||
type: "blindbox",
|
||
idea: revealedIdea.content,
|
||
submitter: revealedIdea.user ?? undefined,
|
||
drawer: revealedIdea.drawnBy ?? undefined,
|
||
roomName: room.name,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{planDays.length > 0 && room && (
|
||
<ShareCardModal
|
||
open={showPlanShareCard}
|
||
onClose={() => 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" && (
|
||
<motion.div
|
||
className="mt-12 flex w-full max-w-sm flex-col items-center gap-2"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: 0.5 }}
|
||
>
|
||
{isCreator ? (
|
||
<>
|
||
<button
|
||
onClick={handleBackToLobby}
|
||
className="flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-xs font-medium text-muted transition-colors hover:text-foreground active:bg-elevated"
|
||
>
|
||
<ArrowLeft size={13} />
|
||
返回大厅
|
||
</button>
|
||
<button
|
||
onClick={handleLeaveOrDelete}
|
||
disabled={leaving}
|
||
className={`flex w-full items-center justify-center gap-2 rounded-xl py-2 text-xs font-medium transition-colors ${
|
||
confirmLeave
|
||
? "bg-rose-600/15 text-rose-400 ring-1 ring-rose-500/30"
|
||
: "text-dim hover:text-rose-400/80"
|
||
}`}
|
||
>
|
||
{leaving ? (
|
||
<Loader2 size={12} className="animate-spin" />
|
||
) : (
|
||
<Trash2 size={12} />
|
||
)}
|
||
{confirmLeave ? "确认删除房间?所有想法将被清除" : "删除房间"}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
onClick={handleLeaveOrDelete}
|
||
disabled={leaving}
|
||
className={`flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-xs font-medium transition-colors ${
|
||
confirmLeave
|
||
? "bg-rose-600/15 text-rose-400 ring-1 ring-rose-500/30"
|
||
: "text-muted hover:text-rose-400/80"
|
||
}`}
|
||
>
|
||
{leaving ? (
|
||
<Loader2 size={13} className="animate-spin" />
|
||
) : (
|
||
<LogOut size={13} />
|
||
)}
|
||
{confirmLeave ? "确认退出房间?" : "退出房间"}
|
||
</button>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
|
||
<div className="h-8 shrink-0" />
|
||
|
||
{/* Contract expiration check modal */}
|
||
{pendingContracts.length > 0 && profile && (
|
||
<ContractCompletionModal
|
||
contracts={pendingContracts}
|
||
userId={profile.id}
|
||
onDone={() => {
|
||
setPendingContracts([]);
|
||
setActiveContract(null);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|