From c93eb7b2ee1e27654f7d5aa80030dac6713b8308 Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 16 Mar 2026 20:08:21 -0500 Subject: [PATCH] 's' --- app/api/join/stats/route.ts | 60 ++++++----- app/components/HomeContent.tsx | 88 +++++---------- app/components/SessionCard.tsx | 18 ++-- app/globals.css | 25 ++++- app/hooks/useCompleteOrderPolling.ts | 64 +++++++++++ app/join/page.tsx | 154 +++++++++++++++------------ app/join/paid/page.tsx | 43 ++------ app/lib/complete-order.ts | 50 +++++++++ app/page.tsx | 10 +- package.json | 1 + pnpm-lock.yaml | 12 +++ 11 files changed, 314 insertions(+), 211 deletions(-) create mode 100644 app/hooks/useCompleteOrderPolling.ts create mode 100644 app/lib/complete-order.ts diff --git a/app/api/join/stats/route.ts b/app/api/join/stats/route.ts index 0cba2df..8019f3a 100644 --- a/app/api/join/stats/route.ts +++ b/app/api/join/stats/route.ts @@ -18,34 +18,40 @@ export async function GET(request: NextRequest) { const stats: Record = {}; - for (const s of SESSIONS) { - try { - const filter = encodeURIComponent(`type="${s.id}"`); - const res = await fetch( - `${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=6`, - { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - } - ); - if (!res.ok) { - stats[s.id] = { count: 0, joiners: [] }; - continue; + const results = await Promise.all( + SESSIONS.map(async (s) => { + try { + const filter = encodeURIComponent(`type="${s.id}"`); + const res = await fetch( + `${base}/api/collections/${pb.joinCollection}/records?filter=${filter}&sort=-created&perPage=6`, + { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + } + ); + if (!res.ok) return { id: s.id, stat: { count: 0, joiners: [] } }; + const data = await res.json(); + const items = Array.isArray(data?.items) ? data.items : []; + const total = typeof data?.totalItems === "number" ? data.totalItems : items.length; + return { + id: s.id, + stat: { + count: total, + joiners: items.slice(0, 6).map((r: { nickname?: string; xiaohongshu_nickname?: string; xiaohongshu_avatar?: string; xiaohongshu_url?: string; media?: string }) => ({ + nickname: String(r?.xiaohongshu_nickname || r?.nickname || "").trim() || "匿名", + avatar: String(r?.xiaohongshu_avatar || r?.media || "").trim() || undefined, + xiaohongshu_url: String(r?.xiaohongshu_url || "").trim() || undefined, + })), + }, + }; + } catch { + return { id: s.id, stat: { count: 0, joiners: [] } }; } - const data = await res.json(); - const items = Array.isArray(data?.items) ? data.items : []; - const total = typeof data?.totalItems === "number" ? data.totalItems : items.length; - stats[s.id] = { - count: total, - joiners: items.slice(0, 6).map((r: { nickname?: string; xiaohongshu_nickname?: string; xiaohongshu_avatar?: string; xiaohongshu_url?: string; media?: string }) => ({ - nickname: String(r?.xiaohongshu_nickname || r?.nickname || "").trim() || "匿名", - avatar: String(r?.xiaohongshu_avatar || r?.media || "").trim() || undefined, - xiaohongshu_url: String(r?.xiaohongshu_url || "").trim() || undefined, - })), - }; - } catch { - stats[s.id] = { count: 0, joiners: [] }; - } + }) + ); + + for (const { id, stat } of results) { + stats[id] = stat; } return NextResponse.json({ ok: true, stats }); diff --git a/app/components/HomeContent.tsx b/app/components/HomeContent.tsx index 34d4299..f7d9c76 100644 --- a/app/components/HomeContent.tsx +++ b/app/components/HomeContent.tsx @@ -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; 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(null); + const [pendingOrderId] = useState(() => + 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 => { - 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 (
@@ -112,7 +74,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {

- 是什么 · 适合谁 + 是什么 · 适合谁

  • · 周末同城轻社交,loft民宿见~
  • @@ -122,7 +84,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) {

- 不是什么 · 不适合谁 + 不是什么 · 不适合谁

  • · 不是相亲局、卖课、强制破冰
  • @@ -137,7 +99,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) { {/* 报名流程 */}

    - 📋 怎么参加 + 怎么参加

    @@ -168,11 +130,11 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) { {/* 本周场次 */}

    - 📅 本周活动 + 本周活动

    -
    +
    {getSessionsWithDates().map((s, i) => ( - + ))}
    @@ -180,7 +142,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) { {/* 活动亮点 */}

    - 💡 活动亮点 + 活动亮点

    @@ -205,7 +167,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) { {/* 发起人 + 志愿者(有志愿者时替换 host-2 卡片) */}

    - 👤 发起人 + 发起人

    {HOSTS.map((host) => { @@ -236,7 +198,7 @@ export default function HomeContent({ initialVolunteer }: HomeContentProps) { {/* FAQ */}

    - 常见问题 + 常见问题

    {[ diff --git a/app/components/SessionCard.tsx b/app/components/SessionCard.tsx index f3e15e6..5c6d67b 100644 --- a/app/components/SessionCard.tsx +++ b/app/components/SessionCard.tsx @@ -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(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 (
    {session.coverImage ? ( @@ -60,8 +54,8 @@ export default function SessionCard({ className="object-cover" /> ) : ( -
    - {session.type === "skill-exchange" ? "🔄" : "☕"} +
    + {session.type === "skill-exchange" ? : }
    )}
    diff --git a/app/globals.css b/app/globals.css index c2a2394..e166258 100644 --- a/app/globals.css +++ b/app/globals.css @@ -134,5 +134,28 @@ body { } .animate-fade-in { - animation: fadeIn 0.4s ease-out forwards; + animation: fadeIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +/* 卡片依次淡入,用于列表 */ +.animate-fade-in-stagger > * { + opacity: 0; + animation: fadeIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.animate-fade-in-stagger > *:nth-child(1) { animation-delay: 0ms; } +.animate-fade-in-stagger > *:nth-child(2) { animation-delay: 80ms; } +.animate-fade-in-stagger > *:nth-child(3) { animation-delay: 160ms; } +.animate-fade-in-stagger > *:nth-child(4) { animation-delay: 240ms; } +.animate-fade-in-stagger > *:nth-child(5) { animation-delay: 320ms; } +.animate-fade-in-stagger > *:nth-child(6) { animation-delay: 400ms; } + +/* 骨架屏脉冲 */ +@keyframes pulse-subtle { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} + +.animate-pulse-subtle { + animation: pulse-subtle 1.5s ease-in-out infinite; } diff --git a/app/hooks/useCompleteOrderPolling.ts b/app/hooks/useCompleteOrderPolling.ts new file mode 100644 index 0000000..f0980c8 --- /dev/null +++ b/app/hooks/useCompleteOrderPolling.ts @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { tryCompleteOrder, applyCompleteSuccess } from "@/app/lib/complete-order"; + +const DEFAULT_RETRY_DELAYS = [1500, 2500, 3500, 4500, 5500]; + +export interface UseCompleteOrderPollingOptions { + /** 待完成订单 ID,null 则不轮询 */ + orderId: string | null; + /** 成功后的额外回调(用 ref 存储避免 deps 变化) */ + onSuccess?: (data: { token: string; record?: { wechatId?: string } }) => void; + /** 重试间隔(毫秒) */ + retryDelays?: number[]; +} + +/** 清除 pending order 的 cookie 和 localStorage */ +export function clearPendingOrder(): void { + try { + localStorage.removeItem("join_pay_order"); + } catch { + /* ignore */ + } + document.cookie = "join_pay_order=; path=/; max-age=0"; +} + +/** 支付完成后轮询 complete-order,成功后同步 session 并清除 pending */ +export function useCompleteOrderPolling({ + orderId, + onSuccess, + retryDelays = DEFAULT_RETRY_DELAYS, +}: UseCompleteOrderPollingOptions): void { + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + + useEffect(() => { + if (!orderId || typeof window === "undefined") return; + + let cancelled = false; + const run = async () => { + for (let i = 0; i <= retryDelays.length; i++) { + if (cancelled) return; + const result = await tryCompleteOrder(orderId); + if (result.ok) { + if (result.token && result.record) { + applyCompleteSuccess({ token: result.token, record: result.record }); + } else if (result.record) { + applyCompleteSuccess({ token: "", record: result.record }); + } + clearPendingOrder(); + onSuccessRef.current?.({ token: result.token || "", record: result.record }); + return; + } + if (i < retryDelays.length && !cancelled) { + await new Promise((r) => setTimeout(r, retryDelays[i])); + } + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [orderId, retryDelays]); +} diff --git a/app/join/page.tsx b/app/join/page.tsx index fd7b44c..6ba8057 100644 --- a/app/join/page.tsx +++ b/app/join/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useRef, type FormEvent } from "react"; +import { useCompleteOrderPolling } from "@/app/hooks/useCompleteOrderPolling"; import Link from "next/link"; import ThemeToggle from "../components/ThemeToggle"; import { DIGITAL_NOMAD_WECHAT_QR } from "@/config/home.config"; @@ -50,13 +51,6 @@ function getPendingOrderId(): string | null { } } -function clearPendingOrder(): void { - try { - localStorage.removeItem(ORDER_COOKIE); - document.cookie = `${ORDER_COOKIE}=; path=/; max-age=0`; - } catch {} -} - interface UserRecord { hasRecord: boolean; record: { @@ -165,6 +159,7 @@ export default function JoinPage() { const initialWechatCheckDone = useRef(false); const lastSubmitTime = useRef(0); const submittingRef = useRef(false); + const fetchByWechatRef = useRef<(w: string) => Promise>(() => Promise.resolve(null)); const SUBMIT_THROTTLE_MS = 3000; const fetchByWechat = async (w: string) => { @@ -186,6 +181,7 @@ export default function JoinPage() { setCheckingWechat(false); } }; + fetchByWechatRef.current = fetchByWechat; const handleWechatBlur = () => { const w = wechat.trim(); @@ -435,14 +431,54 @@ export default function JoinPage() { const params = new URLSearchParams(window.location.search); setFromPaid(params.get("paid") === "1"); setFromVolunteer(params.get("volunteer") === "1"); + + const pendingId = getPendingOrderId(); + const stored = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim(); + + // 有 pending 时先查 by-wechat:若已支付(vip/amount)则直接展示报名成功,不跳转 /join/paid + if (pendingId && !params.has("paid") && stored && WECHAT_REGEX.test(stored)) { + setWechat(stored); + let cancelled = false; + fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(stored)}`, { cache: "no-store" }) + .then((res) => res.json()) + .then((data) => { + if (cancelled) return; + const isPaid = data?.vip || data?.isComplete || (data?.record && Number(data.record?.amount || 0) > 0); + if (isPaid) { + setWechat(stored); + setUserRecord(toUserRecord(data)); + if (!initialWechatCheckDone.current) { + initialWechatCheckDone.current = true; + } + setHydrated(true); + return; + } + window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`); + }) + .catch(() => { + if (!cancelled) window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`); + }); + setHydrated(true); + return () => { + cancelled = true; + }; + } + + if (pendingId && !params.has("paid")) { + try { + window.location.replace(`/join/paid?order_id=${encodeURIComponent(pendingId)}`); + return; + } catch { + /* ignore */ + } + } + if (!initialWechatCheckDone.current) { initialWechatCheckDone.current = true; try { - const stored = localStorage.getItem(WECHAT_STORAGE_KEY); - const w = (stored || "").trim(); - if (w && WECHAT_REGEX.test(w)) { - setWechat(w); - fetchByWechat(w); + if (stored && WECHAT_REGEX.test(stored)) { + setWechat(stored); + fetchByWechat(stored); } } catch { /* ignore */ @@ -451,65 +487,42 @@ export default function JoinPage() { setHydrated(true); }, []); - // 手机浏览器支付后若回到 /join:有 pending order 时主动轮询 complete-order,触发后端更新 solanRed(ZPAY notify 可能未到) useEffect(() => { if (typeof window === "undefined") return; - const orderId = getPendingOrderId(); - if (!orderId) return; - const shouldComplete = - userRecord?.hasRecord && - userRecord?.isWechatFriend && - !userRecord?.vip && - !userRecord?.isComplete; - if (!shouldComplete) return; - - let cancelled = false; - const tryComplete = async (): Promise => { - 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)); - } - 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()) { - if (wechat.trim()) fetchByWechat(wechat.trim()); - return; - } - if (i < retryDelays.length && !cancelled) { - await new Promise((r) => setTimeout(r, retryDelays[i])); - } + const refreshOnReturn = () => { + try { + const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim(); + if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w); + } catch { + /* ignore */ } }; - void run(); - return () => { cancelled = true; }; - }, [ - userRecord?.hasRecord, - userRecord?.isWechatFriend, - userRecord?.vip, - userRecord?.isComplete, - wechat, - ]); + const onVisibilityChange = () => { + if (document.visibilityState === "visible") refreshOnReturn(); + }; + const onPageShow = (e: PageTransitionEvent) => { + if (e.persisted) refreshOnReturn(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("pageshow", onPageShow); + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("pageshow", onPageShow); + }; + }, []); + + const [completeOrderId] = useState(() => + typeof window !== "undefined" ? getPendingOrderId() : null + ); + + useCompleteOrderPolling({ + orderId: completeOrderId, + onSuccess: () => { + const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim(); + if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w); + }, + retryDelays: [500, 1000, 1500, 2500, 4000], + }); // 微信内/手机浏览器支付后:页面可能被关闭或跳转,返回时 notify 可能未到。轮询 fetchByWechat 检测支付成功 // 手机浏览器唤醒微信 app 支付完成后,用户可能直接回 /join,故 h5 也需轮询(参考 nomadvip) @@ -629,6 +642,13 @@ export default function JoinPage() { if (showPayPage) { return (
    + {paying && ( +
    + +

    正在跳转支付…

    +

    请勿关闭页面

    +
    + )}
    ← 返回 diff --git a/app/join/paid/page.tsx b/app/join/paid/page.tsx index 13b1fdc..163c846 100644 --- a/app/join/paid/page.tsx +++ b/app/join/paid/page.tsx @@ -2,16 +2,11 @@ import { useState, useEffect } from "react"; import Link from "next/link"; +import { tryCompleteOrder, applyCompleteSuccess } from "@/app/lib/complete-order"; +import { clearPendingOrder } from "@/app/hooks/useCompleteOrderPolling"; const WECHAT_STORAGE_KEY = "salon_join_wechat"; -function clearPendingOrder(): void { - try { - localStorage.removeItem("join_pay_order"); - } catch {} - document.cookie = "join_pay_order=; path=/; max-age=0"; -} - export default function JoinPaidPage() { const [completeStatus, setCompleteStatus] = useState<"pending" | "success" | "error">("pending"); const [errorDetail, setErrorDetail] = useState(null); @@ -21,31 +16,6 @@ export default function JoinPaidPage() { useEffect(() => { let cancelled = false; - const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string; record?: { wechatId?: string } }> => { - 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 { ok: false, error: d?.error || d?.detail || `HTTP ${r.status}` }; - - if (d?.token && d?.record) { - await fetch("/api/auth/sync-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: d.token, record: d.record }), - credentials: "include", - }); - const { pbSaveAuth } = await import("@/app/lib/pocketbase"); - pbSaveAuth(d.token, d.record); - } - window.dispatchEvent(new CustomEvent("auth:updated")); - window.dispatchEvent(new CustomEvent("vip:updated")); - return { ok: true, record: d?.record }; - }; - const run = async () => { const urlOrderId = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("order_id") || "" @@ -68,13 +38,18 @@ export default function JoinPaidPage() { } const maxClientRetries = 5; - const retryDelays = [2000, 3000, 4000, 5000, 6000]; + const retryDelays = [800, 1500, 2500, 4000, 6000]; let lastError = ""; for (let i = 0; i <= maxClientRetries; i++) { if (cancelled) return; try { - const result = await tryComplete(orderId); + const result = await tryCompleteOrder(orderId); if (result.ok) { + if (result.token && result.record) { + applyCompleteSuccess({ token: result.token, record: result.record }); + } else if (result.record) { + applyCompleteSuccess({ token: "", record: result.record }); + } clearPendingOrder(); if (!cancelled) { setCompleteStatus("success"); diff --git a/app/lib/complete-order.ts b/app/lib/complete-order.ts new file mode 100644 index 0000000..2ac2dd1 --- /dev/null +++ b/app/lib/complete-order.ts @@ -0,0 +1,50 @@ +/** 共享:完成订单 API 调用与成功后的 session 同步 */ + +import type { AuthUser } from "@/app/lib/pocketbase"; + +const WECHAT_STORAGE_KEY = "salon_join_wechat"; + +export interface CompleteOrderResult { + ok: boolean; + token?: string; + record?: { wechatId?: string; [k: string]: unknown }; + error?: string; +} + +/** 调用 complete-order API */ +export async function tryCompleteOrder(orderId: string): Promise { + 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 { ok: false, error: d?.error || d?.detail || `HTTP ${r.status}` }; + return { ok: true, token: d.token, record: d.record }; +} + +/** 成功后的通用处理:sync-session、pbSaveAuth、dispatch 事件、localStorage wechatId。token 为空时仅 dispatch 和保存 wechatId(非 pending 用户) */ +export function applyCompleteSuccess(data: { token?: string; record?: { wechatId?: string; [k: string]: unknown } }): void { + const { token, record } = data; + if (token) { + fetch("/api/auth/sync-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, record }), + credentials: "include", + }).catch(() => {}); + if (record && "id" in record && "email" in record) { + import("@/app/lib/pocketbase").then(({ pbSaveAuth }) => pbSaveAuth(token, record as AuthUser)); + } + } + window.dispatchEvent(new CustomEvent("auth:updated")); + window.dispatchEvent(new CustomEvent("vip:updated")); + if (record?.wechatId) { + try { + localStorage.setItem(WECHAT_STORAGE_KEY, record.wechatId); + } catch { + /* ignore */ + } + } +} diff --git a/app/page.tsx b/app/page.tsx index 4a872c0..8fc26d9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,6 @@ -import { getVolunteer } from "@/app/lib/volunteers"; import HomeContent from "./components/HomeContent"; -export const dynamic = "force-dynamic"; - -/** 首页:服务端先查询第一个志愿者,替换发起人志愿者卡片 */ -export default async function Home() { - const volunteer = await getVolunteer(); - return ; +/** 首页:志愿者由客户端加载,避免 SSR 阻塞首屏 */ +export default function Home() { + return ; } diff --git a/package.json b/package.json index f21f113..d8ea3e0 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "lucide-react": "^0.577.0", "next": "16.1.6", "next-themes": "^0.4.6", "pocketbase": "^0.26.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3529690..9c8918a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + lucide-react: + specifier: ^0.577.0 + version: 0.577.0(react@19.2.3) next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1466,6 +1469,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.577.0: + resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3462,6 +3470,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.577.0(react@19.2.3): + dependencies: + react: 19.2.3 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5