Files
citywalk-stamp/packages/web/src/pages/LandingPage.tsx
YANG JIANKUAN dbe8ea5460 feat: 新增静态文章模块并支持 NFC 链接分发
- 新增 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>
2026-04-19 18:14:41 +08:00

289 lines
12 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, 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>
);
}