"use client"; import { useState, useEffect, useRef, type FormEvent } from "react"; import { usePathname } from "next/navigation"; import { useCompleteOrderPolling } from "@/app/hooks/useCompleteOrderPolling"; import Link from "next/link"; import ThemeToggle from "../components/ThemeToggle"; import { DIGITAL_NOMAD_WECHAT_QR, getSessionsWithDates } from "@/config/home.config"; import { redirectToPay, redirectToPayH5, payWechatXorpaySync, getDeviceFromEnv, getPayEnv, } from "@/app/lib/payment"; const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"]; /** 活动场次选项:周六/周日,用于 meetupdate 下拉 */ function getMeetupdateOptions(): { value: string; label: string; type: string }[] { return getSessionsWithDates().map((s) => ({ value: `${s.date} ${s.time}`, label: `${s.date} ${s.time} · ${s.title}`, type: s.id, })); } /** 微信号仅允许英文字母、数字、下划线、连字符 */ const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/; /** 从粘贴文本中提取小红书 URL(主页或笔记链接),清除前后空格 */ function extractXiaohongshuUrl(text: string): string { const trimmed = text.trim(); if (!trimmed) return ""; const urlMatch = trimmed.match(/https?:\/\/[^\s<>"']*(?:xhslink\.com|xiaohongshu\.com)[^\s<>"']*/i); if (urlMatch) { return urlMatch[0].replace(/[^\w/:?#&=.-]+$/, "").trim(); } const anyUrlMatch = trimmed.match(/https?:\/\/[^\s<>"']+/); if (anyUrlMatch) { return anyUrlMatch[0].replace(/[^\w/:?#&=.-]+$/, "").trim(); } return trimmed; } const WECHAT_STORAGE_KEY = "salon_join_wechat"; const ORDER_COOKIE = "join_pay_order"; function getPendingOrderId(): string | null { if (typeof window === "undefined") return null; try { const cookie = document.cookie.split(";").find((c) => c.trim().startsWith(`${ORDER_COOKIE}=`)); const raw = cookie?.split("=")[1]?.trim(); const decoded = raw ? decodeURIComponent(raw) : ""; const fromStorage = localStorage.getItem(ORDER_COOKIE) || ""; const value = decoded || fromStorage; const orderId = value.split("|")[0]?.trim() || ""; return orderId || null; } catch { return null; } } interface UserRecord { hasRecord: boolean; record: { id?: string; wechatId?: string; user_id?: string; nickname?: string; media?: string; amount?: number; order_id?: string; vip?: boolean; is_volunteer?: boolean; } | null; isComplete: boolean; isWechatFriend: boolean; isApproved: boolean; isRejected: boolean; vip: boolean; } const EMPTY_USER_RECORD: UserRecord = { hasRecord: false, record: null, isComplete: false, isWechatFriend: false, isApproved: false, isRejected: false, vip: false, }; function toUserRecord(data: Partial & { record?: UserRecord["record"] }): UserRecord { return { hasRecord: !!data.hasRecord, record: data.record ?? null, isComplete: !!data.isComplete, isWechatFriend: !!data.isWechatFriend, isApproved: !!data.isApproved, isRejected: !!data.isRejected, vip: !!data.vip, }; } function UserProfileCard({ record, showVolunteerTag }: { record: UserRecord["record"]; showVolunteerTag?: boolean }) { if (!record || (!record.nickname && !record.media && !record.wechatId)) return null; const isVolunteer = showVolunteerTag && record.is_volunteer; return (
{record.media ? ( ) : (
👤
)} {isVolunteer && ( 志愿者 )}

{record.nickname || "—"}

{record.wechatId && (

微信号: {record.wechatId}

)}
); } export default function JoinPage() { const [wechat, setWechat] = useState(""); const [userRecord, setUserRecord] = useState(null); const [checkingWechat, setCheckingWechat] = useState(false); const [xiaohongshuUrl, setXiaohongshuUrl] = useState(""); const [intro, setIntro] = useState(""); const [profession, setProfession] = useState(""); const [gender, setGender] = useState(""); const [ageRange, setAgeRange] = useState(""); const meetupdateOptions = getMeetupdateOptions(); const [meetupdate, setMeetupdate] = useState(() => { const opts = getMeetupdateOptions(); return opts[0]?.value ?? ""; }); const [agreeRules, setAgreeRules] = useState(true); const [urlError, setUrlError] = useState(null); const [profile, setProfile] = useState<{ nickname: string; gender?: string; postCount: number; postCountText?: string; ipLocation?: string; avatarUrl?: string; resolvedUrl?: string; followers?: number; followersText?: string; following?: number; followingText?: string; likesAndCollects?: number; likesAndCollectsText?: string; redbookId?: string; personalLink?: string; } | null>(null); const [validating, setValidating] = useState(false); const [submitted, setSubmitted] = useState(false); const [submitting, setSubmitting] = useState(false); const [qualify, setQualify] = useState(false); const [error, setError] = useState(null); const [hydrated, setHydrated] = useState(false); const [fromPaid, setFromPaid] = useState(false); const [fromVolunteer, setFromVolunteer] = useState(false); const [paying, setPaying] = useState(false); 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) => { if (!w.trim()) return null; setError(null); setCheckingWechat(true); try { const res = await fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(w.trim())}&_=${Date.now()}`, { cache: "no-store", }); const data = await res.json(); const nextRecord = toUserRecord(data); setUserRecord(nextRecord); return nextRecord; } catch { setUserRecord(EMPTY_USER_RECORD); return null; } finally { setCheckingWechat(false); } }; fetchByWechatRef.current = fetchByWechat; const handleWechatBlur = () => { const w = wechat.trim(); if (!w) { setUserRecord(null); try { typeof window !== "undefined" && localStorage.removeItem(WECHAT_STORAGE_KEY); } catch { /* ignore */ } return; } if (!WECHAT_REGEX.test(w)) { setError("微信号仅允许英文字母、数字、下划线和连字符"); return; } setError(null); try { typeof window !== "undefined" && localStorage.setItem(WECHAT_STORAGE_KEY, w); } catch { /* ignore */ } fetchByWechat(w); }; const handleUrlBlur = async () => { const extracted = extractXiaohongshuUrl(xiaohongshuUrl); if (extracted !== xiaohongshuUrl.trim()) { setXiaohongshuUrl(extracted); } const url = extracted; if (!url) { setUrlError(null); setProfile(null); return; } setValidating(true); setUrlError(null); setProfile(null); try { const validateRes = await fetch("/api/xiaohongshu/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); const validateData = await validateRes.json(); if (!validateData.valid) { setUrlError(validateData.error || "小红书链接异常"); return; } const profileRes = await fetch("/api/xiaohongshu/profile", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); const profileData = await profileRes.json(); if (profileData.ok) { setProfile({ nickname: profileData.nickname, gender: profileData.gender, postCount: profileData.postCount ?? 0, postCountText: profileData.postCountText, ipLocation: profileData.ipLocation, avatarUrl: profileData.avatarUrl, resolvedUrl: profileData.resolvedUrl, followers: profileData.followers, followersText: profileData.followersText, following: profileData.following, followingText: profileData.followingText, likesAndCollects: profileData.likesAndCollects, likesAndCollectsText: profileData.likesAndCollectsText, redbookId: profileData.redbookId, personalLink: profileData.personalLink, }); } else { setUrlError(profileData.error || "获取资料失败"); } } catch { setUrlError("网络异常,请重试"); } finally { setValidating(false); } }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(null); const now = Date.now(); if (submittingRef.current) return; if (now - lastSubmitTime.current < SUBMIT_THROTTLE_MS) return; if (userRecord?.hasRecord) { setError("该微信号已报名,请勿重复提交"); return; } if (!xiaohongshuUrl.trim()) { setError("请填写小红书主页链接或笔记链接"); return; } if (xiaohongshuUrl.trim() && urlError) { setError("请先修正小红书链接"); return; } if (!intro.trim()) { setError("请填写自我介绍"); return; } if (intro.trim().length < 20) { setError("自我介绍不少于20字"); return; } if (!profession.trim()) { setError("请填写职业"); return; } if (!wechat.trim()) { setError("请填写微信号"); return; } if (!WECHAT_REGEX.test(wechat)) { setError("微信号仅允许英文字母、数字、下划线和连字符"); return; } if (!gender) { setError("请选择性别"); return; } if (!meetupdate) { setError("请选择要参加的活动场次"); return; } submittingRef.current = true; lastSubmitTime.current = now; setSubmitting(true); try { const checkRes = await fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(wechat.trim())}&_=${Date.now()}`, { cache: "no-store" }); const checkData = await checkRes.json(); if (checkData?.hasRecord) { setError("该微信号已报名,请勿重复提交"); fetchByWechat(wechat.trim()); submittingRef.current = false; setSubmitting(false); return; } const formGenderFemale = gender === "女" || gender === "female"; const postCountOk = (profile?.postCount ?? 0) > 10; const qualify = formGenderFemale && postCountOk; const genderLabel = gender === "女" ? "女" : gender === "男" ? "男" : "无"; const submitXiaohongshuUrl = profile?.resolvedUrl || extractXiaohongshuUrl(xiaohongshuUrl) || ""; const selectedOption = meetupdateOptions.find((o) => o.value === meetupdate); const sessionType = selectedOption?.type ?? "session-1"; const res = await fetch("/api/join", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nickname: profile?.nickname || "匿名", wechat: wechat.trim(), phone: "", profession: profession.trim(), xiaohongshu_url: submitXiaohongshuUrl, xiaohongshu_avatar_url: profile?.avatarUrl || "", gender: genderLabel, intro: intro.trim(), reason: intro.trim(), userInfo: profile ? JSON.stringify({ 昵称: profile.nickname, 性别: profile.gender, 笔记数: profile.postCount, 笔记数文案: profile.postCountText, IP属地: profile.ipLocation, 头像链接: profile.avatarUrl, 主页链接: profile.resolvedUrl, 粉丝数: profile.followers, 粉丝数文案: profile.followersText, 关注数: profile.following, 关注数文案: profile.followingText, 获赞与收藏: profile.likesAndCollects, 获赞与收藏文案: profile.likesAndCollectsText, 小红书号: profile.redbookId, 个人链接: profile.personalLink, }) : "", age_range: ageRange, city: "深圳", session: sessionType, meetupdate: meetupdate.trim(), education: "", graduation_year: "", agree_rules: true, first_time: "", status: "", activity_type: "", why_join: "", region: "", available_time: "", qualify, is_volunteer: fromVolunteer, }), }); const data = await res.json(); if (!res.ok || !data?.ok) { throw new Error(data?.error || "提交失败,请稍后重试"); } setQualify(qualify); setSubmitted(true); try { localStorage.setItem(WECHAT_STORAGE_KEY, wechat.trim()); } catch { /* ignore */ } let result = await fetchByWechat(wechat.trim()); const maxRetries = 5; const retryDelayMs = 1500; for (let i = 0; i < maxRetries && !result?.hasRecord; i++) { await new Promise((r) => setTimeout(r, retryDelayMs)); result = await fetchByWechat(wechat.trim()); } if (!result?.hasRecord) { try { window.location.replace("/join"); } catch { /* ignore */ } } } catch (err) { setError(err instanceof Error ? err.message : "网络错误,请重试"); } finally { submittingRef.current = false; setSubmitting(false); } }; const clearWechatAndRecord = () => { setUserRecord(null); setWechat(""); setXiaohongshuUrl(""); setIntro(""); setProfession(""); setGender(""); setAgeRange(""); setAgreeRules(true); setUrlError(null); setProfile(null); setValidating(false); setSubmitted(false); setMeetupdate(meetupdateOptions[0]?.value ?? ""); setSubmitting(false); setQualify(false); setError(null); try { if (typeof window !== "undefined") { localStorage.removeItem(WECHAT_STORAGE_KEY); localStorage.removeItem("join_pay_order"); document.cookie = "join_pay_order=; path=/; max-age=0"; } } catch { /* ignore */ } }; const handlePayNow = async () => { const rec = userRecord?.record; if (!rec?.user_id || paying) return; setPaying(true); try { const origin = typeof window !== "undefined" ? window.location.origin : ""; const base = { user_id: rec.user_id, return_url: `${origin}/join/paid`, useJoinConfig: true, channel: "wxpay" as const, }; const device = getDeviceFromEnv(getPayEnv()); if (device === "wechat") { payWechatXorpaySync(base); } else if (device === "h5") { await redirectToPayH5(base); } else { redirectToPay({ ...base, device }); } } catch (e) { const msg = e instanceof Error ? e.message : "支付发起失败"; alert(msg.includes("abort") ? "请求超时,请检查网络后重试" : msg); } finally { setPaying(false); } }; useEffect(() => { if (typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); setFromPaid(params.get("paid") === "1"); setFromVolunteer(params.get("volunteer") === "1"); const sessionParam = params.get("session"); if (sessionParam && (sessionParam === "session-1" || sessionParam === "session-2")) { const opts = getMeetupdateOptions(); const match = opts.find((o) => o.type === sessionParam); if (match) setMeetupdate(match.value); } 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)}&_=${Date.now()}`, { 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 { if (stored && WECHAT_REGEX.test(stored)) { setWechat(stored); fetchByWechat(stored); } } catch { /* ignore */ } } setHydrated(true); }, []); useEffect(() => { if (typeof window === "undefined") return; const refreshOnReturn = () => { try { const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim(); if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w); } catch { /* ignore */ } }; 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], }); const pathname = usePathname(); // 每次进入报名页时强制重新拉取,避免预取/缓存导致显示旧状态 useEffect(() => { if (pathname === "/join") { const w = (localStorage.getItem(WECHAT_STORAGE_KEY) || "").trim(); if (w && WECHAT_REGEX.test(w)) fetchByWechatRef.current(w); } }, [pathname]); // 微信内/手机浏览器支付后:页面可能被关闭或跳转,返回时 notify 可能未到。轮询 fetchByWechat 检测支付成功 // 手机浏览器唤醒微信 app 支付完成后,用户可能直接回 /join,故 h5 也需轮询(参考 nomadvip) useEffect(() => { if (typeof window === "undefined") return; const w = wechat.trim(); if (!w || !WECHAT_REGEX.test(w)) return; const device = getDeviceFromEnv(getPayEnv()); const isWechatOrH5 = device === "wechat" || device === "h5"; if (!isWechatOrH5) return; const shouldPoll = userRecord?.hasRecord && userRecord?.isWechatFriend && !userRecord?.vip && !userRecord?.isComplete; if (!shouldPoll) return; let cancelled = false; const poll = async () => { for (let attempt = 0; attempt < 6 && !cancelled; attempt++) { await new Promise((r) => setTimeout(r, 1500)); if (cancelled) return; await fetchByWechat(w); if (cancelled) return; // fetchByWechat 会更新 userRecord,下次渲染会走 showSuccess,此处无需再判断 } }; void poll(); return () => { cancelled = true; }; }, [ wechat, userRecord?.hasRecord, userRecord?.isWechatFriend, userRecord?.vip, userRecord?.isComplete, ]); // 成功报名:已支付 或 支付成功跳转 或 (好友且vip) —— 仅以数据库状态为准,不依赖 submitted const showSuccess = fromPaid || (userRecord?.hasRecord && userRecord.isComplete) || (userRecord?.hasRecord && userRecord.isWechatFriend && userRecord.vip); // 有数据、非好友 → 只展示状态页:审核中/审核通过/审核拒绝 const showStatusPage = userRecord?.hasRecord && !userRecord.isWechatFriend; // 有数据、好友、非vip、未支付 → 支付状态页 const showPayPage = userRecord?.hasRecord && userRecord.isWechatFriend && !userRecord.vip && !userRecord.isComplete; if (showSuccess) { const baseRecord = userRecord?.record ?? null; const displayRecord = baseRecord ? { ...baseRecord, wechatId: baseRecord.wechatId || wechat || undefined } : (wechat ? { wechatId: wechat } : null); return (
🎉

报名成功

{displayRecord?.is_volunteer ? "要提前到场,金额可退" : "感谢报名,活动前会联系你确认~"}

🏠 返回首页
); } if (showStatusPage) { const status = userRecord!.isRejected ? "rejected" : userRecord!.isApproved ? "approved" : "pending"; return (
{status === "approved" ? ( <>
🎉

审核通过

扫码添加好友,加入活动群

微信二维码

📝 添加时请备注:活动报名

) : status === "rejected" ? ( <>

审核拒绝

很抱歉,本次申请未通过审核。如有疑问可联系发起人。

) : ( <>

审核中

⏰ 通常会在 24 小时内完成审核,通过后用微信联系你。

)} {status !== "approved" && ( )} 🏠 返回首页
); } if (showPayPage) { return (
{paying && (

正在跳转支付…

请勿关闭页面

)}
← 返回
💰

支付场地茶歇费

29元/人,用于覆盖民宿/场地基础成本

{userRecord?.record?.is_volunteer && (

🌿 志愿者预付活动结束可退
用于防止临时爽约

)} {paying && (
正在跳转支付,请稍候…
)}
); } if (!hydrated) { return (
加载中…
); } // 微信号输入区:失焦时查询,查询中不展示其他元素 const wechatOnlyView = !userRecord; if (wechatOnlyView) { return (
← 返回
setWechat(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""))} onBlur={handleWechatBlur} className="form-input" autoFocus />
{checkingWechat && (

🔄 查询中…

)} {error && (

⚠️ {error}

)}

填写微信号并完成查询后显示完整表单

🏠 返回首页
); } // 无记录 → 显示完整表单 return (
← 返回
{fromVolunteer && (

🙌 志愿者工作内容

  • · 签到:现场签到、引导参与者
  • · 茶歇准备:协助布置茶歇、收拾整理
  • · 拍照摄像:记录活动现场,分享精彩瞬间
)}
setWechat(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""))} onBlur={handleWechatBlur} required className="form-input" />
{ setXiaohongshuUrl(e.target.value); setUrlError(null); }} onBlur={handleUrlBlur} required className="form-input" /> {validating &&

🔄 校验中…

} {urlError && !validating &&

⚠️ {urlError}

} {profile && !urlError && ( <>

已验证

{process.env.NODE_ENV === "development" && (
{profile.avatarUrl ? ( ) : (
👤
)}

{profile.nickname}

{profile.gender &&

性别 {profile.gender}

} {profile.redbookId &&

小红书号 {profile.redbookId}

} {profile.ipLocation &&

IP属地 {profile.ipLocation}

}
关注 {profile.followingText ?? profile.following ?? "—"} 粉丝 {profile.followersText ?? profile.followers ?? "—"} 获赞与收藏 {profile.likesAndCollectsText ?? profile.likesAndCollects ?? "—"} 笔记 {profile.postCountText ?? profile.postCount ?? "—"}
{profile.personalLink && (

个人链接 {profile.personalLink}

)} {profile.resolvedUrl && (

Profile {profile.resolvedUrl}

)}
)} )}