- 新增 Article 数据模型 + 迁移(title/subtitle/body/coverImage/caption) - 后端:公共 /api/articles 查询接口 + 管理端 CRUD/上传/二维码 - 前端:移动端 /article/:id 阅读页(Playfair + 纸张肌理 + 首行缩进) - Admin:新增文章管理三页(列表/表单/二维码)与侧栏入口 - 导入 6 篇点位解说词:朝天宫/七家湾/运渎/打钉巷/绒庄街/熙南里 - Admin 二维码页增加「复制链接(写入 NFC)」按钮 - 落地页步骤文案从扫码改为 NFC 触碰 - Dockerfile + entrypoint 增加 articles 图片回灌 - 修复 deploy-stamp skill 构建轮询卡住(pgrep 模式错误) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||
import { apiFetch } from "../lib/api";
|
||
import { useAuth } from "../lib/auth";
|
||
import FloatingButton from "../components/FloatingButton";
|
||
import StampPopup from "../components/StampPopup";
|
||
import RegisterModal from "../components/RegisterModal";
|
||
|
||
const PENDING_STAMP_KEY = "stamp_pending_collect";
|
||
|
||
type StampDetail = {
|
||
id: string;
|
||
name: string;
|
||
note: string | null;
|
||
imageColor: string;
|
||
imageGrey: string;
|
||
};
|
||
|
||
type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "collecting" | "collected" | "already_collected";
|
||
|
||
const STEPS = [
|
||
{ num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" },
|
||
{ num: "02", title: "触碰集章", desc: "手机轻触点位 NFC 芯片,图章即刻收入囊中" },
|
||
{ num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" },
|
||
];
|
||
|
||
export default function LandingPage() {
|
||
const [searchParams] = useSearchParams();
|
||
const navigate = useNavigate();
|
||
const { user, isLoading: authLoading } = useAuth();
|
||
|
||
const stampId = searchParams.get("stamp");
|
||
|
||
const [stamp, setStamp] = useState<StampDetail | null>(null);
|
||
const [collectState, setCollectState] = useState<CollectState>("idle");
|
||
|
||
// Fetch stamp info when stampId is present
|
||
useEffect(() => {
|
||
if (!stampId || authLoading) return;
|
||
setCollectState("loading");
|
||
apiFetch<StampDetail>(`/stamps/${stampId}`)
|
||
.then((data) => {
|
||
setStamp(data);
|
||
setCollectState("show_stamp");
|
||
})
|
||
.catch(() => {
|
||
setCollectState("idle");
|
||
});
|
||
}, [stampId, authLoading]);
|
||
|
||
const doCollect = useCallback(async () => {
|
||
if (!stampId) return;
|
||
setCollectState("collecting");
|
||
try {
|
||
await apiFetch(`/stamps/${stampId}/collect`, { method: "POST" });
|
||
setCollectState("collected");
|
||
sessionStorage.removeItem(PENDING_STAMP_KEY);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : "";
|
||
if (msg.includes("已经收集")) {
|
||
setCollectState("already_collected");
|
||
} else {
|
||
setCollectState("idle");
|
||
}
|
||
}
|
||
}, [stampId]);
|
||
|
||
// Auto-collect if user just registered and has a pending stamp
|
||
useEffect(() => {
|
||
if (authLoading || !user || collectState !== "show_stamp" || !stampId) return;
|
||
const pending = sessionStorage.getItem(PENDING_STAMP_KEY);
|
||
if (pending === stampId) {
|
||
doCollect();
|
||
}
|
||
}, [authLoading, user, collectState, stampId, doCollect]);
|
||
|
||
const handleCollect = () => {
|
||
if (!user) {
|
||
sessionStorage.setItem(PENDING_STAMP_KEY, stampId!);
|
||
setCollectState("needs_register");
|
||
return;
|
||
}
|
||
doCollect();
|
||
};
|
||
|
||
const handleRegisterSuccess = () => {
|
||
setCollectState("show_stamp");
|
||
doCollect();
|
||
};
|
||
|
||
const handleClose = () => {
|
||
setCollectState("idle");
|
||
setStamp(null);
|
||
navigate("/", { replace: true });
|
||
};
|
||
|
||
const showStampPopup = stamp && (collectState === "show_stamp" || collectState === "collecting");
|
||
const showCollectedPopup = stamp && collectState === "collected";
|
||
const showAlreadyPopup = stamp && collectState === "already_collected";
|
||
const showRegister = collectState === "needs_register";
|
||
|
||
return (
|
||
<div className="grain-overlay">
|
||
{/* ═══════════ HERO ═══════════ */}
|
||
<section className="relative min-h-svh flex flex-col items-center justify-center overflow-hidden">
|
||
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
|
||
<div
|
||
className="absolute inset-0"
|
||
style={{
|
||
backgroundImage: `
|
||
radial-gradient(ellipse 60% 50% at 50% 40%, rgba(212, 165, 116, 0.08) 0%, transparent 100%),
|
||
radial-gradient(circle at 15% 80%, rgba(199, 91, 57, 0.06) 0%, transparent 50%),
|
||
radial-gradient(circle at 85% 20%, rgba(45, 106, 79, 0.04) 0%, transparent 40%)
|
||
`,
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute inset-0 opacity-[0.03]"
|
||
style={{
|
||
backgroundImage: `
|
||
linear-gradient(rgba(212, 165, 116, 0.5) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(212, 165, 116, 0.5) 1px, transparent 1px)
|
||
`,
|
||
backgroundSize: "60px 60px",
|
||
}}
|
||
/>
|
||
|
||
<div className="relative z-10 text-center px-8 flex flex-col items-center">
|
||
<div className="animate-fade-in mb-8" style={{ animationDelay: "0.2s" }}>
|
||
<div className="inline-flex items-center gap-3">
|
||
<span className="block w-8 h-px bg-[var(--gold)]/40" />
|
||
<span className="text-[var(--gold)] text-[11px] tracking-[0.4em] uppercase"
|
||
style={{ fontFamily: "'Playfair Display', serif" }}>
|
||
CityWalk
|
||
</span>
|
||
<span className="block w-8 h-px bg-[var(--gold)]/40" />
|
||
</div>
|
||
</div>
|
||
|
||
<h1 className="animate-fade-in-up text-[var(--text-inverted)] leading-none mb-6"
|
||
style={{
|
||
animationDelay: "0.4s",
|
||
fontSize: "clamp(3rem, 12vw, 4.5rem)",
|
||
fontFamily: "'Playfair Display', serif",
|
||
fontWeight: 700,
|
||
letterSpacing: "-0.02em",
|
||
}}>
|
||
城市漫步
|
||
</h1>
|
||
|
||
<p className="animate-fade-in-up text-[var(--gold-light)]/70 text-sm leading-relaxed max-w-[260px]"
|
||
style={{ animationDelay: "0.6s", letterSpacing: "0.08em" }}>
|
||
走过每一条街巷<br />收集属于你的城市记忆
|
||
</p>
|
||
|
||
<div className="animate-scale-in mt-14" style={{ animationDelay: "0.9s" }}>
|
||
<div className="stamp-seal w-[100px] h-[100px] animate-float">
|
||
<div className="w-[100px] h-[100px] rounded-full flex items-center justify-center"
|
||
style={{
|
||
background: "radial-gradient(circle, rgba(212, 165, 116, 0.12) 0%, rgba(212, 165, 116, 0.02) 70%)",
|
||
border: "1.5px solid rgba(212, 165, 116, 0.2)",
|
||
}}>
|
||
<div className="text-center">
|
||
<div className="text-[var(--gold)] text-[10px] tracking-[0.2em] uppercase opacity-60">Stamp</div>
|
||
<div className="text-[var(--gold)] text-2xl mt-0.5 opacity-80"
|
||
style={{ fontFamily: "'Playfair Display', serif" }}>9</div>
|
||
<div className="text-[var(--gold)] text-[9px] tracking-[0.15em] uppercase opacity-50">Collect</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="animate-fade-in mt-16" style={{ animationDelay: "1.4s" }}>
|
||
<div className="flex flex-col items-center gap-2">
|
||
<span className="text-[var(--gold)]/30 text-[10px] tracking-[0.3em] uppercase">探索</span>
|
||
<div className="w-px h-8 bg-gradient-to-b from-[var(--gold)]/30 to-transparent" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ═══════════ ABOUT ═══════════ */}
|
||
<section className="relative bg-[var(--bg-dark)] py-20 px-6 overflow-hidden">
|
||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[var(--gold)]/20 to-transparent" />
|
||
<div className="max-w-sm mx-auto">
|
||
<div className="flex items-center gap-3 mb-8 animate-fade-in-up">
|
||
<span className="block w-6 h-px bg-[var(--gold)]/40" />
|
||
<span className="text-[var(--gold)]/50 text-[10px] tracking-[0.3em] uppercase">About</span>
|
||
</div>
|
||
<h2 className="text-[var(--text-inverted)] text-2xl leading-snug mb-6 animate-fade-in-up"
|
||
style={{ fontFamily: "'Playfair Display', serif", animationDelay: "0.1s" }}>
|
||
一场属于你的<br /><span className="text-[var(--gold)]">城市寻宝之旅</span>
|
||
</h2>
|
||
<p className="text-[var(--text-inverted)]/50 text-sm leading-[1.9] animate-fade-in-up"
|
||
style={{ animationDelay: "0.2s" }}>
|
||
穿行于古桥与老街之间,在园林深处驻足片刻,于茶馆中听一段旧时光。每一个城市坐标都藏着一枚专属图章,等你亲手揭开。
|
||
</p>
|
||
<div className="ornament-line mt-10" />
|
||
<div className="mt-10 grid grid-cols-3 gap-4 stagger-children">
|
||
{[
|
||
{ num: "9", label: "城市坐标" },
|
||
{ num: "4", label: "限定好礼" },
|
||
{ num: "∞", label: "重复挑战" },
|
||
].map((item) => (
|
||
<div key={item.label} className="text-center">
|
||
<div className="text-[var(--gold)] text-3xl mb-1.5"
|
||
style={{ fontFamily: "'Playfair Display', serif" }}>{item.num}</div>
|
||
<div className="text-[var(--text-inverted)]/35 text-[11px] tracking-wider">{item.label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ═══════════ HOW IT WORKS ═══════════ */}
|
||
<section className="relative paper-texture py-20 px-6 pb-32">
|
||
<div className="relative z-10 max-w-sm mx-auto pt-4">
|
||
<div className="flex items-center gap-3 mb-3">
|
||
<span className="block w-6 h-px bg-[var(--text-primary)]/20" />
|
||
<span className="text-[var(--text-muted)] text-[10px] tracking-[0.3em] uppercase">How it works</span>
|
||
</div>
|
||
<h2 className="text-[var(--text-primary)] text-2xl leading-snug mb-12"
|
||
style={{ fontFamily: "'Playfair Display', serif" }}>
|
||
三步开启旅程
|
||
</h2>
|
||
<div className="space-y-0 stagger-children">
|
||
{STEPS.map((step, i) => (
|
||
<div key={step.num} className="relative flex gap-5">
|
||
<div className="flex flex-col items-center shrink-0">
|
||
<div className="w-11 h-11 rounded-full border-2 flex items-center justify-center"
|
||
style={{ borderColor: "var(--gold)", background: "rgba(212, 165, 116, 0.06)" }}>
|
||
<span className="text-[var(--gold)] text-xs"
|
||
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}>
|
||
{step.num}
|
||
</span>
|
||
</div>
|
||
{i < STEPS.length - 1 && <div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />}
|
||
</div>
|
||
<div className="pb-10 pt-1.5">
|
||
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1">{step.title}</h3>
|
||
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{step.desc}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<FloatingButton />
|
||
|
||
{/* ═══════════ Collection Overlays ═══════════ */}
|
||
{showStampPopup && (
|
||
<StampPopup
|
||
name={stamp.name}
|
||
imageColor={stamp.imageColor}
|
||
note={stamp.note}
|
||
status="preview"
|
||
onCollect={handleCollect}
|
||
onClose={handleClose}
|
||
/>
|
||
)}
|
||
{showCollectedPopup && (
|
||
<StampPopup
|
||
name={stamp.name}
|
||
imageColor={stamp.imageColor}
|
||
note={stamp.note}
|
||
status="collected"
|
||
onClose={handleClose}
|
||
/>
|
||
)}
|
||
{showAlreadyPopup && (
|
||
<StampPopup
|
||
name={stamp.name}
|
||
imageColor={stamp.imageColor}
|
||
note={stamp.note}
|
||
status="already"
|
||
onClose={handleClose}
|
||
/>
|
||
)}
|
||
{showRegister && (
|
||
<RegisterModal
|
||
onSuccess={handleRegisterSuccess}
|
||
onClose={() => setCollectState("show_stamp")}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|