Files
gitlab-instance-0a899031_cn…/app/[locale]/city/[slug]/volunteer/page.tsx
root b65908e328 s
2026-06-08 10:02:16 +08:00

393 lines
15 KiB
TypeScript

"use client";
import { use, useState, type FormEvent } from "react";
import { Link, useLocale } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
import { citySlug, findCityBySlug } from "@/app/lib/city-slugs";
interface CityVolunteerPageProps {
params: Promise<{ slug: string }>;
}
interface VolunteerForm {
applicantName: string;
email: string;
wechat: string;
phone: string;
currentCity: string;
availability: string;
localExperience: string;
motivation: string;
links: string;
roles: string[];
}
const ROLE_OPTIONS = {
zh: ["城市资料维护", "新成员 CoffeeChat", "地陪路线协助", "Citywalk/徒步带队", "同城活动协助", "线上社群答疑"],
en: ["City data updates", "New member CoffeeChat", "Local guide routes", "Citywalk / hiking host", "City event support", "Online community support"],
};
const copy = {
zh: {
notFound: "城市不存在",
back: "返回首页",
kicker: "城市志愿者招募",
titleSuffix: "城市志愿者申请",
subtitle: "帮助维护城市资料、迎新 CoffeeChat、组织同城活动或带 Citywalk。提交后会进入审核状态。",
status: "提交后状态:待审核",
applicantName: "姓名 / 昵称",
applicantNamePlaceholder: "方便社区联系你的称呼",
email: "邮箱",
wechat: "微信",
phone: "手机",
currentCity: "当前所在城市",
availability: "可投入时间",
availabilityPlaceholder: "例如:每周 2 小时 / 周末可带活动",
roles: "想参与的志愿者方向",
localExperience: "你和这个城市的关系",
localExperiencePlaceholder: "例如:已在这里居住 1 年,熟悉咖啡馆、短租和徒步路线。",
motivation: "申请说明",
motivationPlaceholder: "你希望如何帮助这个城市的新成员?",
links: "补充资料链接",
linksPlaceholder: "可选:个人主页、社群资料、作品链接等",
contactHint: "邮箱、微信、手机至少填写一项。",
roleHint: "至少选择一个志愿者方向。",
submit: "提交申请",
submitting: "提交中...",
successTitle: "申请已提交",
successDesc: "当前状态:待审核。审核通过后,社区会把你加入该城市的志愿者协作名单。",
submitAnother: "再提交一份",
viewCity: "返回城市列表",
},
en: {
notFound: "City not found",
back: "Back home",
kicker: "City volunteer recruiting",
titleSuffix: "Volunteer application",
subtitle: "Help maintain city data, welcome new members, support local events, or host citywalks. Applications enter pending review after submission.",
status: "After submission: pending review",
applicantName: "Name / nickname",
applicantNamePlaceholder: "How the community should contact you",
email: "Email",
wechat: "WeChat",
phone: "Phone",
currentCity: "Current city",
availability: "Availability",
availabilityPlaceholder: "e.g. 2 hours weekly / weekends for events",
roles: "Volunteer areas",
localExperience: "Your connection to this city",
localExperiencePlaceholder: "e.g. I have lived here for one year and know cafes, short rentals, and hiking routes.",
motivation: "Application note",
motivationPlaceholder: "How would you help new members in this city?",
links: "Supporting links",
linksPlaceholder: "Optional: profile, community record, portfolio links",
contactHint: "Please provide at least one contact: email, WeChat, or phone.",
roleHint: "Please select at least one volunteer area.",
submit: "Submit application",
submitting: "Submitting...",
successTitle: "Application submitted",
successDesc: "Current status: pending review. Once approved, the community will add you to this city's volunteer roster.",
submitAnother: "Submit another",
viewCity: "Back to cities",
},
};
function getCopy(locale: string) {
return locale === "zh" ? copy.zh : copy.en;
}
const initialForm: VolunteerForm = {
applicantName: "",
email: "",
wechat: "",
phone: "",
currentCity: "",
availability: "",
localExperience: "",
motivation: "",
links: "",
roles: [],
};
export default function CityVolunteerPage({ params }: CityVolunteerPageProps) {
const { slug } = use(params);
const city = findCityBySlug(slug);
const locale = useLocale();
const c = getCopy(locale);
const roleOptions = locale === "zh" ? ROLE_OPTIONS.zh : ROLE_OPTIONS.en;
const [form, setForm] = useState<VolunteerForm>(initialForm);
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!city) {
return (
<main className="min-h-screen bg-[#fafafa] px-4 py-16 dark:bg-gray-950">
<section className="mx-auto max-w-[560px] rounded-2xl border border-gray-100 bg-white p-8 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{c.notFound}</h1>
<Link href="/" className="mt-6 inline-flex rounded-xl bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#ff7a45]">
{c.back}
</Link>
</section>
</main>
);
}
const slugToSubmit = citySlug(city);
const setField = <K extends keyof VolunteerForm>(field: K, value: VolunteerForm[K]) => {
setForm((current) => ({ ...current, [field]: value }));
};
const toggleRole = (role: string) => {
setForm((current) => {
const selected = new Set(current.roles);
if (selected.has(role)) selected.delete(role);
else selected.add(role);
return { ...current, roles: Array.from(selected) };
});
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setError(null);
if (!form.email.trim() && !form.wechat.trim() && !form.phone.trim()) {
setError(c.contactHint);
return;
}
if (form.roles.length === 0) {
setError(c.roleHint);
return;
}
setSubmitting(true);
try {
await apiFetch(`/api/cities/${slugToSubmit}/volunteer-applications`, {
method: "POST",
body: JSON.stringify({
citySlug: slugToSubmit,
cityName: city.name,
...form,
}),
});
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Submit failed");
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
<main className="min-h-screen bg-[#fafafa] px-4 py-12 dark:bg-gray-950 sm:py-20">
<section className="mx-auto max-w-[620px] rounded-2xl border border-gray-100 bg-white p-8 text-center shadow-lg dark:border-gray-800 dark:bg-gray-900 sm:p-12">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-50 text-xl font-bold text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300">
OK
</div>
<p className="mb-2 text-sm font-semibold text-[#ff4d4f]">{city.name}</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{c.successTitle}</h1>
<p className="mt-3 leading-7 text-gray-500 dark:text-gray-400">{c.successDesc}</p>
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
<button
type="button"
onClick={() => {
setSubmitted(false);
setForm(initialForm);
}}
className="rounded-xl border border-gray-200 px-6 py-3 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
{c.submitAnother}
</button>
<Link href="/" className="rounded-xl bg-[#ff4d4f] px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#ff7a45]">
{c.viewCity}
</Link>
</div>
</section>
</main>
);
}
return (
<main className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<section
className="px-4 py-10 text-white sm:px-6 sm:py-14"
style={{ background: `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})` }}
>
<div className="mx-auto max-w-[920px]">
<Link href="/" className="mb-5 inline-flex text-sm font-medium text-white/80 transition-colors hover:text-white">
{c.back}
</Link>
<p className="text-sm font-semibold text-white/80">{c.kicker}</p>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-normal sm:text-4xl">
{city.name} {c.titleSuffix}
</h1>
<p className="mt-3 max-w-[660px] text-sm leading-7 text-white/85 sm:text-base">{c.subtitle}</p>
</div>
<span className="w-fit rounded-full bg-white/20 px-3 py-1.5 text-sm font-semibold text-white backdrop-blur-sm">
{c.status}
</span>
</div>
</div>
</section>
<section className="mx-auto max-w-[920px] px-4 py-8 sm:px-6 sm:py-10">
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900 sm:p-8">
<div className="grid gap-4 sm:grid-cols-2">
<TextField
label={`${c.applicantName} *`}
value={form.applicantName}
required
placeholder={c.applicantNamePlaceholder}
onChange={(value) => setField("applicantName", value)}
/>
<TextField
label={c.currentCity}
value={form.currentCity}
placeholder={city.name}
onChange={(value) => setField("currentCity", value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<TextField label={c.email} type="email" value={form.email} onChange={(value) => setField("email", value)} />
<TextField label={c.wechat} value={form.wechat} onChange={(value) => setField("wechat", value)} />
<TextField label={c.phone} type="tel" value={form.phone} onChange={(value) => setField("phone", value)} />
</div>
<TextField
label={c.availability}
value={form.availability}
placeholder={c.availabilityPlaceholder}
onChange={(value) => setField("availability", value)}
/>
<fieldset>
<legend className="mb-3 block text-sm font-semibold text-gray-800 dark:text-gray-200">{c.roles} *</legend>
<div className="grid gap-2 sm:grid-cols-2">
{roleOptions.map((role) => {
const checked = form.roles.includes(role);
return (
<label
key={role}
className={`flex min-h-11 cursor-pointer items-center gap-3 rounded-xl border px-4 py-3 text-sm font-medium transition-colors ${
checked
? "border-[#ff4d4f] bg-red-50 text-[#c7252a] dark:border-red-500/70 dark:bg-red-950/20 dark:text-red-200"
: "border-gray-200 text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleRole(role)}
className="h-4 w-4 rounded border-gray-300 text-[#ff4d4f] focus:ring-[#ff4d4f]"
/>
<span>{role}</span>
</label>
);
})}
</div>
</fieldset>
<TextArea
label={`${c.localExperience} *`}
value={form.localExperience}
required
placeholder={c.localExperiencePlaceholder}
onChange={(value) => setField("localExperience", value)}
/>
<TextArea
label={`${c.motivation} *`}
value={form.motivation}
required
placeholder={c.motivationPlaceholder}
onChange={(value) => setField("motivation", value)}
/>
<TextArea
label={c.links}
value={form.links}
rows={3}
placeholder={c.linksPlaceholder}
onChange={(value) => setField("links", value)}
/>
{error && (
<p className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm font-medium text-red-700 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-300">
{error}
</p>
)}
<button
type="submit"
disabled={submitting}
className="min-h-12 w-full rounded-xl bg-[#ff4d4f] px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#ff7a45] disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto"
>
{submitting ? c.submitting : c.submit}
</button>
</form>
</section>
</main>
);
}
function TextField({
label,
value,
onChange,
placeholder = "",
type = "text",
required = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: string;
required?: boolean;
}) {
return (
<label className="block">
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>
<input
type={type}
required={required}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/15 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</label>
);
}
function TextArea({
label,
value,
onChange,
placeholder = "",
rows = 4,
required = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
required?: boolean;
}) {
return (
<label className="block">
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>
<textarea
rows={rows}
required={required}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="form-input w-full resize-none rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/15 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</label>
);
}