Files
2026-03-15 11:19:52 -05:00

506 lines
19 KiB
TypeScript
Raw Permalink 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 "next/link";
import {
getPayEnv,
redirectToPay,
getDeviceFromEnv,
} from "@/app/lib/payment";
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
import AuthModal from "@/app/components/AuthModal";
const genderOptions = ["男", "女"];
const singleOptions = ["单身", "恋爱中", "已婚"];
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;
}
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: "",
});
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);
const [checkingStatus, setCheckingStatus] = useState(true);
useEffect(() => {
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("paid") === "1") {
setSubmitted(true);
setCheckingStatus(false);
return;
}
const checkExisting = async () => {
const meRes = await fetch("/api/auth/me", { credentials: "include" });
const meData = await meRes.json();
if (!meData?.user) {
setCheckingStatus(false);
return;
}
const statusRes = await fetch("/api/join/status", { credentials: "include", cache: "no-store" });
const statusData = await statusRes.json();
if (statusData?.hasRecord && statusData?.isComplete) {
setIsAlreadyRecorded(true);
setSubmitted(true);
}
setCheckingStatus(false);
};
checkExisting();
}, []);
usePayStatusPoll((orderId) => {
if (typeof window === "undefined") return;
const paidPath = "/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 res = await fetch("/api/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, media: media?.url }),
credentials: "include",
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "提交失败,请稍后重试");
}
const userId = data.user_id;
if (!userId) {
setSubmitted(true);
return;
}
const vipRes2 = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData2 = await vipRes2.json();
if (vipData2?.vip) {
setIsVipSubmit(true);
setSubmitted(true);
setSubmitting(false);
return;
}
setSubmitting(false);
setPayRedirecting(true);
const returnUrl = `${window.location.origin}/join/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 meRes = await fetch("/api/auth/me", { credentials: "include" });
const meData = await meRes.json();
if (!meData?.user) {
setPendingSubmit(true);
setAuthOpen(true);
return;
}
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
const vipData = await vipRes.json();
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>
);
}
if (checkingStatus) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f0f4f8] dark:bg-gray-950 px-4">
<div className="text-slate-500 dark:text-slate-400"></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>
<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>
</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>
</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>
<div className="border-b border-slate-100 dark:border-gray-700 py-4 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>
</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>
</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 res = await fetch("/api/upload-media", {
method: "POST",
body: fd,
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "上传失败");
}
setMedia((m) => (m ? { ...m, url: data.url } : 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 text-center">
<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,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="relative flex items-center gap-3 py-3.5 sm:flex-col sm:items-stretch sm:gap-0 sm:py-4 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>
);
}