Files
gitlab-instance-0a899031_cn…/app/[locale]/join/page.tsx
2026-06-08 02:32:33 -05:00

598 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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, useRef, useEffect, type FormEvent, type ChangeEvent } from "react";
import { Link } from "@/i18n/navigation";
import {
getPayEnv,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
import AuthModal from "@/app/components/AuthModal";
import { apiFetch, fetchAuthMe } from "@/app/lib/api-client";
import { cities } from "@/app/data/cities";
const genderOptions = ["男", "女"];
const singleOptions = ["单身", "恋爱中", "已婚"];
const lookingForOptions = [
{ key: "friends", label: "🤝 交友" },
{ key: "dating", label: "🌹 约会" },
{ key: "partner", label: "👥 搭档" },
{ key: "roommate", label: "🏠 室友" },
{ key: "cofounder", label: "🚀 合伙人" },
{ key: "explore", label: "🍜 探店" },
];
const educationOptions = ["高中及以下", "大专", "本科", "硕士", "博士"];
const graduationYears = Array.from({ length: 41 }, (_, i) => String(2026 - i));
interface FormData {
nickname: string;
gender: string;
single: string;
profession: string;
intro: string;
wechat: string;
education: string;
graduationYear: string;
phone: string;
location: string;
citySlug: string;
}
interface MediaInfo {
preview: string;
url: string;
type: "image" | "video";
}
export default function JoinPage() {
const [form, setForm] = useState<FormData>({
nickname: "",
gender: "",
single: "",
profession: "",
intro: "",
wechat: "",
education: "",
graduationYear: "",
phone: "",
location: "",
citySlug: "",
});
const [lookingFor, setLookingFor] = useState<string[]>(["friends", "explore"]);
const [media, setMedia] = useState<MediaInfo | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [submitted, setSubmitted] = useState(false);
const [isVipSubmit, setIsVipSubmit] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [payRedirecting, setPayRedirecting] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [pendingSubmit, setPendingSubmit] = useState(false);
const [isAlreadyRecorded, setIsAlreadyRecorded] = useState(false);
useEffect(() => {
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
setSubmitted(true);
return;
}
const checkExisting = async () => {
try {
const meData = await fetchAuthMe();
if (!meData?.user) return;
const statusData = await apiFetch<{ hasRecord?: boolean; isComplete?: boolean }>(
"/api/join/status",
{ cache: "no-store" }
);
if (statusData?.hasRecord && statusData?.isComplete) {
setIsAlreadyRecorded(true);
setSubmitted(true);
}
} catch {
// Keep the form usable when the API proxy/backend is temporarily unavailable.
}
};
checkExisting();
}, []);
// 轮询到支付成功时跳转到 /join/paid携带 order_id 供 complete-order 使用
usePayStatusPoll((orderId) => {
if (typeof window === "undefined") return;
const path = window.location.pathname;
const paidPath = path.replace(/\/join\/?$/, "") + "/join/paid";
const url = orderId ? `${paidPath}?order_id=${encodeURIComponent(orderId)}` : paidPath;
window.location.replace(url);
});
const set = (key: keyof FormData, value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const doSubmit = async () => {
setError(null);
setSubmitting(true);
try {
const data = await apiFetch<{ ok?: boolean; user_id?: string; error?: string }>("/api/join", {
method: "POST",
body: JSON.stringify({
...form,
media: media?.url,
lookingFor,
}),
});
if (!data?.ok) {
throw new Error(data?.error || "提交失败,请稍后重试");
}
const userId = data.user_id;
if (!userId) {
setSubmitted(true);
return;
}
// 再次确认 VIP防止 handleAuthSuccess 等路径绕过
const vipData2 = await apiFetch<{ vip?: boolean }>("/api/vip/check", { cache: "no-store" });
if (vipData2?.vip) {
setIsVipSubmit(true);
setSubmitted(true);
setSubmitting(false);
return;
}
setSubmitting(false);
setPayRedirecting(true);
const returnUrl = `${window.location.origin}${window.location.pathname}/paid`;
const env = getPayEnv();
const channel = "wxpay";
const device = getDeviceFromEnv(env);
redirectToPay({
user_id: userId,
return_url: returnUrl,
channel,
device,
useJoinConfig: true,
});
return;
} catch (err) {
setError(err instanceof Error ? err.message : "网络错误,请重试");
} finally {
setSubmitting(false);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const meData = await fetchAuthMe();
if (!meData?.user) {
setPendingSubmit(true);
setAuthOpen(true);
return;
}
const vipData = await apiFetch<{ vip?: boolean }>("/api/vip/check", { cache: "no-store" });
if (vipData?.vip) {
setIsVipSubmit(true);
}
await doSubmit();
};
const handleAuthSuccess = () => {
setAuthOpen(false);
if (pendingSubmit) {
setPendingSubmit(false);
doSubmit();
}
};
if (submitted) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
<div className="w-full max-w-md animate-fade-in rounded-2xl bg-white dark:bg-gray-900 p-10 text-center shadow-lg border border-gray-100 dark:border-gray-800">
<div className="mx-auto mb-5 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-5xl">
</div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{isAlreadyRecorded ? "资料已录入" : isVipSubmit ? "提交成功" : "支付成功"}
</h2>
<p className="mt-3 text-slate-500 dark:text-slate-400">
{isAlreadyRecorded ? "您的报名信息已录入,无需重复提交" : isVipSubmit ? "感谢您的参与" : "感谢您的支持"}
</p>
<Link
href="/"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[#ff4d4f] px-8 py-3 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
>
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#f0f4f8] dark:bg-gray-950">
<div className="mx-auto max-w-2xl px-0 py-0 sm:px-6 sm:py-10 lg:py-14">
<div className="overflow-hidden bg-white dark:bg-gray-900 shadow-sm sm:rounded-t-2xl">
<div className="relative h-40 overflow-hidden bg-gradient-to-br from-sky-400 via-cyan-500 to-teal-400 sm:h-52">
<div className="absolute inset-0 flex flex-col items-center justify-center text-white">
<div className="text-6xl sm:text-7xl">🌍</div>
<div className="mt-2 text-sm font-medium opacity-80">
·
</div>
</div>
<div className="absolute -top-10 -left-10 h-40 w-40 rounded-full bg-white/10 blur-2xl" />
<div className="absolute -right-10 -bottom-10 h-48 w-48 rounded-full bg-white/10 blur-2xl" />
</div>
<div className="px-5 py-5 text-center sm:px-8 sm:py-7">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100 sm:text-2xl">
🌍
</h1>
<p className="mt-3 whitespace-pre-line text-sm leading-relaxed text-slate-400 dark:text-slate-500 sm:text-base">
{"💼 结识志同道合的伙伴\n📍 线下聚会 + 线上分享\n🤝 互助成长,资源共享"}
</p>
</div>
</div>
<form
onSubmit={handleSubmit}
className="mt-0 bg-white dark:bg-gray-900 px-5 pb-8 pt-2 shadow-sm sm:mt-5 sm:rounded-2xl sm:px-8 sm:py-8 sm:shadow-md border border-gray-100 dark:border-gray-800"
>
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="🌟 昵称">
<input
type="text"
maxLength={140}
placeholder="请输入您的昵称"
value={form.nickname}
onChange={(e) => set("nickname", e.target.value)}
required
className="form-input"
/>
</Field>
<Field label="👤 性别">
<select
value={form.gender}
onChange={(e) => set("gender", e.target.value)}
required
className={`form-input appearance-none ${form.gender ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled></option>
{genderOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
<ChevronDown />
</Field>
</div>
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="💕 感情状态">
<select
value={form.single}
onChange={(e) => set("single", e.target.value)}
required
className={`form-input appearance-none ${form.single ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled></option>
{singleOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
<ChevronDown />
</Field>
<Field label="💼 职业">
<input
type="text"
maxLength={140}
placeholder="您的职业或专业"
value={form.profession}
onChange={(e) => set("profession", e.target.value)}
required
className="form-input"
/>
</Field>
</div>
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="💬 微信号">
<input
type="text"
maxLength={140}
placeholder="用于拉群"
value={form.wechat}
onChange={(e) => set("wechat", e.target.value)}
required
className="form-input"
/>
</Field>
<Field label="📱 手机号">
<input
type="tel"
maxLength={11}
pattern="^\d{11}$"
placeholder="请输入11位手机号"
value={form.phone}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, "").slice(0, 11);
set("phone", v);
}}
required
className="form-input"
/>
</Field>
</div>
<Field label="📍 当前城市">
<select
value={form.citySlug}
onChange={(e) => {
const city = cities.find((item) => (item.slug || item.name) === e.target.value);
setForm((prev) => ({
...prev,
citySlug: e.target.value,
location: city?.name || "",
}));
}}
required
className={`form-input appearance-none ${form.citySlug ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled></option>
{cities.map((city) => (
<option key={city.name} value={city.slug || city.name}>
{city.name}
</option>
))}
</select>
<ChevronDown />
</Field>
<div className="py-4 sm:py-5">
<label className="mb-3 block text-base font-bold text-slate-700 dark:text-slate-300 sm:text-[17px]">
🎯
<span className="ml-2 text-xs font-normal text-slate-400 dark:text-slate-500"></span>
</label>
<div className="flex flex-wrap gap-2">
{lookingForOptions.map((option) => {
const active = lookingFor.includes(option.key);
return (
<button
key={option.key}
type="button"
onClick={() =>
setLookingFor((prev) =>
active ? prev.filter((item) => item !== option.key) : [...prev, option.key]
)
}
className={`rounded-full px-3 py-2 text-sm font-medium transition ${
active
? "bg-[#ff4d4f] text-white"
: "border border-slate-200 bg-white text-slate-600 dark:border-gray-700 dark:bg-gray-900 dark:text-slate-300"
}`}
>
{option.label}
</button>
);
})}
</div>
</div>
<div className="border-b border-slate-100 dark:border-gray-700 py-4 sm:border-b-0 sm:py-5">
<label className="mb-2 block text-base font-bold text-slate-700 dark:text-slate-300 sm:text-[17px]">
📝
</label>
<div className="relative">
<textarea
maxLength={500}
rows={5}
placeholder="分享您的经历、兴趣或加入原因,越详细越好哦~"
value={form.intro}
onChange={(e) => set("intro", e.target.value)}
className="form-input min-h-[120px] resize-none sm:min-h-[140px]"
/>
<span className="absolute right-3 bottom-3 text-xs text-slate-300 dark:text-slate-500">
{form.intro.length}/500
</span>
</div>
</div>
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
<Field label="🎓 学历">
<select
value={form.education}
onChange={(e) => set("education", e.target.value)}
className={`form-input appearance-none ${form.education ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled></option>
{educationOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
<ChevronDown />
</Field>
<Field label="📅 毕业时间">
<select
value={form.graduationYear}
onChange={(e) => set("graduationYear", e.target.value)}
className={`form-input appearance-none ${form.graduationYear ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled></option>
{graduationYears.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
<ChevronDown />
</Field>
</div>
<div className="py-4 sm:py-5">
<label className="mb-1 block text-[15px] font-bold text-slate-700 dark:text-slate-300 sm:text-[17px]">
📷 /
<span className="ml-2 text-xs font-normal text-slate-400 dark:text-slate-500"></span>
</label>
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500">
JPG/PNGMP4 20MB
</p>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
className="hidden"
onChange={async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 20 * 1024 * 1024) {
alert("文件大小不能超过 20MB");
return;
}
const isVideo = file.type.startsWith("video/");
const preview = URL.createObjectURL(file);
setMedia({ preview, url: "", type: isVideo ? "video" : "image" });
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const data = await apiFetch<{ ok?: boolean; url?: string; error?: string }>(
"/api/upload-media",
{ method: "POST", body: fd }
);
if (!data?.ok || !data.url) {
throw new Error(data?.error || "上传失败");
}
const uploadedUrl = data.url;
setMedia((m) => (m ? { ...m, url: uploadedUrl } : null));
} catch (err) {
alert(err instanceof Error ? err.message : "上传失败");
URL.revokeObjectURL(preview);
setMedia(null);
if (fileRef.current) fileRef.current.value = "";
} finally {
setUploading(false);
}
}}
/>
{media ? (
<div className="relative inline-block">
{uploading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-black/40 text-sm font-medium text-white">
</div>
)}
{media.type === "image" ? (
<img
src={media.preview}
alt="preview"
className="h-32 w-32 rounded-xl border border-slate-200 dark:border-gray-700 object-cover sm:h-40 sm:w-40"
/>
) : (
<video
src={media.preview}
className="h-32 w-32 rounded-xl border border-slate-200 dark:border-gray-700 object-cover sm:h-40 sm:w-40"
muted
playsInline
controls
/>
)}
<button
type="button"
disabled={uploading}
onClick={() => {
URL.revokeObjectURL(media.preview);
setMedia(null);
if (fileRef.current) fileRef.current.value = "";
}}
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white shadow-md transition-colors hover:bg-red-600 disabled:opacity-50"
>
</button>
</div>
) : (
<button
type="button"
disabled={uploading}
onClick={() => fileRef.current?.click()}
className="flex h-32 w-32 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-slate-200 dark:border-gray-700 text-slate-400 dark:text-slate-500 transition-colors hover:border-sky-300 hover:text-sky-500 disabled:opacity-50 sm:h-40 sm:w-40"
>
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
</svg>
<span className="text-xs">{uploading ? "上传中…" : "点击上传"}</span>
</button>
)}
</div>
<div className="mt-8 sm:mt-10">
{error && (
<p className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-3 text-center text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
<button
type="submit"
disabled={submitting || uploading || payRedirecting}
className="w-full rounded-full bg-[#1aad19] py-3.5 text-lg font-semibold text-white shadow-md shadow-emerald-500/20 transition-all hover:bg-[#179b16] hover:shadow-lg hover:shadow-emerald-500/25 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70 sm:py-4"
>
{uploading ? "媒体上传中…" : payRedirecting ? "跳转支付中…" : submitting ? "提交中…" : "提交申请"}
</button>
</div>
</form>
<div className="mt-6 hidden text-center sm:block">
<Link href="/" className="text-sm text-slate-400 dark:text-slate-500 transition-colors hover:text-[#ff4d4f]">
</Link>
</div>
<div className="mt-4 pb-6 text-center sm:hidden">
<Link href="/" className="text-sm text-slate-400 dark:text-slate-500 transition-colors hover:text-[#ff4d4f]">
</Link>
</div>
</div>
<AuthModal
isOpen={authOpen}
onClose={() => { setAuthOpen(false); setPendingSubmit(false); }}
onSuccess={handleAuthSuccess}
/>
</div>
);
}
function Field({
label,
children,
noBorder,
}: {
label: string;
children: React.ReactNode;
noBorder?: boolean;
}) {
return (
<div
className={`relative flex items-center gap-3 py-3.5 sm:flex-col sm:items-stretch sm:gap-0 sm:py-4 ${
noBorder ? "" : "border-b border-slate-100 dark:border-gray-700 sm:border-b-0"
}`}
>
<label className="w-[90px] shrink-0 text-[15px] font-bold text-slate-700 dark:text-slate-300 sm:mb-2 sm:w-auto sm:text-[17px]">
{label}
</label>
<div className="relative min-w-0 flex-1">{children}</div>
</div>
);
}
function ChevronDown() {
return (
<svg
className="pointer-events-none absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 text-slate-400 sm:right-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}