Files
no-whatever/src/components/ContractCompletionModal.tsx
T
kurihada ce76980fe5 refactor(P0): JWT 认证、并发安全、错误日志三项安全加固
- 新增 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 全景分析文档
2026-03-02 17:24:26 +08:00

127 lines
4.0 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 } 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 };