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 */}
+
+
+
+
+
+
+
+ )}
);
}