Files
2026-03-16 19:17:06 -05:00

855 lines
34 KiB
TypeScript
Raw Permalink 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 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<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 [profession, setProfession] = useState("");
const [gender, setGender] = useState("");
const [ageRange, setAgeRange] = useState("");
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;
} | 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 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触发后端更新 solanRedZPAY 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<boolean> => {
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 (
<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">
<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">1/宿/</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">📱 </label>
<p className="text-xs text-stone-500 dark:text-stone-400 mb-1.5">💡 </p>
<input
type="text"
value={xiaohongshuUrl}
onChange={(e) => { setXiaohongshuUrl(e.target.value); setUrlError(null); }}
onBlur={handleUrlBlur}
placeholder="https://www.xiaohongshu.com/user/profile/xxx 或粘贴分享文案"
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>}
</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={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>
);
}