s
This commit is contained in:
392
app/[locale]/city/[slug]/volunteer/page.tsx
Normal file
392
app/[locale]/city/[slug]/volunteer/page.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user