Files
gitlab-instance-0a899031_salon/app/join/page.tsx
2026-03-15 23:19:43 -05:00

486 lines
21 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, useEffect, type FormEvent } from "react";
import Link from "next/link";
import SessionCoverImage from "../components/SessionCoverImage";
import { SESSIONS } from "@/config/home.config";
import CitySelect from "../components/CitySelect";
import ThemeToggle from "../components/ThemeToggle";
const ageOptions = ["18-24", "25-30", "31-35", "36-40", "40+"];
const statusOptions = [
{ value: "student", label: "学生" },
{ value: "working", label: "上班" },
{ value: "freelance", label: "自由职业" },
{ value: "startup", label: "创业" },
{ value: "other", label: "其他" },
];
const activityTypeOptions = [
{ value: "cafe", label: "咖啡馆聊天" },
{ value: "bookstore", label: "书店" },
{ value: "citywalk", label: "Citywalk" },
{ value: "side-hustle", label: "轻副业交流" },
{ value: "any", label: "都可以" },
];
const firstTimeOptions = ["是,第一次参加", "否,参加过类似活动"];
const availableTimeOptions = [
{ value: "sat-pm", label: "周六下午" },
{ value: "sun-pm", label: "周日下午" },
{ value: "both", label: "都可以" },
];
const REGIONS: Record<string, string[]> = {
深圳: ["南山区", "福田区", "罗湖区", "宝安区", "龙岗区", "其他"],
广州: ["天河区", "越秀区", "海珠区", "荔湾区", "白云区", "其他"],
惠州: ["惠城区", "惠阳区", "其他"],
};
interface FormData {
city: string;
session: string;
ageRange: string;
status: string;
activityType: string;
intro: string;
whyJoin: string;
nickname: string;
wechatOrPhone: string;
region: string;
firstTime: string;
availableTime: string;
agreeRules: boolean;
}
function getSessionFromUrl(): string {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("session") || "";
}
function getSessionInfo(sessionId: string) {
return SESSIONS.find((s) => s.id === sessionId);
}
export default function JoinPage() {
const [step, setStep] = useState<1 | 2>(1);
const [form, setForm] = useState<FormData>({
city: "",
session: "",
ageRange: "",
status: "",
activityType: "",
intro: "",
whyJoin: "",
nickname: "",
wechatOrPhone: "",
region: "",
firstTime: "",
availableTime: "",
agreeRules: false,
});
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const sid = getSessionFromUrl();
if (sid) {
const s = SESSIONS.find((x) => x.id === sid);
setForm((prev) => ({
...prev,
session: sid,
city: s?.city || prev.city,
}));
}
}, []);
const set = (key: keyof FormData, value: string | boolean) =>
setForm((prev) => ({ ...prev, [key]: value }));
const canProceedStep1 = form.city && form.session && form.ageRange && form.status && form.activityType && form.intro.trim() && form.whyJoin.trim();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const reasonParts = [
form.intro,
form.whyJoin,
form.status && `状态: ${statusOptions.find((o) => o.value === form.status)?.label || form.status}`,
form.activityType && `活动偏好: ${activityTypeOptions.find((o) => o.value === form.activityType)?.label || form.activityType}`,
form.region && `常活动区域: ${form.region}`,
form.availableTime && `可参加时间: ${availableTimeOptions.find((o) => o.value === form.availableTime)?.label || form.availableTime}`,
].filter(Boolean);
const res = await fetch("/api/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
city: form.city,
session: form.session,
nickname: form.nickname,
age_range: form.ageRange,
wechat_or_phone: form.wechatOrPhone,
intro: reasonParts.join(" | "),
first_time: form.firstTime,
agree_rules: form.agreeRules,
status: form.status,
activity_type: form.activityType,
why_join: form.whyJoin,
region: form.region,
available_time: form.availableTime,
}),
});
const data = await res.json();
if (!res.ok || !data?.ok) {
throw new Error(data?.error || "提交失败,请稍后重试");
}
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "网络错误,请重试");
} finally {
setSubmitting(false);
}
};
const selectedSession = getSessionInfo(form.session);
const regions = form.city ? (REGIONS[form.city] || []) : [];
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">
<div className="mx-auto mb-4 sm:mb-5 flex h-16 w-16 sm:h-20 sm:w-20 items-center justify-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 text-4xl sm:text-5xl">
✅
</div>
<h2 className="text-xl sm:text-2xl font-bold text-stone-800 dark:text-stone-100">报名成功</h2>
<p className="mt-2 sm:mt-3 text-sm sm:text-base text-stone-500 dark:text-stone-400">
我们会尽快通过您留下的联系方式与您确认。不是所有报名都会自动确认,我们会根据场次与匹配度联系合适报名者。
</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-0 py-0 sm:px-6 sm:py-8 lg:py-10">
{/* Trust bar */}
<div className="mx-4 sm:mx-0 mt-4 flex flex-wrap justify-center gap-x-6 gap-y-2 text-xs sm:text-sm text-stone-600 dark:text-stone-400 py-3">
<span>📍 公开场地</span>
<span>👥 6–8 人小组</span>
<span>🆓 不收费</span>
<span>🤝 尊重边界</span>
</div>
<div className="overflow-hidden bg-white dark:bg-stone-900 shadow-sm sm:rounded-t-2xl">
<div className="relative h-28 sm:h-36 md:h-44 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 md:text-6xl" aria-hidden>☕</div>
<div className="mt-1.5 sm:mt-2 text-xs sm:text-sm font-medium opacity-90 flex items-center gap-1.5">
<span>📍</span> 深圳 · 广州 · 惠州
</div>
</div>
</div>
<div className="px-4 sm:px-6 py-4 sm:py-5 text-center">
<h1 className="text-lg sm:text-xl md:text-2xl font-bold text-stone-800 dark:text-stone-100">
活动报名
</h1>
<p className="mt-2 sm:mt-3 text-xs sm:text-sm leading-relaxed text-stone-500 dark:text-stone-400">
先填表,我们会根据场次与匹配度联系合适报名者
</p>
<p className="mt-1.5 text-xs text-stone-400 dark:text-stone-500">
不是所有报名都会自动确认
</p>
{/* 进度提示 */}
<div className="mt-4 flex items-center justify-center gap-2">
<div className={`h-1.5 flex-1 max-w-[120px] rounded-full transition-colors ${step === 1 ? "bg-amber-500" : "bg-amber-200 dark:bg-amber-800"}`} />
<span className="text-xs font-medium text-stone-500 dark:text-stone-400">
{step === 1 ? "第一步" : "第二步"}
</span>
<div className={`h-1.5 flex-1 max-w-[120px] rounded-full transition-colors ${step === 2 ? "bg-amber-500" : "bg-stone-200 dark:bg-stone-700"}`} />
</div>
</div>
</div>
{selectedSession && (
<div className="mt-4 sm:mt-5 mx-4 sm:mx-0 rounded-xl bg-white dark:bg-stone-900 px-4 sm:px-5 py-3 sm:py-4 shadow-sm border border-stone-200 dark:border-stone-800 flex gap-3">
{selectedSession.coverImage && (
<div className="relative w-16 h-16 sm:w-20 sm:h-20 shrink-0 rounded-lg overflow-hidden">
<SessionCoverImage src={selectedSession.coverImage} alt="" type={selectedSession.type} fill sizes="72px" className="object-cover" />
</div>
)}
<div className="min-w-0 flex-1 text-left">
<p className="text-[10px] sm:text-xs text-stone-500 dark:text-stone-500 mb-0.5">已选场次</p>
<p className="font-medium text-stone-800 dark:text-stone-100 text-sm sm:text-base">{selectedSession.title}</p>
<p className="text-xs sm:text-sm text-stone-500 dark:text-stone-500">{selectedSession.date} {selectedSession.time} · {selectedSession.place}</p>
</div>
</div>
)}
<form
onSubmit={handleSubmit}
className="mt-0 sm:mt-5 bg-white dark:bg-stone-900 px-4 sm:px-6 md:px-8 pb-8 pt-6 sm:pt-8 shadow-sm sm:rounded-b-2xl sm:shadow-md border border-t-0 sm:border border-stone-200 dark:border-stone-800"
>
{step === 1 ? (
<div className="space-y-6">
<div className="space-y-4">
<h3 className="text-sm font-semibold text-stone-700 dark:text-stone-300">基本信息</h3>
<Field label="城市">
<CitySelect value={form.city} onChange={(v) => set("city", v)} required />
</Field>
<Field label="想参加的场次">
<select
value={form.session}
onChange={(e) => {
const sid = e.target.value;
const s = SESSIONS.find((x) => x.id === sid);
setForm((prev) => ({ ...prev, session: sid, city: s?.city || prev.city }));
}}
required
className={`form-input appearance-none ${form.session ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}
>
<option value="" disabled>请选择</option>
{SESSIONS.map((s) => (
<option key={s.id} value={s.id}>
{s.title} · {s.city} · {s.date} {s.time}
</option>
))}
</select>
</Field>
<Field label="年龄段">
<select
value={form.ageRange}
onChange={(e) => set("ageRange", e.target.value)}
required
className={`form-input appearance-none ${form.ageRange ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}
>
<option value="" disabled>请选择</option>
{ageOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
</Field>
<Field label="当前状态">
<select
value={form.status}
onChange={(e) => set("status", e.target.value)}
required
className={`form-input appearance-none ${form.status ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}
>
<option value="" disabled>请选择</option>
{statusOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
<Field label="想参加的活动类型">
<select
value={form.activityType}
onChange={(e) => set("activityType", e.target.value)}
required
className={`form-input appearance-none ${form.activityType ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}
>
<option value="" disabled>请选择</option>
{activityTypeOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
</div>
<div className="pt-4 border-t border-stone-100 dark:border-stone-700 space-y-4">
<h3 className="text-sm font-semibold text-stone-700 dark:text-stone-300">简单介绍</h3>
<Field label="一句话介绍自己">
<input
type="text"
maxLength={140}
placeholder="简单介绍一下自己"
value={form.intro}
onChange={(e) => set("intro", e.target.value)}
required
className="form-input"
/>
</Field>
<Field label="为什么想参加">
<input
type="text"
maxLength={200}
placeholder="简单说说你的期待"
value={form.whyJoin}
onChange={(e) => set("whyJoin", e.target.value)}
required
className="form-input"
/>
</Field>
</div>
<button
type="button"
onClick={() => setStep(2)}
disabled={!canProceedStep1}
className="w-full rounded-xl bg-amber-600 py-3 sm: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 disabled:hover:bg-amber-600 mt-6"
>
下一步:填写联系方式
</button>
</div>
) : (
<div className="space-y-6">
<button
type="button"
onClick={() => setStep(1)}
className="text-sm text-stone-500 dark:text-stone-400 hover:text-amber-600 dark:hover:text-amber-400"
>
← 返回上一步
</button>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-stone-700 dark:text-stone-300">联系方式与确认</h3>
<Field label="昵称">
<input
type="text"
maxLength={40}
placeholder="活动中使用的称呼"
value={form.nickname}
onChange={(e) => set("nickname", e.target.value)}
required
className="form-input"
/>
</Field>
<Field label="微信号或手机号">
<div>
<input
type="text"
maxLength={50}
placeholder="用于活动联系"
value={form.wechatOrPhone}
onChange={(e) => set("wechatOrPhone", e.target.value)}
required
className="form-input"
/>
<p className="mt-1.5 text-xs text-stone-500 dark:text-stone-400">
仅用于活动确认,不会公开给其他报名者
</p>
</div>
</Field>
<Field label="平时常活动区域">
<select
value={form.region}
onChange={(e) => set("region", e.target.value)}
className={`form-input appearance-none ${form.region ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}
>
<option value="">请选择(选填)</option>
{regions.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</Field>
<Field label="是否第一次参加类似活动">
<select
value={form.firstTime}
onChange={(e) => set("firstTime", e.target.value)}
required
className={`form-input appearance-none ${form.firstTime ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}
>
<option value="" disabled>请选择</option>
{firstTimeOptions.map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
</Field>
<Field label="可参加时间">
<select
value={form.availableTime}
onChange={(e) => set("availableTime", e.target.value)}
className={`form-input appearance-none ${form.availableTime ? "text-stone-800 dark:text-stone-100" : "text-stone-400 dark:text-stone-500"}`}
>
<option value="">请选择(选填)</option>
{availableTimeOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
</div>
<div className="pt-4 border-t border-stone-100 dark:border-stone-700">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={form.agreeRules}
onChange={(e) => set("agreeRules", 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>
<p className="text-xs text-stone-500 dark:text-stone-400">
提交后我们会根据场次与匹配度联系合适报名者,通过微信/短信确认地点与时间。若当前场次不匹配,不会反复打扰。
</p>
<div className="pt-2">
{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}
className="w-full rounded-xl bg-amber-600 py-3 sm:py-3.5 sm:py-4 text-base sm:text-lg font-medium text-white transition-colors hover:bg-amber-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
>
{submitting ? "提交中…" : "提交报名"}
</button>
</div>
</div>
)}
</form>
<div className="mt-4 sm:mt-6 text-center px-4">
<Link href="/" className="text-xs sm:text-sm text-stone-400 dark:text-stone-500 transition-colors hover:text-amber-600">
← 返回首页
</Link>
</div>
</div>
</div>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-2 py-3">
<label className="text-sm sm:text-base font-medium text-stone-700 dark:text-stone-300">
{label}
</label>
<div className="min-w-0">{children}</div>
</div>
);
}