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>
This commit is contained in:
2026-04-20 17:16:50 +08:00
parent 394b643304
commit 2c179cd19a
11 changed files with 296 additions and 161 deletions

View File

@@ -9,9 +9,10 @@ type RedeemModalProps = {
const CONFIRM_COUNTDOWN = 5;
type Mode = "redeemed" | "sold-out" | "unavailable" | "ready";
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";
@@ -70,6 +71,8 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
const buttonCopy = () => {
switch (mode) {
case "uncollected":
return "前往点位收集";
case "redeemed":
return "已兑换";
case "sold-out":
@@ -96,7 +99,9 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
>
<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)]"></h3>
<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" />
@@ -108,21 +113,40 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
<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)" }}
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.imageColor} alt={stamp.name} className="w-[92%] h-[92%] object-contain" />
<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>
{stamp.collectedAt && (
{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">
@@ -148,6 +172,11 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
</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>
)}