Files
citywalk-stamp/packages/web/src/components/RedeemModal.tsx
YANG JIANKUAN 2c179cd19a feat: 落地页改造 + 集章弹窗全状态品牌/奖品说明
- 落地页:顶部改为活动海报,底部替换为「活动规则」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>
2026-04-20 17:16:50 +08:00

285 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}