This commit is contained in:
eric
2026-03-16 20:08:21 -05:00
parent e30d4ad950
commit c93eb7b2ee
11 changed files with 314 additions and 211 deletions

View File

@@ -9,8 +9,10 @@ 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";
const WECHAT_STORAGE_KEY = "salon_join_wechat";
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;
@@ -33,15 +35,12 @@ function getPendingOrderId(): string | null {
}
}
function clearPendingOrder(): void {
try {
localStorage.removeItem("join_pay_order");
document.cookie = "join_pay_order=; path=/; max-age=0";
} catch {}
}
export default function HomeContent({ initialVolunteer }: HomeContentProps) {
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")
@@ -50,55 +49,18 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
.catch(() => {});
}, []);
// 支付完成后再次打开首页后台完成订单并刷新用户信息手机浏览器唤醒微信支付后用户可能直接回首页notify 可能未到,需轮询)
useEffect(() => {
const orderId = getPendingOrderId();
if (!orderId) return;
let cancelled = false;
const tryComplete = async (): Promise<boolean> => {
const r = await fetch("/api/salon/complete-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
credentials: "include",
});
const d = await r.json();
if (!d?.ok) return false;
if (d?.token && d?.record) {
fetch("/api/auth/sync-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: d.token, record: d.record }),
credentials: "include",
}).catch(() => {});
import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(d.token, d.record));
if (d.record?.wechatId) {
try {
localStorage.setItem(WECHAT_STORAGE_KEY, d.record.wechatId);
} catch {}
}
}
clearPendingOrder();
window.dispatchEvent(new CustomEvent("auth:updated"));
window.dispatchEvent(new CustomEvent("vip:updated"));
return true;
};
const run = async () => {
const retryDelays = [1500, 2500, 3500, 4500, 5500];
for (let i = 0; i <= retryDelays.length; i++) {
if (cancelled) return;
if (await tryComplete()) return;
if (i < retryDelays.length && !cancelled) {
await new Promise((r) => setTimeout(r, retryDelays[i]));
}
}
};
void run();
return () => { cancelled = true; };
fetch("/api/join/stats")
.then((r) => r.json())
.then((d) => d?.stats && setStats(d.stats))
.catch(() => {});
}, []);
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 />
@@ -112,7 +74,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
<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> ·
<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>
@@ -122,7 +84,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
</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> ·
<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>
@@ -137,7 +99,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* 报名流程 */}
<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>
<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">
@@ -168,11 +130,11 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* 本周场次 */}
<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>
<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">
<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} />
<SessionCard key={s.id} session={s} index={i} stats={stats?.[s.id] ?? null} />
))}
</div>
</section>
@@ -180,7 +142,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* 活动亮点 */}
<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>
<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">
@@ -205,7 +167,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* 发起人 + 志愿者(有志愿者时替换 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>
<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) => {
@@ -236,7 +198,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {
{/* 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>
<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">
{[

View File

@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { RefreshCw, Coffee } from "lucide-react";
import SessionCoverImage from "./SessionCoverImage";
import AddressLink from "./AddressLink";
import { getSessionsWithDates, ACTIVITY_TYPES, SESSION_TAGS, RECENT_JOINERS } from "@/config/home.config";
@@ -31,23 +31,17 @@ interface SessionItem {
export default function SessionCard({
session,
index,
stats,
}: {
session: SessionItem;
index: number;
stats: JoinStats | null;
}) {
const [stats, setStats] = useState<JoinStats | null>(null);
useEffect(() => {
fetch("/api/join/stats")
.then((r) => r.json())
.then((d) => d?.stats?.[session.id] && setStats(d.stats[session.id]))
.catch(() => {});
}, [session.id]);
return (
<Link
href={`/join?session=${encodeURIComponent(session.id)}`}
className="group flex flex-col sm:flex-row 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 hover:shadow-amber-900/5 transition-all"
className="group flex flex-col sm:flex-row 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 hover:shadow-amber-900/5 hover:-translate-y-0.5 transition-all duration-200 ease-out"
>
<div className="relative w-full sm:w-32 sm:min-w-[128px] shrink-0 aspect-[16/10] rounded-lg sm:rounded-xl overflow-hidden bg-stone-100 dark:bg-stone-800">
{session.coverImage ? (
@@ -60,8 +54,8 @@ export default function SessionCard({
className="object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-2xl">
{session.type === "skill-exchange" ? "🔄" : "☕"}
<div className="absolute inset-0 flex items-center justify-center text-amber-500/70 dark:text-amber-400/50">
{session.type === "skill-exchange" ? <RefreshCw className="size-8" aria-hidden /> : <Coffee className="size-8" aria-hidden />}
</div>
)}
</div>