Files
gitlab-instance-0a899031_salon/app/join/page.tsx
2026-03-16 06:53:23 -05:00

495 lines
22 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, 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, getDeviceFromEnv } from "@/app/lib/payment/client";
const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"];
/** 微信号仅允许英文字母、数字、下划线、连字符 */
const WECHAT_REGEX = /^[a-zA-Z0-9_-]*$/;
interface UserRecord {
hasRecord: boolean;
record: { user_id?: string; amount?: number; order_id?: string; vip?: boolean } | null;
isComplete: boolean;
isWechatFriend: boolean;
isApproved: boolean;
isRejected: boolean;
vip: boolean;
}
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(() =>
typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1"
);
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);
return;
}
if (!WECHAT_REGEX.test(w)) {
setError("微信号仅允许英文字母、数字、下划线和连字符");
return;
}
setError(null);
fetchByWechat(w);
};
const handleUrlBlur = async () => {
const url = xiaohongshuUrl.trim();
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);
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;
}
setSubmitting(true);
try {
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: "digital-nomad",
education: "",
graduation_year: "",
agree_rules: true,
first_time: "",
status: "",
activity_type: "",
why_join: "",
region: "",
available_time: "",
}),
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "提交失败,请稍后重试");
}
setQualify(qualify);
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "网络错误,请重试");
} finally {
setSubmitting(false);
}
};
const handlePayNow = () => {
const rec = userRecord?.record;
if (!rec?.user_id) return;
const origin = typeof window !== "undefined" ? window.location.origin : "";
const device = typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod|webOS|Mobile/i.test(navigator.userAgent) ? "h5" : "pc";
redirectToPay({
user_id: rec.user_id,
return_url: `${origin}/join/paid`,
useJoinConfig: true,
channel: "wxpay",
device: getDeviceFromEnv(device),
});
};
useEffect(() => {
setHydrated(true);
}, []);
// 成功报名:已支付 或 支付成功跳转 或 (好友且vip)
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 (submitted || showSuccess) {
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"></p>
<p className="mt-6 text-xs text-stone-400 dark:text-stone-500 leading-relaxed whitespace-pre-line">
{`本场收取少量场地分摊费29/人
用于覆盖民宿/场地基础成本。
通过审核后再确认名额,未确认无需支付。`}
</p>
<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>
</>
)}
<p className="mt-6 text-xs text-stone-400 dark:text-stone-500 leading-relaxed whitespace-pre-line">
{`本场收取少量场地分摊费29/人
用于覆盖民宿/场地基础成本。
通过审核后再确认名额,未确认无需支付。`}
</p>
<button
type="button"
onClick={() => { setUserRecord(null); setWechat(""); }}
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">29/宿/</p>
<button
type="button"
onClick={handlePayNow}
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"
>
</button>
<button
type="button"
onClick={() => { setUserRecord(null); setWechat(""); }}
className="mt-3 text-sm text-stone-500 hover:text-stone-700"
>
</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 || checkingWechat;
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">
<form onSubmit={handleSubmit} 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="url"
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>
<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>
)}
<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 ? "🔄 提交中…" : "📤 提交报名"}
</button>
</form>
</div>
</div>
<div className="mt-4 text-center">
<button type="button" onClick={() => { setUserRecord(null); setWechat(""); }} 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>
);
}