ce76980fe5
- 新增 JWT httpOnly cookie 认证链路 (jose),登录/注册签发 token, 所有用户和盲盒 API 改为从 cookie 提取 userId,不再信任客户端传值 - 新增 /api/auth/logout 端点清除认证 cookie - GET /api/user 区分 owner/非 owner,非 owner 不暴露 email - atomicUpdateRoom 新增 per-room 应用层互斥锁,防止 SQLite 下并发 lost update - 修复 getRoomData 中 fire-and-forget delete 改为 await - 37 个静默 catch 块跨 17 个文件添加 console.error 日志 - 新增 REFACTOR_PLAN.md 全景分析文档
127 lines
4.0 KiB
TypeScript
127 lines
4.0 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||
|
||
interface PendingContract {
|
||
id: string;
|
||
roomName: string;
|
||
date: string;
|
||
activities: string[];
|
||
}
|
||
|
||
interface ContractCompletionModalProps {
|
||
contracts: PendingContract[];
|
||
userId: string;
|
||
onDone: () => void;
|
||
}
|
||
|
||
export default function ContractCompletionModal({
|
||
contracts,
|
||
userId,
|
||
onDone,
|
||
}: ContractCompletionModalProps) {
|
||
const [current, setCurrent] = useState(0);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
if (contracts.length === 0) return null;
|
||
|
||
const contract = contracts[current];
|
||
|
||
const handleAction = async (action: "complete" | "expire") => {
|
||
if (loading) return;
|
||
setLoading(true);
|
||
try {
|
||
await fetch("/api/blindbox/plan", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ planId: contract.id, userId, action }),
|
||
});
|
||
} catch (e) { console.error("ContractCompletionModal: submit failed:", e); }
|
||
setLoading(false);
|
||
|
||
if (current < contracts.length - 1) {
|
||
setCurrent((c) => c + 1);
|
||
} else {
|
||
onDone();
|
||
}
|
||
};
|
||
|
||
const summary =
|
||
contract.activities.length <= 3
|
||
? contract.activities.join(" → ")
|
||
: contract.activities.slice(0, 3).join(" → ") + "…";
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
<motion.div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-6 backdrop-blur-sm"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
>
|
||
<motion.div
|
||
className="w-full max-w-sm overflow-hidden rounded-2xl bg-surface shadow-2xl ring-1 ring-border"
|
||
initial={{ scale: 0.9, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||
>
|
||
<div className="bg-linear-to-br from-purple-600/15 to-indigo-600/15 px-5 py-4">
|
||
<p className="text-center text-xs font-bold tracking-wider text-purple-400/70">
|
||
✦ 契约到期 ✦
|
||
</p>
|
||
<p className="mt-2 text-center text-base font-bold text-heading">
|
||
{contract.roomName}
|
||
</p>
|
||
<p className="mt-1 text-center text-[11px] text-muted">
|
||
{contract.date} · {summary}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="px-5 py-4">
|
||
<p className="text-center text-sm text-secondary">
|
||
这份契约已到期,你完成了吗?
|
||
</p>
|
||
|
||
<div className="mt-4 flex gap-3">
|
||
<button
|
||
onClick={() => handleAction("complete")}
|
||
disabled={loading}
|
||
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-emerald-600 py-3 text-sm font-bold text-white transition-colors hover:bg-emerald-500 disabled:opacity-50"
|
||
>
|
||
{loading ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<CheckCircle2 size={16} />
|
||
)}
|
||
完成了!
|
||
</button>
|
||
<button
|
||
onClick={() => handleAction("expire")}
|
||
disabled={loading}
|
||
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-surface py-3 text-sm font-medium text-muted ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-50"
|
||
>
|
||
{loading ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<XCircle size={16} />
|
||
)}
|
||
没完成
|
||
</button>
|
||
</div>
|
||
|
||
{contracts.length > 1 && (
|
||
<p className="mt-3 text-center text-[10px] text-dim">
|
||
{current + 1} / {contracts.length}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
);
|
||
}
|
||
|
||
export type { PendingContract };
|