feat: 契约生命周期 + 到期通知 + 成就墙

- 扩展 WeekendPlan schema: 新增 endTime 字段与 userId 索引
- PATCH /api/blindbox/plan 支持 accept/complete/expire 操作,
  接受时自动计算契约结束时间
- GET /api/blindbox/plan 支持 mode 参数 (latest/pending/history)
- 房间页接受契约后自动返回想法池,顶部显示"契约进行中"指示器
- 契约到期时触发浏览器通知 + 页面加载时弹出完成确认弹窗
- 新增 /achievements 成就墙页面:统计数据 + 决策记录/契约记录双标签
- 首页和个人中心新增成就墙入口
This commit is contained in:
2026-02-27 02:12:18 +08:00
parent d8e42b860f
commit c17b13b6a8
10 changed files with 889 additions and 36 deletions
+111 -7
View File
@@ -18,6 +18,8 @@ import {
MapPin,
Calendar,
Sparkles,
ClipboardCheck,
ChevronRight,
} from "lucide-react";
import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId";
@@ -27,6 +29,7 @@ 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";
@@ -76,6 +79,12 @@ export default function BlindboxRoomPage() {
const [planAccepted, setPlanAccepted] = useState(false);
const [generating, setGenerating] = useState(false);
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
const [activeContract, setActiveContract] = useState<{
id: string;
days: WeekendPlanData[];
endTime: string | null;
} | null>(null);
const [pendingContracts, setPendingContracts] = useState<PendingContract[]>([]);
const boxControls = useAnimation();
const inputRef = useRef<HTMLInputElement>(null);
@@ -141,14 +150,15 @@ export default function BlindboxRoomPage() {
const p = getCachedProfile();
if (!room || !p) return;
try {
const res = await fetch(`/api/blindbox/plan?roomId=${room.id}&userId=${p.id}`);
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) {
setPlanId(data.plan.id);
setPlanDays(data.plan.days);
setPlanAccepted(true);
setPhase("plan_reveal");
setActiveContract({
id: data.plan.id,
days: data.plan.days,
endTime: data.plan.endTime ?? null,
});
}
} catch { /* ignore */ }
}, [room]);
@@ -160,6 +170,53 @@ export default function BlindboxRoomPage() {
}
}, [isMember, room, fetchIdeas, fetchAcceptedPlan]);
// Check for expired contracts on load
useEffect(() => {
const p = getCachedProfile();
if (!isMember || !p) return;
(async () => {
try {
const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`);
if (!res.ok) return;
const data = await res.json();
if (data.pending?.length) setPendingContracts(data.pending);
} catch { /* ignore */ }
})();
}, [isMember]);
// Browser notification timer for active contract
useEffect(() => {
if (!activeContract?.endTime) return;
const end = new Date(activeContract.endTime).getTime();
const now = Date.now();
const ms = end - now;
if (ms <= 0) return;
if (typeof Notification !== "undefined" && Notification.permission === "default") {
Notification.requestPermission();
}
const timer = setTimeout(() => {
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
const n = new Notification("周末契约到期", {
body: "你的周末契约已结束,完成了吗?",
icon: "/icon-192x192.png",
});
n.onclick = () => { window.focus(); n.close(); };
}
// Refresh pending contracts
const p = getCachedProfile();
if (p) {
fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`)
.then((r) => r.json())
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
.catch(() => {});
}
}, ms);
return () => clearTimeout(timer);
}, [activeContract?.endTime]);
useEffect(() => {
if (isMember && inputRef.current) {
const t = setTimeout(() => inputRef.current?.focus(), 300);
@@ -505,6 +562,28 @@ export default function BlindboxRoomPage() {
</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 && (
@@ -808,13 +887,26 @@ export default function BlindboxRoomPage() {
fireConfetti();
if (planId && profile) {
try {
await fetch("/api/blindbox/plan", {
const res = await fetch("/api/blindbox/plan", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId, userId: profile.id }),
body: JSON.stringify({ planId, userId: profile.id, action: "accept" }),
});
const data = await res.json();
setActiveContract({
id: planId,
days: planDays,
endTime: data.endTime ?? null,
});
} catch { /* best-effort */ }
}
toast.show("契约已接受!");
timersRef.current.push(setTimeout(() => {
setPhase("pool");
setPlanId(null);
setPlanDays([]);
setPlanAccepted(false);
}, 1500));
}}
onRegenerate={() => {
setPhase("time_select");
@@ -914,6 +1006,18 @@ export default function BlindboxRoomPage() {
)}
<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>
);
}