init: init prok

This commit is contained in:
2026-04-16 15:34:47 +08:00
commit db74381f13
56 changed files with 5850 additions and 0 deletions

View 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>
);
}