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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user