This commit is contained in:
eric
2026-03-16 09:32:35 -05:00
parent c54c236d1b
commit 14b0cd2429
21 changed files with 1058 additions and 706 deletions

View File

@@ -0,0 +1,214 @@
"use client";
import { useEffect, useState } from "react";
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";
interface HomeContentProps {
initialVolunteer: { nickname: string; avatar?: string; xiaohongshu_url?: string } | null;
}
const VOLUNTEER_BIO = "签到、茶歇准备、拍照摄像。";
export default function HomeContent({ initialVolunteer }: HomeContentProps) {
const [volunteer, setVolunteer] = useState(initialVolunteer);
useEffect(() => {
fetch("/api/join/volunteers")
.then((r) => r.json())
.then((d) => d?.volunteer && setVolunteer(d.volunteer))
.catch(() => {});
}, []);
return (
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950 pb-20 sm:pb-12">
<Header />
<HeroSection />
<main className="max-w-[900px] 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">
<span className="text-amber-600 dark:text-amber-400"></span> ·
</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">
<span className="text-stone-400 dark:text-stone-500"></span> ·
</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">
<span>📋</span>
</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">
<span>📅</span>
</h2>
<div className="grid gap-3 sm:gap-4 sm:grid-cols-2">
{getSessionsWithDates().map((s, i) => (
<SessionCard key={s.id} session={s} index={i} />
))}
</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">
<span>💡</span>
</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">
<span>👤</span>
</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 }
: host;
return (
<HostLink
key={host.id}
host={displayHost}
noLink
{...(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">
<span></span>
</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>
);
}

View File

@@ -8,14 +8,20 @@ interface HostLinkProps {
/** 志愿者招募:无审核通过时显示「立即申请」+ 免费标识 */
ctaText?: string;
showFreeBadge?: boolean;
/** 卡片有 link 样式但不可点击跳转 */
noLink?: boolean;
}
/** 移动端优先尝试唤醒小红书 appPC 新窗口打开 */
export default function HostLink({ host, ctaText, showFreeBadge }: HostLinkProps) {
/** 移动端优先尝试唤醒小红书 appPC 新窗口打开。noLink 时仅展示,不跳转 */
export default function HostLink({ host, ctaText, showFreeBadge, noLink }: HostLinkProps) {
const href = "link" in host ? host.link : `/host/${host.id}`;
const isXiaohongshu = typeof href === "string" && (href.includes("xhslink.com") || href.includes("xiaohongshu.com"));
const handleClick = (e: React.MouseEvent) => {
if (noLink) {
e.preventDefault();
return;
}
if (!isXiaohongshu || typeof href !== "string") return;
const isMobile = /Android|iPhone|iPad|iPod|webOS|Mobile/i.test(navigator.userAgent);
if (isMobile) {
@@ -27,14 +33,9 @@ export default function HostLink({ host, ctaText, showFreeBadge }: HostLinkProps
if (typeof href !== "string") return null;
return (
<Link
href={href}
target={href.startsWith("http") ? "_blank" : undefined}
rel={href.startsWith("http") ? "noopener noreferrer" : undefined}
onClick={handleClick}
className="flex gap-3 sm:gap-4 p-4 sm:p-5 rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 hover:border-amber-200 dark:hover:border-amber-900/50 hover:shadow-lg transition-all"
>
const cardClassName = "flex gap-3 sm:gap-4 p-4 sm:p-5 rounded-xl sm:rounded-2xl bg-white dark:bg-stone-900/80 border border-stone-200/80 dark:border-stone-700/80 hover:border-amber-200 dark:hover:border-amber-900/50 hover:shadow-lg transition-all";
const cardContent = (
<>
<div className="relative w-14 h-14 sm:w-16 sm:h-16 shrink-0 rounded-full overflow-hidden border-2 border-amber-100 dark:border-amber-900/30">
<Image src={host.avatar} alt={host.name} fill sizes="64px" className="object-cover" unoptimized />
</div>
@@ -52,6 +53,26 @@ export default function HostLink({ host, ctaText, showFreeBadge }: HostLinkProps
{ctaText ?? "查看主页"}
</span>
</div>
</>
);
if (noLink) {
return (
<div className={`${cardClassName} cursor-default`}>
{cardContent}
</div>
);
}
return (
<Link
href={href}
target={href.startsWith("http") ? "_blank" : undefined}
rel={href.startsWith("http") ? "noopener noreferrer" : undefined}
onClick={handleClick}
className={cardClassName}
>
{cardContent}
</Link>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
const WECHAT_STORAGE_KEY = "salon_join_wechat";
interface RecordData {
id?: string;
wechatId?: string;
nickname?: string;
media?: string;
is_volunteer?: boolean;
}
interface UserRecord {
hasRecord: boolean;
record: RecordData | null;
isComplete: boolean;
isApproved: boolean;
isRejected: boolean;
vip: boolean;
}
function getStatusLabel(data: UserRecord): string {
if (data.isComplete || (data.vip && data.hasRecord)) return "报名成功";
if (data.isRejected) return "审核拒绝";
if (data.isApproved) return "审核通过";
return "审核中";
}
function getStatusColor(status: string): string {
if (status === "报名成功") return "bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300";
if (status === "审核通过") return "bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400";
if (status === "审核拒绝") return "bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400";
return "bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400";
}
export default function MyRegistrationStatus() {
const [data, setData] = useState<UserRecord | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = () => {
if (typeof window === "undefined") return;
try {
const stored = localStorage.getItem(WECHAT_STORAGE_KEY);
const wechatId = (stored || "").trim();
if (!wechatId) {
setLoading(false);
setData(null);
return;
}
setLoading(true);
fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(wechatId)}`)
.then((r) => r.json())
.then((d) => {
if (d?.hasRecord) {
const rec = d.record ?? null;
setData({
hasRecord: !!d.hasRecord,
record: rec ? { ...rec, wechatId: rec.wechatId || wechatId } : null,
isComplete: !!d.isComplete,
isApproved: !!d.isApproved,
isRejected: !!d.isRejected,
vip: !!d.vip,
});
} else {
setData(null);
}
})
.catch(() => setData(null))
.finally(() => setLoading(false));
} catch {
setLoading(false);
}
};
useEffect(() => {
if (typeof window === "undefined") return;
let cancelled = false;
fetchStatus();
const onUpdate = () => {
if (!cancelled) fetchStatus();
};
window.addEventListener("vip:updated", onUpdate);
return () => {
cancelled = true;
window.removeEventListener("vip:updated", onUpdate);
};
}, []);
if (loading || !data?.hasRecord || !data.record) return null;
const rec = data.record;
const status = getStatusLabel(data);
const statusColor = getStatusColor(status);
return (
<section className="mb-8 sm:mb-10">
<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">
<h3 className="text-sm font-medium text-stone-500 dark:text-stone-400 mb-3 flex items-center gap-2">
<span>👤</span>
</h3>
<div className="flex items-center gap-3 sm:gap-4">
<div className="relative shrink-0">
<div className="relative w-12 h-12 sm:w-14 sm:h-14 rounded-full overflow-hidden border-2 border-stone-200 dark:border-stone-600">
{rec.media ? (
<Image
src={rec.media}
alt=""
fill
sizes="56px"
className="object-cover"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-amber-100 dark:bg-amber-900/40 text-xl sm:text-2xl">
👤
</div>
)}
</div>
{rec.is_volunteer && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500 text-white whitespace-nowrap">
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-stone-800 dark:text-stone-100 truncate">
{rec.nickname || "—"}
</p>
{rec.wechatId && (
<p className="text-xs text-stone-500 dark:text-stone-400 font-mono truncate mt-0.5">
: {rec.wechatId}
</p>
)}
<span
className={`inline-block mt-2 px-2.5 py-1 rounded-lg text-xs font-medium ${statusColor}`}
>
{status}
</span>
</div>
<Link
href="/join"
className="shrink-0 text-sm font-medium text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300"
>
</Link>
</div>
</div>
</section>
);
}