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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user