diff --git a/packages/server/src/routes/redemption.ts b/packages/server/src/routes/redemption.ts index 164cb34..76c96eb 100644 --- a/packages/server/src/routes/redemption.ts +++ b/packages/server/src/routes/redemption.ts @@ -41,10 +41,19 @@ router.post("/redeem", requireAuth, async (req, res) => { } const redemption = await prisma.$transaction(async (tx) => { - const record = await tx.redemption.create({ - data: { userId: req.userId!, ruleId: rule.id, stampCount: collectionCount }, + // Deduct the oldest N collections (chronological order by collectedAt) + const toDelete = await tx.collection.findMany({ + where: { userId: req.userId! }, + orderBy: { collectedAt: "asc" }, + take: rule.threshold, + select: { id: true }, + }); + await tx.collection.deleteMany({ + where: { id: { in: toDelete.map((c) => c.id) } }, + }); + const record = await tx.redemption.create({ + data: { userId: req.userId!, ruleId: rule.id, stampCount: rule.threshold }, }); - await tx.collection.deleteMany({ where: { userId: req.userId! } }); return record; }); diff --git a/packages/web/src/admin/RedemptionLog.tsx b/packages/web/src/admin/RedemptionLog.tsx index d7c0b35..8e469cd 100644 --- a/packages/web/src/admin/RedemptionLog.tsx +++ b/packages/web/src/admin/RedemptionLog.tsx @@ -63,7 +63,7 @@ export default function RedemptionLog() { 用户 手机号 兑换奖品 - 图章数 + 扣除枚数 时间 diff --git a/packages/web/src/components/RedeemModal.tsx b/packages/web/src/components/RedeemModal.tsx index 495fec9..7ae4ffb 100644 --- a/packages/web/src/components/RedeemModal.tsx +++ b/packages/web/src/components/RedeemModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { RedemptionRuleInfo } from "@stamp/shared"; type RedeemModalProps = { @@ -8,19 +8,52 @@ type RedeemModalProps = { onClose: () => void; }; +const CONFIRM_COUNTDOWN = 5; + export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }: RedeemModalProps) { const [redeeming, setRedeeming] = useState(null); const [error, setError] = useState(""); + const [confirmRuleId, setConfirmRuleId] = useState(null); + const [countdown, setCountdown] = useState(CONFIRM_COUNTDOWN); - const handleRedeem = async (ruleId: string) => { - if (!confirm("兑换后所有已收集的图章将被清空,确定兑换吗?")) return; - setRedeeming(ruleId); + const confirmRule = confirmRuleId ? rules.find((r) => r.id === confirmRuleId) : null; + + // 5-second countdown that restarts each time the confirm panel opens + useEffect(() => { + if (!confirmRuleId) return; + setCountdown(CONFIRM_COUNTDOWN); + const interval = setInterval(() => { + setCountdown((c) => { + if (c <= 1) { + clearInterval(interval); + return 0; + } + return c - 1; + }); + }, 1000); + return () => clearInterval(interval); + }, [confirmRuleId]); + + const openConfirm = (ruleId: string) => { + setError(""); + setConfirmRuleId(ruleId); + }; + + const cancelConfirm = () => { + if (redeeming) return; + setConfirmRuleId(null); + }; + + const doRedeem = async () => { + if (!confirmRule || countdown > 0) return; + setRedeeming(confirmRule.id); setError(""); try { - await onRedeem(ruleId); + await onRedeem(confirmRule.id); onClose(); } catch (e) { setError(e instanceof Error ? e.message : "兑换失败"); + setConfirmRuleId(null); } finally { setRedeeming(null); } @@ -29,9 +62,13 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose } return (
e.target === e.currentTarget && onClose()} + onClick={(e) => { + if (e.target !== e.currentTarget) return; + if (confirmRuleId) return; // Don't dismiss during confirm flow + onClose(); + }} > -
+

兑换奖品

); })}
+
+ + {/* Confirmation dialog — centered over the sheet, highest priority */} + {confirmRule && ( +
e.target === e.currentTarget && cancelConfirm()} + > +
+ {/* Warning at the top — most prominent, filled terracotta */} +
+
+ + + +
+

Important

+

+ 请在工作人员的注视下进行兑换,并确保您已领取奖品 +

+
+
+
+ + {/* Body */} +
+

确认兑换

+ + {/* Reward */} +
+

Reward

+

{confirmRule.name}

+ {confirmRule.description && ( +

{confirmRule.description}

+ )} +
+ + {/* Deduction summary */} +
+
+ 将扣除 + {confirmRule.threshold} +
+

+ 按收集顺序扣除最早的 {confirmRule.threshold} 枚,剩余{" "} + + {collectedCount - confirmRule.threshold} + {" "} + 枚将继续保留。 +

+
+ +

+ 一旦确认,图章将立即扣除,此操作不可撤销 +

+ + {/* Buttons */} +
+ + +
+
+
+
+ )} ); }