- 落地页:顶部改为活动海报,底部替换为「活动规则」5 条编号列表 - 集章收集弹窗 (StampPopup):新增奖品规则卡片展示 Prize 信息 - 集章册 (AlbumPage / StampGrid):所有状态图章均可点击查看详情 - 兑换弹窗 (RedeemModal):新增 uncollected 分支,统一承载未收集/ 已集齐/已兑换三种状态;新增可选品牌说明区 - 后端 /api/stamps/:id 补充返回 prize 字段 - 管理后台字段标签改名:备注 → 品牌说明;奖品描述 → 奖品说明 - 新增一次性脚本 update-brand-rules,批量写入 16 条品牌权益文案 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import type { StampWithStatus } from "@stamp/shared";
|
||
|
||
type RedeemModalProps = {
|
||
stamp: StampWithStatus;
|
||
onRedeem: (stampId: string) => Promise<void>;
|
||
onClose: () => void;
|
||
};
|
||
|
||
const CONFIRM_COUNTDOWN = 5;
|
||
|
||
type Mode = "uncollected" | "redeemed" | "sold-out" | "unavailable" | "ready";
|
||
|
||
function resolveMode(stamp: StampWithStatus): Mode {
|
||
if (!stamp.collected) return "uncollected";
|
||
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 mode = resolveMode(stamp);
|
||
const prize = stamp.prize;
|
||
|
||
useEffect(() => {
|
||
if (!confirming) return;
|
||
setCountdown(CONFIRM_COUNTDOWN);
|
||
const interval = setInterval(() => {
|
||
setCountdown((c) => {
|
||
if (c <= 1) {
|
||
clearInterval(interval);
|
||
return 0;
|
||
}
|
||
return c - 1;
|
||
});
|
||
}, 1000);
|
||
return () => clearInterval(interval);
|
||
}, [confirming]);
|
||
|
||
const openConfirm = () => {
|
||
if (mode !== "ready") return;
|
||
setError("");
|
||
setConfirming(true);
|
||
};
|
||
|
||
const cancelConfirm = () => {
|
||
if (redeeming) return;
|
||
setConfirming(false);
|
||
};
|
||
|
||
const doRedeem = async () => {
|
||
if (countdown > 0 || redeeming || mode !== "ready") return;
|
||
setRedeeming(true);
|
||
setError("");
|
||
try {
|
||
await onRedeem(stamp.id);
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "兑换失败");
|
||
setConfirming(false);
|
||
} finally {
|
||
setRedeeming(false);
|
||
}
|
||
};
|
||
|
||
const buttonCopy = () => {
|
||
switch (mode) {
|
||
case "uncollected":
|
||
return "前往点位收集";
|
||
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"
|
||
style={{ backgroundColor: "var(--overlay)" }}
|
||
onClick={(e) => {
|
||
if (e.target !== e.currentTarget) return;
|
||
if (confirming) return;
|
||
onClose();
|
||
}}
|
||
>
|
||
<div className="w-full max-w-lg bg-[var(--bg-cream)] rounded-t-2xl p-6 pb-10 animate-slide-up safe-bottom relative overflow-hidden">
|
||
<div className="flex items-center justify-between mb-5">
|
||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||
{mode === "uncollected" ? "品牌权益" : "兑换奖品"}
|
||
</h3>
|
||
<button onClick={onClose} className="text-[var(--text-muted)] p-1">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M18 6L6 18M6 6l12 12" />
|
||
</svg>
|
||
</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: stamp.collected
|
||
? "0 2px 6px rgba(212,165,116,0.25), inset 0 0 0 1px rgba(212,165,116,0.15)"
|
||
: "0 1px 3px rgba(0,0,0,0.05)",
|
||
}}
|
||
>
|
||
<img
|
||
src={stamp.collected ? stamp.imageColor : stamp.imageGrey}
|
||
alt={stamp.name}
|
||
className="w-[92%] h-[92%] object-contain"
|
||
style={{ opacity: stamp.collected ? 1 : 0.6 }}
|
||
/>
|
||
</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>
|
||
{mode === "uncollected" ? (
|
||
<p className="text-xs text-[var(--text-muted)] mt-0.5">尚未收集</p>
|
||
) : stamp.collectedAt ? (
|
||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||
收集于 {new Date(stamp.collectedAt).toLocaleDateString("zh-CN")}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Brand description (optional) */}
|
||
{stamp.note && (
|
||
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-3.5 mb-3">
|
||
<p className="text-[10px] tracking-[0.2em] text-[var(--gold)] uppercase mb-1">Brand</p>
|
||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">{stamp.note}</p>
|
||
</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 === "uncollected" && (
|
||
<p className="text-xs text-[var(--text-muted)] text-center mb-4 leading-relaxed">
|
||
请前往线下点位触碰 NFC 收集该图章后再来兑换
|
||
</p>
|
||
)}
|
||
{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 */}
|
||
{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)" }}
|
||
onClick={(e) => e.target === e.currentTarget && cancelConfirm()}
|
||
>
|
||
<div
|
||
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 */}
|
||
<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"
|
||
>
|
||
<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">
|
||
<p className="text-[11px] tracking-[0.25em] uppercase opacity-80 mb-0.5">Important</p>
|
||
<p className="text-[15px] font-semibold leading-snug">
|
||
请在工作人员的注视下进行兑换,并确保您已领取奖品
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-5 pt-5 pb-5">
|
||
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-4">确认兑换</h3>
|
||
|
||
<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)]">{prize.name}</p>
|
||
{prize.description && (
|
||
<p className="text-xs text-[var(--text-muted)] mt-0.5">{prize.description}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-[var(--border-default)] bg-white p-3.5 mb-2">
|
||
<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>
|
||
|
||
<div className="flex gap-2.5">
|
||
<button
|
||
onClick={cancelConfirm}
|
||
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}
|
||
className="flex-[1.3] py-3 rounded-xl text-sm font-medium transition-colors"
|
||
style={{
|
||
backgroundColor: countdown > 0 || redeeming ? "var(--border-default)" : "var(--jade)",
|
||
color: countdown > 0 || redeeming ? "var(--text-muted)" : "white",
|
||
boxShadow: countdown > 0 || redeeming ? "none" : "0 2px 8px rgba(45,106,79,0.25)",
|
||
}}
|
||
>
|
||
{redeeming ? "兑换中..." : countdown > 0 ? `请阅读提示 ${countdown}s` : "确认兑换"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|