init: init prok
This commit is contained in:
288
packages/web/src/pages/LandingPage.tsx
Normal file
288
packages/web/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
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: "发现点位专属二维码,扫描即刻收入囊中" },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user