"use client"; import { useState, useEffect, useRef, type FormEvent } from "react"; import Link from "next/link"; import ThemeToggle from "../components/ThemeToggle"; import { DIGITAL_NOMAD_WECHAT_QR } 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+"]; /** 微信号仅允许英文字母、数字、下划线、连字符 */ const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/; /** 从粘贴文本中提取小红书 URL,如 "我在小红书收获了536次赞与收藏>> https://xhslink.com/m/9H50QYiVOVx" */ 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; } } function clearPendingOrder(): void { try { localStorage.removeItem(ORDER_COOKIE); document.cookie = `${ORDER_COOKIE}=; path=/; max-age=0`; } catch {} } 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 [profession, setProfession] = useState(""); const [gender, setGender] = useState(""); const [ageRange, setAgeRange] = useState(""); 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; } | 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 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())}`, { 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); } }; 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) { 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 || "小红书link异常"); 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, }); } 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() && urlError) { setError("请先修正小红书链接"); return; } if (!profession.trim()) { setError("请填写职业"); return; } if (!wechat.trim()) { setError("请填写微信号"); return; } if (!WECHAT_REGEX.test(wechat)) { setError("微信号仅允许英文字母、数字、下划线和连字符"); return; } if (!gender) { setError("请选择性别"); return; } submittingRef.current = true; lastSubmitTime.current = now; setSubmitting(true); try { const checkRes = await fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(wechat.trim())}`, { 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 || xiaohongshuUrl.trim() || ""; 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: "", age_range: ageRange, city: "深圳", session: fromVolunteer ? "volunteer" : "digital-nomad", 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(""); setProfession(""); setGender(""); setAgeRange(""); setAgreeRules(true); setUrlError(null); setProfile(null); setValidating(false); setSubmitted(false); 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"); 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); } } catch { /* ignore */ } } 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])); } } }; void run(); return () => { cancelled = true; }; }, [ userRecord?.hasRecord, userRecord?.isWechatFriend, userRecord?.vip, userRecord?.isComplete, wechat, ]); // 微信内/手机浏览器支付后:页面可能被关闭或跳转,返回时 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 (
← 返回
💰

支付场地茶歇费

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

{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} placeholder="https://www.xiaohongshu.com/user/profile/xxx 或粘贴分享文案" className="form-input" /> {validating &&

🔄 校验中…

} {urlError && !validating &&

⚠️ {urlError}

} {profile && !urlError &&

已验证

}
setProfession(e.target.value)} required className="form-input" />
{fromVolunteer && (
)}
{error && (

⚠️ {error}

)} {submitted && checkingWechat && (

提交成功,正在刷新状态…

)}
| 🏠 返回首页
); }