"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;
}
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;
setError(null);
setCheckingWechat(true);
try {
const res = await fetch(`/api/join/by-wechat?wechat_id=${encodeURIComponent(w.trim())}`);
const data = await res.json();
setUserRecord({
hasRecord: !!data.hasRecord,
record: data.record ?? null,
isComplete: !!data.isComplete,
isWechatFriend: !!data.isWechatFriend,
isApproved: !!data.isApproved,
isRejected: !!data.isRejected,
vip: !!data.vip,
});
} catch {
setUserRecord({ hasRecord: false, record: null, isComplete: false, isWechatFriend: false, isApproved: false, isRejected: false, vip: false });
} 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 {
await fetchByWechat(wechat.trim());
} catch {
try {
localStorage.setItem(WECHAT_STORAGE_KEY, wechat.trim());
} catch {}
window.location.replace("/join");
return;
}
} 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 && (
🌿 志愿者预付活动结束可退
用于防止临时爽约
)}
);
}
if (!hydrated) {
return (
加载中…
);
}
// 微信号输入区:失焦时查询,查询中不展示其他元素
const wechatOnlyView = !userRecord || checkingWechat;
if (wechatOnlyView) {
return (
setWechat(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""))}
onBlur={handleWechatBlur}
className="form-input"
autoFocus
/>
{checkingWechat && (
🔄 查询中…
)}
{error && (
⚠️ {error}
)}
填写微信号并完成查询后显示完整表单
🏠 返回首页
);
}
// 无记录 → 显示完整表单
return (
{fromVolunteer && (
🙌 志愿者工作内容
- · 签到:现场签到、引导参与者
- · 茶歇准备:协助布置茶歇、收拾整理
- · 拍照摄像:记录活动现场,分享精彩瞬间
)}
|
🏠 返回首页
);
}