Files
2026-03-18 02:01:48 -05:00

272 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"use client";
import { useEffect, useState, useCallback } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import HeroSection from "./HeroSection";
import MyRegistrationStatus from "./MyRegistrationStatus";
import SessionCard from "./SessionCard";
import HostLink from "./HostLink";
import Footer from "./Footer";
import Header from "./Header";
import { getSessionsWithDates, HOSTS } from "@/config/home.config";
import { useCompleteOrderPolling } from "@/app/hooks/useCompleteOrderPolling";
import { Check, X, ClipboardList, Calendar, Lightbulb, Users, HelpCircle } from "lucide-react";
export type JoinStatsMap = Record<string, { count: number; joiners: { nickname: string; avatar?: string; xiaohongshu_url?: string }[] }>;
interface HomeContentProps {
initialVolunteer: { nickname: string; avatar?: string; xiaohongshu_url?: string } | null;
}
const VOLUNTEER_BIO = "签到、茶歇准备、拍照摄像。";
function getPendingOrderId(): string | null {
if (typeof window === "undefined") return null;
try {
const cookie = document.cookie.split(";").find((c) => c.trim().startsWith("join_pay_order="));
const raw = cookie?.split("=")[1]?.trim();
const decoded = raw ? decodeURIComponent(raw) : "";
const fromStorage = localStorage.getItem("join_pay_order") || "";
const value = decoded || fromStorage;
const orderId = value.split("|")[0]?.trim() || "";
return orderId || null;
} catch {
return null;
}
}
export default function HomeContent({ initialVolunteer }: HomeContentProps) {
const pathname = usePathname();
const [volunteer, setVolunteer] = useState(initialVolunteer);
const [stats, setStats] = useState<JoinStatsMap | null>(null);
const [pendingOrderId] = useState<string | null>(() =>
typeof window !== "undefined" ? getPendingOrderId() : null
);
useEffect(() => {
fetch("/api/join/volunteers")
.then((r) => r.json())
.then((d) => d?.volunteer && setVolunteer(d.volunteer))
.catch(() => {});
}, []);
const fetchStats = useCallback(() => {
fetch(`/api/join/stats?_=${Date.now()}`, { cache: "no-store" })
.then((r) => r.json())
.then((d) => d?.stats && setStats(d.stats))
.catch(() => {});
}, []);
useEffect(() => {
if (pathname !== "/") return;
fetchStats();
const onVisible = () => fetchStats();
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) fetchStats();
};
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("pageshow", onPageShow);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("pageshow", onPageShow);
};
}, [pathname, fetchStats]);
useCompleteOrderPolling({
orderId: pendingOrderId,
retryDelays: [500, 1000, 1500, 2500, 4000],
});
return (
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950 pb-20 sm:pb-12">
<Header />
<HeroSection />
<main className="max-w-[900px] md:max-w-[1024px] mx-auto px-4 sm:px-6 md:px-8 py-8 sm:py-12 md:py-14 space-y-10 sm:space-y-14">
<MyRegistrationStatus />
{/* 是什么 / 不是什么 - 小红书风格 */}
<section>
<div className="rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 p-4 sm:p-6 overflow-hidden">
<div className="grid sm:grid-cols-2 gap-6 sm:gap-8">
<div>
<h3 className="text-base sm:text-lg font-semibold text-stone-800 dark:text-stone-100 mb-3 flex items-center gap-2">
<Check className="size-4 sm:size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden /> ·
</h3>
<ul className="space-y-1.5 text-sm text-stone-600 dark:text-stone-400">
<li>· loft民宿见</li>
<li>· 48 </li>
<li>· OK</li>
</ul>
</div>
<div>
<h3 className="text-base sm:text-lg font-semibold text-stone-800 dark:text-stone-100 mb-3 flex items-center gap-2">
<X className="size-4 sm:size-5 text-stone-400 dark:text-stone-500 shrink-0" aria-hidden /> ·
</h3>
<ul className="space-y-1.5 text-sm text-stone-600 dark:text-stone-400">
<li>· </li>
<li>· 线 pass</li>
<li>· pass</li>
</ul>
</div>
</div>
</div>
</section>
{/* 报名流程 */}
<section>
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<ClipboardList className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 p-4 sm:p-6 md:p-8">
<div className="grid grid-cols-3 gap-3 sm:gap-6 md:gap-8">
<div className="flex flex-col items-center text-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 font-bold text-sm sm:text-lg flex items-center justify-center mb-2 sm:mb-3">1</div>
<h4 className="font-semibold text-stone-800 dark:text-stone-100 text-xs sm:text-base"></h4>
</div>
<div className="flex flex-col items-center text-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 font-bold text-sm sm:text-lg flex items-center justify-center mb-2 sm:mb-3">2</div>
<h4 className="font-semibold text-stone-800 dark:text-stone-100 text-xs sm:text-base"></h4>
</div>
<div className="flex flex-col items-center text-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 font-bold text-sm sm:text-lg flex items-center justify-center mb-2 sm:mb-3">3</div>
<h4 className="font-semibold text-stone-800 dark:text-stone-100 text-xs sm:text-base leading-tight"></h4>
</div>
</div>
</div>
</section>
{/* 信任标签 */}
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1.5 sm:gap-x-8 text-xs sm:text-sm text-stone-600 dark:text-stone-400">
<span>📍 </span>
<span>👥 48 </span>
<span> </span>
<span> </span>
</div>
{/* 本周场次 */}
<section>
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<Calendar className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="grid gap-3 sm:gap-4 sm:grid-cols-2 animate-fade-in-stagger">
{getSessionsWithDates().map((s, i) => (
<SessionCard key={s.id} session={s} index={i} stats={stats?.[s.id] ?? null} />
))}
</div>
</section>
{/* 活动亮点 */}
<section>
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<Lightbulb className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 p-4 sm:p-6 md:p-8">
<div className="grid sm:grid-cols-2 gap-4 sm:gap-6">
{[
{ icon: "😌", title: "氛围轻松", desc: "loft民宿里的小聚不用尬聊聊得来就多聊" },
{ icon: "🛡️", title: "安全靠谱", desc: "公开场地,地址提前说。尊重边界,不追问隐私。" },
{ icon: "👍", title: "第一次也 OK", desc: "很多人都是第一次来,小圈子人数可控~" },
{ icon: "✍️", title: "填表就行", desc: "不用注册、不用先付,填个表就好。" },
].map((item) => (
<div key={item.title} className="flex gap-2.5 sm:gap-3">
<span className="text-xl sm:text-2xl shrink-0">{item.icon}</span>
<div>
<p className="font-medium text-stone-800 dark:text-stone-100 text-sm sm:text-base">{item.title}</p>
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-400 mt-0.5 leading-relaxed">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* 发起人 + 志愿者(有志愿者时替换 host-2 卡片) */}
<section>
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<Users className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4 mb-4">
{HOSTS.map((host) => {
const isHost2 = host.id === "host-2";
const displayHost = isHost2 && volunteer
? {
...host,
name: volunteer.nickname,
avatar: volunteer.avatar || host.avatar,
bio: VOLUNTEER_BIO,
link: volunteer.xiaohongshu_url || host.link,
}
: host;
return (
<HostLink
key={host.id}
host={displayHost}
{...(isHost2 && !volunteer && {
ctaText: "立即申请",
showFreeBadge: true,
})}
/>
);
})}
</div>
</section>
{/* FAQ */}
<section id="faq">
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 dark:text-stone-100 mb-4 sm:mb-5 flex items-center gap-2">
<HelpCircle className="size-5 text-amber-600 dark:text-amber-400 shrink-0" aria-hidden />
</h2>
<div className="space-y-2 sm:space-y-3">
{[
{ q: "提交后多久会收到确认?", a: "活动前 12 天会联系,微信/短信确认。不匹配不会反复打扰~", icon: "⏱️" },
{ q: "第一次参加可以吗?", a: "可以呀很多人都是第一次来。48 人小圈子、公开场地,聊得来就多聊~", icon: "✨" },
{ q: "临时有事怎么办?", a: "随时可以退出,提前说一声就行~", icon: "🔄" },
].map((faq) => (
<details
key={faq.q}
className="group rounded-lg sm:rounded-xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 overflow-hidden"
>
<summary className="flex items-center gap-2.5 sm:gap-3 p-3 sm:p-4 cursor-pointer list-none [&::-webkit-details-marker]:hidden select-none">
<span className="text-base sm:text-lg shrink-0">{faq.icon}</span>
<p className="font-medium text-stone-800 dark:text-stone-100 text-xs sm:text-sm flex-1">{faq.q}</p>
<span className="shrink-0 text-stone-400 text-xs transition-transform duration-200 group-open:rotate-180"></span>
</summary>
<div className="px-3 pb-3 sm:px-4 sm:pb-4 pt-0 flex gap-2.5 sm:gap-3">
<span className="w-[1.25rem] sm:w-[1.5rem] shrink-0" aria-hidden />
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-400 leading-relaxed">{faq.a}</p>
</div>
</details>
))}
</div>
</section>
{/* CTA - 移动端隐藏,只保留底部悬浮去报名 */}
<section className="pt-2 sm:pt-4 hidden sm:block">
<Link
href="/join"
className="block w-full min-w-[200px] bg-amber-600 hover:bg-amber-700 text-white text-center py-3.5 sm:py-4 rounded-xl font-semibold text-sm sm:text-base shadow-lg shadow-amber-900/20 transition-all"
>
</Link>
<p className="mt-3 text-center text-xs sm:text-sm text-stone-500 dark:text-stone-400">
</p>
</section>
</main>
{/* 移动端悬浮 CTA */}
<div className="fixed bottom-0 left-0 right-0 p-3 sm:p-4 bg-white/95 dark:bg-stone-900/95 backdrop-blur border-t border-stone-200 dark:border-stone-800 sm:hidden z-50 safe-area-pb">
<Link
href="/join"
className="block w-full bg-amber-600 hover:bg-amber-700 text-white text-center py-3 rounded-xl font-medium text-sm"
>
</Link>
</div>
<Footer />
</div>
);
}