Files
citywalk-stamp/packages/web/src/pages/AlbumPage.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

160 lines
5.8 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 { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import type { StampWithStatus, RedemptionRecord } from "@stamp/shared";
import { apiFetch } from "../lib/api";
import { useAuth } from "../lib/auth";
import StampGrid from "../components/StampGrid";
import RedeemModal from "../components/RedeemModal";
import RegisterModal from "../components/RegisterModal";
export default function AlbumPage() {
const navigate = useNavigate();
const { user, isLoading: authLoading } = useAuth();
const [stamps, setStamps] = useState<StampWithStatus[]>([]);
const [history, setHistory] = useState<RedemptionRecord[]>([]);
const [loading, setLoading] = useState(true);
const [selectedStampId, setSelectedStampId] = useState<string | null>(null);
const [showRegister, setShowRegister] = useState(false);
const collectedCount = stamps.filter((s) => s.collected).length;
const selectedStamp = selectedStampId ? stamps.find((s) => s.id === selectedStampId) ?? null : null;
const fetchData = async () => {
setLoading(true);
try {
const stampsData = await apiFetch<StampWithStatus[]>("/stamps");
setStamps(stampsData);
if (user) {
const historyData = await apiFetch<RedemptionRecord[]>("/redemption/history");
setHistory(historyData);
} else {
setHistory([]);
}
} catch {
// Stamps endpoint works without auth
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!authLoading) fetchData();
}, [authLoading, user]);
const handleRedeem = async (stampId: string) => {
await apiFetch("/redemption/redeem", {
method: "POST",
body: JSON.stringify({ stampId }),
});
await fetchData();
};
const handleStampClick = (stamp: StampWithStatus) => {
if (!user) {
setShowRegister(true);
return;
}
setSelectedStampId(stamp.id);
};
if (loading || authLoading) {
return (
<div className="min-h-screen bg-[var(--bg-cream)] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[var(--gold)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-[var(--bg-cream)] paper-texture">
{/* Header */}
<div className="sticky top-0 z-40 bg-[var(--bg-cream)]/90 backdrop-blur-sm border-b border-[var(--border-muted)]">
<div className="flex items-center justify-between px-4 py-3">
<button onClick={() => navigate("/")} className="text-[var(--text-secondary)] p-1">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<h1 className="text-base font-semibold text-[var(--text-primary)]"></h1>
<div className="w-8" />
</div>
</div>
{/* Progress */}
<div className="px-6 pt-5 pb-5">
<div className="flex items-end justify-between mb-2">
<div>
<div className="text-xs text-[var(--text-muted)] mb-0.5"></div>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-semibold text-[var(--text-primary)]">{collectedCount}</span>
<span className="text-sm text-[var(--text-muted)]">/ {stamps.length}</span>
</div>
</div>
{stamps.length > 0 && collectedCount < stamps.length && (
<span className="text-xs text-[var(--text-secondary)] pb-1">
{stamps.length - collectedCount}
</span>
)}
{stamps.length > 0 && collectedCount === stamps.length && (
<span className="text-xs font-medium text-[var(--gold)] pb-1"> </span>
)}
</div>
<div className="h-1.5 bg-[var(--border-muted)] rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--gold)] to-[var(--terracotta)] rounded-full transition-all duration-500"
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
/>
</div>
<p className="mt-3 text-[11px] text-[var(--text-muted)] leading-relaxed">
</p>
</div>
{/* Stamp Grid */}
<div className="px-4 pb-6">
<StampGrid stamps={stamps} onStampClick={handleStampClick} />
</div>
{/* Redemption History */}
{history.length > 0 && (
<div className="px-6 pb-8">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3"></h3>
<div className="space-y-2">
{history.map((r) => (
<div key={r.id} className="flex items-center justify-between py-2 px-3 bg-white/60 rounded-lg">
<div className="min-w-0">
<p className="text-sm text-[var(--text-primary)] truncate">{r.prizeName}</p>
<p className="text-xs text-[var(--text-muted)]">
{r.stampName} · {new Date(r.redeemedAt).toLocaleDateString("zh-CN")}
</p>
</div>
<span className="text-xs text-[var(--jade)] shrink-0 ml-3"></span>
</div>
))}
</div>
</div>
)}
{/* Modals */}
{selectedStamp && (
<RedeemModal
stamp={selectedStamp}
onRedeem={handleRedeem}
onClose={() => setSelectedStampId(null)}
/>
)}
{showRegister && (
<RegisterModal
onSuccess={() => {
setShowRegister(false);
fetchData();
}}
onClose={() => setShowRegister(false)}
/>
)}
</div>
);
}