Files
gitlab-instance-0a899031_salon/app/join/page.tsx
2026-03-15 22:57:54 -05:00

328 lines
13 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 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 firstTimeOptions = ["是,第一次参加", "否,参加过类似活动"];
interface FormData {
city: string;
session: string;
nickname: string;
ageRange: string;
wechatOrPhone: string;
intro: string;
firstTime: 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 [form, setForm] = useState<FormData>({
city: "",
session: "",
nickname: "",
ageRange: "",
wechatOrPhone: "",
intro: "",
firstTime: "",
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 handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
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: form.intro,
first_time: form.firstTime,
agree_rules: form.agreeRules,
}),
});
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);
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">
<div className="overflow-hidden bg-white dark:bg-stone-900 shadow-sm sm:rounded-t-2xl">
<div className="relative h-32 sm:h-40 md:h-52 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-5xl sm:text-6xl md:text-7xl" 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>
</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="80px" 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"
>
<div className="space-y-4 sm:space-y-0 sm:grid sm:grid-cols-2 sm:gap-6">
<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>
</div>
<div className="mt-4 sm:mt-6 space-y-4 sm:space-y-0 sm:grid sm:grid-cols-2 sm:gap-6">
<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="年龄段">
<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>
</div>
<div className="mt-4 sm:mt-6 pt-4 sm:pt-5 border-t border-stone-100 dark:border-stone-700">
<label className="mb-2 block text-sm sm:text-base font-bold text-stone-700 dark:text-stone-300">
</label>
<input
type="text"
maxLength={50}
placeholder="用于活动联系"
value={form.wechatOrPhone}
onChange={(e) => set("wechatOrPhone", e.target.value)}
required
className="form-input"
/>
</div>
<div className="mt-4 sm:mt-5 pt-4 sm:pt-5 border-t border-stone-100 dark:border-stone-700">
<label className="mb-2 block text-sm sm:text-base font-bold text-stone-700 dark:text-stone-300">
</label>
<input
type="text"
maxLength={140}
placeholder="简单介绍一下自己"
value={form.intro}
onChange={(e) => set("intro", e.target.value)}
required
className="form-input"
/>
</div>
<div className="mt-4 sm:mt-5 pt-4 sm:pt-5 border-t border-stone-100 dark:border-stone-700">
<label className="mb-2 block text-sm sm:text-base font-bold text-stone-700 dark:text-stone-300">
</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>
</div>
<div className="mt-4 sm:mt-5 pt-4 sm:pt-5 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>
<div className="mt-6 sm: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}
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>
</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="relative flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-0 py-3 sm:py-4 border-b border-stone-100 dark:border-stone-700 sm:border-b-0 last:border-b-0">
<label className="shrink-0 text-sm sm:text-base font-bold text-stone-700 dark:text-stone-300 sm:mb-0 sm:w-[100px]">
{label}
</label>
<div className="relative min-w-0 flex-1">{children}</div>
</div>
);
}