refactor: 兑换机制改为一图章一奖品并引入库存

- 废弃 RedemptionRule(集 N 换 1),新增 Prize 表与 Stamp 1:1 关联
- Redemption 记录直接绑定到 stampId + prizeId + prizeName 快照
- 兑换事务用 updateMany + stock>0 条件作乐观锁
- 兑换后保留 Collection 记录,图章持续彩色点亮并标记"已兑换"
- 用户端入口改为点击已收集图章弹出兑换,库存为 0 时按钮禁用
- 管理后台删除 /admin/rules,奖品编辑嵌入 StampForm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 15:30:28 +08:00
parent 52169ac71d
commit 394b643304
20 changed files with 581 additions and 642 deletions

View File

@@ -1,26 +1,34 @@
import { useEffect, useState } from "react";
import type { RedemptionRuleInfo } from "@stamp/shared";
import type { StampWithStatus } from "@stamp/shared";
type RedeemModalProps = {
rules: RedemptionRuleInfo[];
collectedCount: number;
onRedeem: (ruleId: string) => Promise<void>;
stamp: StampWithStatus;
onRedeem: (stampId: string) => Promise<void>;
onClose: () => void;
};
const CONFIRM_COUNTDOWN = 5;
export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }: RedeemModalProps) {
const [redeeming, setRedeeming] = useState<string | null>(null);
const [error, setError] = useState("");
const [confirmRuleId, setConfirmRuleId] = useState<string | null>(null);
type Mode = "redeemed" | "sold-out" | "unavailable" | "ready";
function resolveMode(stamp: StampWithStatus): Mode {
if (stamp.redeemed) return "redeemed";
if (!stamp.prize || !stamp.prize.enabled) return "unavailable";
if (stamp.prize.stock <= 0) return "sold-out";
return "ready";
}
export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalProps) {
const [confirming, setConfirming] = useState(false);
const [countdown, setCountdown] = useState(CONFIRM_COUNTDOWN);
const [redeeming, setRedeeming] = useState(false);
const [error, setError] = useState("");
const confirmRule = confirmRuleId ? rules.find((r) => r.id === confirmRuleId) : null;
const mode = resolveMode(stamp);
const prize = stamp.prize;
// 5-second countdown that restarts each time the confirm panel opens
useEffect(() => {
if (!confirmRuleId) return;
if (!confirming) return;
setCountdown(CONFIRM_COUNTDOWN);
const interval = setInterval(() => {
setCountdown((c) => {
@@ -32,39 +40,57 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
});
}, 1000);
return () => clearInterval(interval);
}, [confirmRuleId]);
}, [confirming]);
const openConfirm = (ruleId: string) => {
const openConfirm = () => {
if (mode !== "ready") return;
setError("");
setConfirmRuleId(ruleId);
setConfirming(true);
};
const cancelConfirm = () => {
if (redeeming) return;
setConfirmRuleId(null);
setConfirming(false);
};
const doRedeem = async () => {
if (!confirmRule || countdown > 0) return;
setRedeeming(confirmRule.id);
if (countdown > 0 || redeeming || mode !== "ready") return;
setRedeeming(true);
setError("");
try {
await onRedeem(confirmRule.id);
await onRedeem(stamp.id);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "兑换失败");
setConfirmRuleId(null);
setConfirming(false);
} finally {
setRedeeming(null);
setRedeeming(false);
}
};
const buttonCopy = () => {
switch (mode) {
case "redeemed":
return "已兑换";
case "sold-out":
return "已兑完";
case "unavailable":
return "暂无奖品";
case "ready":
return "立即兑换";
}
};
const buttonBg = mode === "ready" ? "var(--jade)" : mode === "redeemed" ? "var(--gold)" : "var(--border-muted)";
const buttonColor = mode === "ready" || mode === "redeemed" ? "white" : "var(--text-muted)";
return (
<div className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
<div
className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
style={{ backgroundColor: "var(--overlay)" }}
onClick={(e) => {
if (e.target !== e.currentTarget) return;
if (confirmRuleId) return; // Don't dismiss during confirm flow
if (confirming) return;
onClose();
}}
>
@@ -78,57 +104,75 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
</button>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-4">
<span className="font-semibold text-[var(--jade)]">{collectedCount}</span>
</p>
{error && (
<p className="text-sm text-[var(--terracotta)] mb-3">{error}</p>
)}
<div className="space-y-3">
{rules.map((rule) => {
const canRedeem = collectedCount >= rule.threshold;
return (
<div
key={rule.id}
className="flex items-center justify-between p-4 rounded-xl border"
style={{
borderColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
backgroundColor: canRedeem ? "rgba(45, 106, 79, 0.05)" : "white",
}}
>
<div className="flex-1 min-w-0 mr-3">
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
{rule.name}
</p>
{rule.description && (
<p className="text-xs text-[var(--text-muted)] mt-0.5 truncate">{rule.description}</p>
)}
<p className="text-xs text-[var(--text-muted)] mt-1">
{rule.threshold}
</p>
</div>
<button
onClick={() => openConfirm(rule.id)}
disabled={!canRedeem || !!redeeming}
className="shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{
backgroundColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
color: canRedeem ? "white" : "var(--text-muted)",
}}
>
</button>
</div>
);
})}
{/* Stamp header */}
<div className="flex items-center gap-4 mb-5">
<div
className="w-16 h-16 rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] shrink-0"
style={{ boxShadow: "0 2px 6px rgba(212,165,116,0.25), inset 0 0 0 1px rgba(212,165,116,0.15)" }}
>
<img src={stamp.imageColor} alt={stamp.name} className="w-[92%] h-[92%] object-contain" />
</div>
<div className="min-w-0">
<p className="text-[10px] tracking-[0.25em] uppercase text-[var(--gold)] mb-0.5">Stamp</p>
<p className="text-base font-semibold text-[var(--text-primary)] truncate">{stamp.name}</p>
{stamp.collectedAt && (
<p className="text-xs text-[var(--text-muted)] mt-0.5">
{new Date(stamp.collectedAt).toLocaleDateString("zh-CN")}
</p>
)}
</div>
</div>
{/* Prize card */}
{prize ? (
<div className="rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-4 mb-4">
<p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-1.5">Reward</p>
<p className="text-sm font-semibold text-[var(--text-primary)]">{prize.name}</p>
{prize.description && (
<p className="text-xs text-[var(--text-muted)] mt-1 leading-relaxed">{prize.description}</p>
)}
<div className="mt-3 pt-3 border-t border-[var(--jade)]/15 flex items-baseline justify-between">
<span className="text-xs text-[var(--text-muted)]"></span>
<span
className="text-xl font-semibold"
style={{ color: prize.stock > 0 ? "var(--jade)" : "var(--terracotta)" }}
>
{prize.stock}
<span className="text-xs font-normal text-[var(--text-muted)] ml-1"></span>
</span>
</div>
</div>
) : (
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 mb-4 text-center">
<p className="text-sm text-[var(--text-muted)]"></p>
</div>
)}
{mode === "redeemed" && (
<p className="text-xs text-[var(--text-muted)] text-center mb-4"></p>
)}
{mode === "sold-out" && (
<p className="text-xs text-[var(--terracotta)] text-center mb-4"></p>
)}
{error && <p className="text-sm text-[var(--terracotta)] mb-3 text-center">{error}</p>}
<button
onClick={openConfirm}
disabled={mode !== "ready"}
className="w-full py-3.5 rounded-xl text-sm font-medium transition-all disabled:cursor-not-allowed"
style={{
backgroundColor: buttonBg,
color: buttonColor,
boxShadow: mode === "ready" ? "0 2px 8px rgba(45,106,79,0.25)" : "none",
}}
>
{buttonCopy()}
</button>
</div>
{/* Confirmation dialog — centered over the sheet, highest priority */}
{confirmRule && (
{/* Confirmation dialog */}
{confirming && prize && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center px-5 py-6 animate-overlay-fade"
style={{ backgroundColor: "rgba(26, 26, 46, 0.6)" }}
@@ -138,17 +182,18 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
className="w-full max-w-sm bg-[var(--bg-cream)] rounded-2xl animate-scale-in overflow-hidden"
style={{ boxShadow: "0 24px 60px rgba(26,26,46,0.35)" }}
>
{/* Warning at the top — most prominent, filled terracotta */}
<div
className="px-5 py-4"
style={{
backgroundColor: "var(--terracotta)",
color: "#fff",
}}
>
{/* Warning */}
<div className="px-5 py-4" style={{ backgroundColor: "var(--terracotta)", color: "#fff" }}>
<div className="flex gap-3">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"
className="shrink-0 mt-0.5">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
className="shrink-0 mt-0.5"
>
<path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="flex-1">
@@ -160,50 +205,37 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
</div>
</div>
{/* Body */}
<div className="px-5 pt-5 pb-5">
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-4"></h3>
{/* Reward */}
<div className="rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-3.5 mb-3">
<p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-1">Reward</p>
<p className="text-sm font-semibold text-[var(--text-primary)]">{confirmRule.name}</p>
{confirmRule.description && (
<p className="text-xs text-[var(--text-muted)] mt-0.5">{confirmRule.description}</p>
<p className="text-sm font-semibold text-[var(--text-primary)]">{prize.name}</p>
{prize.description && (
<p className="text-xs text-[var(--text-muted)] mt-0.5">{prize.description}</p>
)}
</div>
{/* Deduction summary */}
<div className="rounded-xl border border-[var(--border-default)] bg-white p-3.5 mb-2">
<div className="flex items-baseline justify-between">
<span className="text-xs text-[var(--text-muted)]"></span>
<span className="text-xl font-semibold text-[var(--terracotta)]">{confirmRule.threshold}</span>
</div>
<p className="text-xs text-[var(--text-muted)] mt-1 leading-relaxed">
{confirmRule.threshold} {" "}
<span className="font-medium text-[var(--text-secondary)]">
{collectedCount - confirmRule.threshold}
</span>{" "}
<p className="text-xs text-[var(--text-muted)] leading-relaxed">
<span className="font-medium text-[var(--text-secondary)]">{stamp.name}</span>
<span className="font-medium text-[var(--jade)]"></span>
</p>
</div>
<p className="text-[11px] text-[var(--text-muted)] text-center mb-4">
</p>
<p className="text-[11px] text-[var(--text-muted)] text-center mb-4"></p>
{/* Buttons */}
<div className="flex gap-2.5">
<button
onClick={cancelConfirm}
disabled={!!redeeming}
disabled={redeeming}
className="flex-1 py-3 rounded-xl text-sm font-medium border border-[var(--border-default)] text-[var(--text-secondary)] bg-white disabled:opacity-40"
>
</button>
<button
onClick={doRedeem}
disabled={countdown > 0 || !!redeeming}
disabled={countdown > 0 || redeeming}
className="flex-[1.3] py-3 rounded-xl text-sm font-medium transition-colors"
style={{
backgroundColor: countdown > 0 || redeeming ? "var(--border-default)" : "var(--jade)",
@@ -211,11 +243,7 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
boxShadow: countdown > 0 || redeeming ? "none" : "0 2px 8px rgba(45,106,79,0.25)",
}}
>
{redeeming
? "兑换中..."
: countdown > 0
? `请阅读提示 ${countdown}s`
: "确认兑换"}
{redeeming ? "兑换中..." : countdown > 0 ? `请阅读提示 ${countdown}s` : "确认兑换"}
</button>
</div>
</div>