Files
gitlab-instance-0a899031_salon/app/join/page.tsx
2026-03-16 05:41:47 -05:00

437 lines
17 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, type FormEvent } from "react";
import Link from "next/link";
import ThemeToggle from "../components/ThemeToggle";
import { DIGITAL_NOMAD_WECHAT_QR } from "@/config/home.config";
const ageRangeOptions = ["18-22", "23-27", "28-32", "33-37", "38+"];
function getDisplayCount(text?: string, value?: number | string, suffix = ""): string | null {
if (typeof text === "string" && text.trim()) {
return `${text}${suffix}`;
}
if (typeof value === "number" || typeof value === "string") {
return `${value}${suffix}`;
}
return null;
}
function getGenderLabel(gender?: string): string {
if (gender === "female") return "\u5973";
if (gender === "male") return "\u7537";
return "\u65e0";
}
export default function JoinPage() {
const [xiaohongshuUrl, setXiaohongshuUrl] = useState("");
const [profession, setProfession] = useState("");
const [wechat, setWechat] = useState("");
const [gender, setGender] = useState("");
const [ageRange, setAgeRange] = useState("");
const [agreeRules, setAgreeRules] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null);
const [profile, setProfile] = useState<{
nickname: string;
gender?: string;
followers: number | string;
followersText?: string;
following?: number | string;
followingText?: string;
likesAndCollects?: number | string;
likesAndCollectsText?: string;
postCount: number;
postCountText?: string;
redbookId?: string;
ipLocation?: string;
personalLink?: 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 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,
followers: profileData.followers ?? 0,
followersText: profileData.followersText,
following: profileData.following,
followingText: profileData.followingText,
likesAndCollects: profileData.likesAndCollects,
likesAndCollectsText: profileData.likesAndCollectsText,
postCount: profileData.postCount ?? 0,
postCountText: profileData.postCountText,
redbookId: profileData.redbookId,
ipLocation: profileData.ipLocation,
personalLink: profileData.personalLink,
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 (!gender) {
setError("请选择性别");
return;
}
if (!ageRange) {
setError("请选择年龄区间");
return;
}
if (!agreeRules) {
setError("请同意社区规则");
return;
}
setSubmitting(true);
try {
const formGenderFemale = gender === "女" || gender === "female";
const xiaohongshuGenderFemale = profile?.gender === "female";
const postCountOk = (profile?.postCount ?? 0) > 10;
const ipInGuangdong = profile?.ipLocation?.includes("广东") ?? false;
const qualify =
formGenderFemale &&
xiaohongshuGenderFemale &&
postCountOk &&
ipInGuangdong;
const genderLabel = gender === "女" ? "女" : gender === "男" ? "男" : "无";
const postCountLabel = getDisplayCount(profile?.postCountText, profile?.postCount) ?? "0";
const submitXiaohongshuUrl = profile?.resolvedUrl || xiaohongshuUrl.trim() || "";
const submitReasonParts = [
`职业:${profession.trim()}`,
`性别:${genderLabel}`,
`年龄:${ageRange}`,
profile && `发帖:${postCountLabel}`,
profile?.ipLocation && `IP:${profile.ipLocation}`,
].filter(Boolean);
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: submitReasonParts.join(" | "),
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 followingDisplay = getDisplayCount(profile?.followingText, profile?.following);
const followersDisplay = getDisplayCount(profile?.followersText, profile?.followers);
const likesDisplay = getDisplayCount(profile?.likesAndCollectsText, profile?.likesAndCollects);
const safePostCountDisplay = getDisplayCount(profile?.postCountText, profile?.postCount, " \u7bc7");
const profileGenderLabel = getGenderLabel(profile?.gender);
if (submitted) {
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">
{qualify ? (
<>
<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>
</>
) : (
<>
<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">
💰 29/宿/<br />
</p>
<Link
href="/"
className="mt-6 sm:mt-8 inline-flex items-center gap-2 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>
);
}
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">
📱
</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">
{profile.nickname}
{profile.redbookId && ` \u00b7 @${profile.redbookId}`}
{profile.ipLocation && ` \u00b7 ${profile.ipLocation}`}
{profile.personalLink && ` \u00b7 ${profile.personalLink}`}
{followingDisplay && ` \u00b7 \u5173\u6ce8 ${followingDisplay}`}
{followersDisplay && ` \u00b7 \u7c89\u4e1d ${followersDisplay}`}
{likesDisplay && ` \u00b7 \u83b7\u8d5e\u4e0e\u6536\u85cf ${likesDisplay}`}
{safePostCountDisplay && ` \u00b7 \u53d1\u5e16 ${safePostCountDisplay}`}
{` \u00b7 ${profileGenderLabel}`}
</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>
<input
type="text"
maxLength={140}
placeholder="用于拉群"
value={wechat}
onChange={(e) => setWechat(e.target.value)}
required
className="form-input"
/>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<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>
<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={ageRange}
onChange={(e) => setAgeRange(e.target.value)}
required
className={`form-input appearance-none ${ageRange ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}
>
<option value=""></option>
{ageRangeOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
</div>
</div>
<div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={agreeRules}
onChange={(e) => setAgreeRules(e.target.checked)}
required
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 flex items-center gap-2">
{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 flex items-center justify-center gap-2"
>
{submitting ? "🔄 提交中…" : "📤 提交报名"}
</button>
</form>
</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 inline-flex items-center gap-1">
🏠
</Link>
</div>
</div>
</div>
);
}