Files
gitlab-instance-0a899031_salon/app/join/page.tsx
2026-03-18 02:01:48 -05:00

1032 lines
43 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<UserRecord> & { 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 (
<div className="mt-6 flex items-center justify-center gap-3 rounded-xl bg-stone-50 dark:bg-stone-800/50 px-4 py-3">
<div className="relative shrink-0">
{record.media ? (
<img
src={record.media}
alt=""
className="h-12 w-12 rounded-full object-cover border border-stone-200 dark:border-stone-600"
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40 text-xl">
👤
</div>
)}
{isVolunteer && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500 text-white whitespace-nowrap">
志愿者
</span>
)}
</div>
<div className="text-left">
<p className="font-medium text-stone-800 dark:text-stone-100">{record.nickname || "—"}</p>
{record.wechatId && (
<p className="text-xs text-stone-500 dark:text-stone-400 font-mono">微信号: {record.wechatId}</p>
)}
</div>
</div>
);
}
export default function JoinPage() {
const [wechat, setWechat] = useState("");
const [userRecord, setUserRecord] = useState<UserRecord | null>(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<string | null>(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<string | null>(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<unknown>>(() => 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<string | null>(() =>
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 (
<div className="flex min-h-screen items-center justify-center bg-[#faf8f5] dark:bg-stone-950 px-4 py-8">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-stone-900 p-6 sm:p-10 text-center shadow-lg border border-stone-200 dark:border-stone-800">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-4xl">🎉</div>
<h2 className="text-xl sm:text-2xl font-bold text-stone-800 dark:text-stone-100">报名成功</h2>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">
{displayRecord?.is_volunteer ? "要提前到场,金额可退" : "感谢报名,活动前会联系你确认~"}
</p>
<UserProfileCard record={displayRecord} showVolunteerTag />
<Link href="/" className="mt-6 sm:mt-8 inline-flex items-center justify-center rounded-xl bg-amber-600 px-6 sm:px-8 py-2.5 sm:py-3 font-medium text-white text-sm sm:text-base transition-colors hover:bg-amber-700">
🏠 返回首页
</Link>
</div>
</div>
);
}
if (showStatusPage) {
const status = userRecord!.isRejected ? "rejected" : userRecord!.isApproved ? "approved" : "pending";
return (
<div className="flex min-h-screen items-center justify-center bg-[#faf8f5] dark:bg-stone-950 px-4 py-8">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-stone-900 p-6 sm:p-10 text-center shadow-lg border border-stone-200 dark:border-stone-800">
{status === "approved" ? (
<>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-4xl">🎉</div>
<h2 className="text-xl sm:text-2xl font-bold text-stone-800 dark:text-stone-100">审核通过</h2>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">扫码添加好友,加入活动群</p>
<div className="mt-6 flex justify-center">
<img src={DIGITAL_NOMAD_WECHAT_QR} alt="微信二维码" className="w-40 h-40 object-contain border border-stone-200 dark:border-stone-700 rounded-lg" />
</div>
<p className="mt-4 text-xs text-stone-400 dark:text-stone-500">📝 添加时请备注:活动报名</p>
</>
) : status === "rejected" ? (
<>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-50 dark:bg-red-900/30 text-4xl">❌</div>
<h2 className="text-xl sm:text-2xl font-bold text-stone-800 dark:text-stone-100">审核拒绝</h2>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">很抱歉,本次申请未通过审核。如有疑问可联系发起人。</p>
</>
) : (
<>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-50 dark:bg-amber-900/30 text-4xl">⏳</div>
<h2 className="text-xl sm:text-2xl font-bold text-stone-800 dark:text-stone-100">审核中</h2>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">⏰ 通常会在 24 小时内完成审核,通过后用微信联系你。</p>
</>
)}
<UserProfileCard record={userRecord?.record ?? null} showVolunteerTag />
{status !== "approved" && (
<button
type="button"
onClick={clearWechatAndRecord}
className="mt-6 text-sm text-stone-500 hover:text-amber-600"
>
重新填写报名表
</button>
)}
<Link href="/" className="mt-4 block text-sm text-amber-600 hover:text-amber-700">
🏠 返回首页
</Link>
</div>
</div>
);
}
if (showPayPage) {
return (
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950">
{paying && (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-white/95 dark:bg-stone-950/95 backdrop-blur-sm">
<span className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-amber-500 border-t-transparent" />
<p className="mt-4 text-base font-medium text-stone-700 dark:text-stone-300">正在跳转支付…</p>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">请勿关闭页面</p>
</div>
)}
<header className="sticky top-0 z-40 w-full border-b border-stone-200/80 dark:border-stone-800 bg-[#faf8f5]/95 dark:bg-stone-950/95 backdrop-blur">
<div className="max-w-2xl mx-auto px-4 h-12 sm:h-14 flex items-center justify-between">
<Link href="/" className="text-sm font-medium text-stone-600 dark:text-stone-400">← 返回</Link>
<ThemeToggle />
</div>
</header>
<div className="mx-auto max-w-2xl px-4 sm:px-6 py-8 lg:py-10">
<div className="overflow-hidden bg-white dark:bg-stone-900 shadow-sm sm:rounded-2xl p-6 sm:p-10 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-50 dark:bg-amber-900/30 text-4xl">💰</div>
<h2 className="text-xl sm:text-2xl font-bold text-stone-800 dark:text-stone-100">支付场地茶歇费</h2>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">29元/人,用于覆盖民宿/场地基础成本</p>
<UserProfileCard record={userRecord?.record ?? null} showVolunteerTag />
{userRecord?.record?.is_volunteer && (
<p className="mt-3 text-xs text-emerald-600 dark:text-emerald-400">
🌿 志愿者预付活动结束可退<br />
用于防止临时爽约
</p>
)}
{paying && (
<div className="mt-6 flex items-center justify-center gap-2 rounded-xl bg-amber-50 dark:bg-amber-900/20 py-3 px-4 text-amber-700 dark:text-amber-400 text-sm">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-amber-500 border-t-transparent" />
正在跳转支付,请稍候…
</div>
)}
<button
type="button"
onClick={handlePayNow}
disabled={paying}
className="mt-6 w-full rounded-xl bg-amber-600 py-3.5 text-base font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{paying ? (
<>
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
跳转中…
</>
) : (
"立即报名"
)}
</button>
</div>
</div>
</div>
);
}
if (!hydrated) {
return (
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950 flex items-center justify-center">
<span className="text-stone-400">加载中…</span>
</div>
);
}
// 微信号输入区:失焦时查询,查询中不展示其他元素
const wechatOnlyView = !userRecord;
if (wechatOnlyView) {
return (
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950">
<header className="sticky top-0 z-40 w-full border-b border-stone-200/80 dark:border-stone-800 bg-[#faf8f5]/95 dark:bg-stone-950/95 backdrop-blur">
<div className="max-w-2xl mx-auto px-4 h-12 sm:h-14 flex items-center justify-between">
<Link href="/" className="text-sm font-medium text-stone-600 dark:text-stone-400">← 返回</Link>
<ThemeToggle />
</div>
</header>
<div className="mx-auto max-w-2xl px-4 sm:px-6 py-8 lg:py-10">
<div className="overflow-hidden bg-white dark:bg-stone-900 shadow-sm sm:rounded-2xl">
<div className="relative h-28 sm:h-36 overflow-hidden bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700">
<div className="absolute inset-0 flex flex-col items-center justify-center text-white">
<div className="text-4xl sm:text-5xl" aria-hidden>☕</div>
</div>
</div>
<div className="px-4 sm:px-6 py-5 sm:py-6">
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">
💬 微信号 <span className="text-red-500">*</span>
</label>
<input
type="text"
maxLength={140}
placeholder="用于拉群"
value={wechat}
onChange={(e) => setWechat(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""))}
onBlur={handleWechatBlur}
className="form-input"
autoFocus
/>
</div>
{checkingWechat && (
<p className="mt-3 text-sm text-amber-600 dark:text-amber-400">🔄 查询中…</p>
)}
{error && (
<p className="mt-3 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-3 text-sm text-red-600 dark:text-red-400">⚠️ {error}</p>
)}
<button
type="button"
disabled
className="mt-5 w-full rounded-xl bg-amber-600/50 py-3.5 text-base font-medium text-white cursor-not-allowed"
>
📤 提交报名
</button>
<p className="mt-2 text-xs text-stone-400 dark:text-stone-500">填写微信号并完成查询后显示完整表单</p>
</div>
</div>
<div className="mt-4 text-center">
<Link href="/" className="text-xs sm:text-sm text-stone-400 dark:text-stone-500 hover:text-amber-600">🏠 返回首页</Link>
</div>
</div>
</div>
);
}
// 无记录 → 显示完整表单
return (
<div className="min-h-screen bg-[#faf8f5] dark:bg-stone-950">
<header className="sticky top-0 z-40 w-full border-b border-stone-200/80 dark:border-stone-800 bg-[#faf8f5]/95 dark:bg-stone-950/95 backdrop-blur">
<div className="max-w-2xl mx-auto px-4 h-12 sm:h-14 flex items-center justify-between">
<Link href="/" className="text-sm font-medium text-stone-600 dark:text-stone-400">← 返回</Link>
<ThemeToggle />
</div>
</header>
<div className="mx-auto max-w-2xl px-4 sm:px-6 py-8 lg:py-10">
<div className="overflow-hidden bg-white dark:bg-stone-900 shadow-sm sm:rounded-2xl">
<div className="relative h-28 sm:h-36 overflow-hidden bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700">
<div className="absolute inset-0 flex flex-col items-center justify-center text-white">
<div className="text-4xl sm:text-5xl" aria-hidden>☕</div>
</div>
</div>
<div className="px-4 sm:px-6 py-5 sm:py-6">
{fromVolunteer && (
<div className="mb-6 rounded-xl bg-amber-50 dark:bg-amber-900/20 p-4">
<h3 className="font-semibold text-stone-800 dark:text-stone-100 text-sm mb-2">🙌 志愿者工作内容</h3>
<ul className="text-xs sm:text-sm text-stone-600 dark:text-stone-400 space-y-1">
<li>· 签到:现场签到、引导参与者</li>
<li>· 茶歇准备:协助布置茶歇、收拾整理</li>
<li>· 拍照摄像:记录活动现场,分享精彩瞬间</li>
</ul>
</div>
)}
<form onSubmit={handleSubmit} action="javascript:void(0)" className="space-y-5">
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">💬 微信号 <span className="text-red-500">*</span></label>
<input
type="text"
maxLength={140}
placeholder="用于拉群"
value={wechat}
onChange={(e) => setWechat(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ""))}
onBlur={handleWechatBlur}
required
className="form-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">📱 小红书主页链接 或 笔记链接 <span className="text-red-500">*</span></label>
<input
type="text"
value={xiaohongshuUrl}
onChange={(e) => { setXiaohongshuUrl(e.target.value); setUrlError(null); }}
onBlur={handleUrlBlur}
required
className="form-input"
/>
{validating && <p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">🔄 校验中…</p>}
{urlError && !validating && <p className="mt-1.5 text-xs text-red-600 dark:text-red-400">⚠️ {urlError}</p>}
{profile && !urlError && (
<>
<p className="mt-1.5 text-xs text-emerald-600 dark:text-emerald-400">已验证</p>
{process.env.NODE_ENV === "development" && (
<div className="mt-3 rounded-xl border border-stone-200 dark:border-stone-600 bg-stone-50 dark:bg-stone-800/50 p-4">
<div className="flex items-center gap-3">
{profile.avatarUrl ? (
<img
src={profile.avatarUrl}
alt=""
className="h-14 w-14 rounded-full object-cover border border-stone-200 dark:border-stone-600"
/>
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/40 text-2xl">
👤
</div>
)}
<div>
<p className="font-medium text-stone-800 dark:text-stone-100">{profile.nickname}</p>
{profile.gender && <p className="text-xs text-stone-500 dark:text-stone-400">性别 {profile.gender}</p>}
{profile.redbookId && <p className="text-xs text-stone-500 dark:text-stone-400">小红书号 {profile.redbookId}</p>}
{profile.ipLocation && <p className="text-xs text-stone-500 dark:text-stone-400">IP属地 {profile.ipLocation}</p>}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-stone-600 dark:text-stone-400">
<span>关注 {profile.followingText ?? profile.following ?? "—"}</span>
<span>粉丝 {profile.followersText ?? profile.followers ?? "—"}</span>
<span>获赞与收藏 {profile.likesAndCollectsText ?? profile.likesAndCollects ?? "—"}</span>
<span>笔记 {profile.postCountText ?? profile.postCount ?? "—"}</span>
</div>
{profile.personalLink && (
<p className="mt-2 text-xs text-stone-500 dark:text-stone-400 truncate">个人链接 {profile.personalLink}</p>
)}
{profile.resolvedUrl && (
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500 truncate">Profile {profile.resolvedUrl}</p>
)}
</div>
)}
</>
)}
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">✍️ 自我介绍 <span className="text-red-500">*</span></label>
<div className="relative">
<textarea
rows={4}
maxLength={500}
minLength={20}
placeholder="不少于20字"
value={intro}
onChange={(e) => setIntro(e.target.value)}
required
className="form-input resize-y min-h-[80px] pr-14"
/>
<span
className={`absolute bottom-2 right-3 text-xs ${
intro.length < 20 ? "text-amber-600 dark:text-amber-400" : "text-stone-400 dark:text-stone-500"
}`}
>
{intro.length}/500
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">💼 职业 <span className="text-red-500">*</span></label>
<input type="text" maxLength={140} placeholder="您的职业或专业" value={profession} onChange={(e) => setProfession(e.target.value)} required className="form-input" />
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">📅 参加场次 <span className="text-red-500">*</span></label>
<select value={meetupdate} onChange={(e) => setMeetupdate(e.target.value)} required className={`form-input appearance-none ${meetupdate ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}>
<option value="">请选择要参加的活动</option>
{meetupdateOptions.map((o) => (
<option key={o.type} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 dark:text-stone-300 mb-2">👤 性别 <span className="text-red-500">*</span></label>
<select value={gender} onChange={(e) => setGender(e.target.value)} required className={`form-input appearance-none ${gender ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}>
<option value="">请选择</option>
<option value="女">女</option>
<option value="男">男</option>
</select>
</div>
{fromVolunteer && (
<div>
<label className="flex items-center gap-3 cursor-default">
<input type="checkbox" checked readOnly disabled className="rounded border-stone-300 text-amber-600" />
<span className="text-sm font-medium text-stone-700 dark:text-stone-300">🙌 志愿者</span>
</label>
</div>
)}
<div className="hidden">
<select value={ageRange} onChange={(e) => setAgeRange(e.target.value)} className="form-input">
<option value="">请选择</option>
{ageRangeOptions.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</div>
<div className="hidden">
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={agreeRules} onChange={(e) => setAgreeRules(e.target.checked)} className="mt-1 rounded border-stone-300 text-amber-600 focus:ring-amber-500" />
<span className="text-xs sm:text-sm text-stone-600 dark:text-stone-400">📋 我已阅读并同意社区规则</span>
</label>
</div>
{error && (
<p className="rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-3 text-sm text-red-600 dark:text-red-400">⚠️ {error}</p>
)}
{submitted && checkingWechat && (
<p className="text-sm text-amber-600 dark:text-amber-400">提交成功,正在刷新状态…</p>
)}
<button type="submit" disabled={submitting || !!urlError || validating} className="w-full rounded-xl bg-amber-600 py-3.5 text-base font-medium text-white transition-colors hover:bg-amber-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50">
{submitting ? "🔄 提交中…" : fromVolunteer ? "📤 提交志愿者申请" : "📤 提交报名"}
</button>
</form>
</div>
</div>
<div className="mt-4 text-center">
<button type="button" onClick={clearWechatAndRecord} className="text-xs sm:text-sm text-stone-500 hover:text-amber-600">
重新填写报名表
</button>
<span className="mx-2 text-stone-300">|</span>
<Link href="/" className="text-xs sm:text-sm text-stone-500 hover:text-amber-600">🏠 返回首页</Link>
</div>
</div>
</div>
);
}