Files
no-whatever/src/app/blindbox/[code]/page.tsx
T
kurihada df2e373beb feat: 改进计划生成体验与 AI 提示词
- 生成中状态改为滚动日志列表,底部新增消息自动滚动
- 返回想法池不再清空计划,pool 页面保留"待确认计划"横幅
- 换方案时才清空旧计划
- 提示词补充午餐/晚餐时间窗口约束(午餐11:30-13:00,晚餐17:30-19:30)
- get_travel_time 从驾车改为公共交通,阈值从30分钟调整为45分钟
2026-03-02 11:26:20 +08:00

1256 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}