- 落地页:顶部改为活动海报,底部替换为「活动规则」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>
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
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>
|
||
);
|
||
}
|