192 lines
6.4 KiB
TypeScript
192 lines
6.4 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState, useEffect } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { api } from "@/lib/api";
|
||
import { useToast } from "@/lib/toast";
|
||
import { useAuth } from "@/lib/auth";
|
||
import { useI18n } from "@/lib/i18n";
|
||
import type { Meetup } from "@/lib/types";
|
||
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
|
||
import RingNext from "@/components/RingNext";
|
||
import { useRingSteps } from "@/lib/rings";
|
||
|
||
const MODE_LABELS: Record<string, string> = {
|
||
online: "💻 线上",
|
||
offline: "📍 线下",
|
||
hybrid: "🔀 混合",
|
||
};
|
||
|
||
interface Props {
|
||
meetups: Meetup[];
|
||
}
|
||
|
||
export default function MeetupsClient({ meetups }: Props) {
|
||
const { t } = useI18n();
|
||
const rings = useRingSteps();
|
||
const { toast } = useToast();
|
||
const { token } = useAuth();
|
||
const router = useRouter();
|
||
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
|
||
const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set());
|
||
const [busyId, setBusyId] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!token) {
|
||
setRsvpIds(new Set());
|
||
return;
|
||
}
|
||
api.getMyRsvps(token).then((r) => setRsvpIds(new Set(r.ids))).catch(() => {});
|
||
}, [token]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (mode === "all") return meetups;
|
||
return meetups.filter((m) => m.mode === mode);
|
||
}, [meetups, mode]);
|
||
|
||
const handleRsvp = async (meetup: Meetup) => {
|
||
if (!token) {
|
||
toast("登录后报名,名额才会保留", "info");
|
||
router.push(`/login?next=${encodeURIComponent("/meetups")}`);
|
||
return;
|
||
}
|
||
if (rsvpIds.has(meetup.id)) {
|
||
setBusyId(meetup.id);
|
||
try {
|
||
await api.cancelMeetupRsvp(token, meetup.id);
|
||
const next = new Set(rsvpIds);
|
||
next.delete(meetup.id);
|
||
setRsvpIds(next);
|
||
toast("已取消报名", "success");
|
||
} catch {
|
||
toast(t.meetups.rsvpFail, "error");
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
return;
|
||
}
|
||
setBusyId(meetup.id);
|
||
try {
|
||
const res = await api.rsvpMeetup(meetup.id, undefined, token);
|
||
setRsvpIds(new Set(rsvpIds).add(meetup.id));
|
||
toast(res.message || t.meetups.rsvpOk, "success");
|
||
} catch {
|
||
toast(t.meetups.rsvpFail, "error");
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="meetups-page">
|
||
<div className="container">
|
||
<nav className="detail-nav">
|
||
<Link href="/">{t.common.backHome}</Link>
|
||
<span> · </span>
|
||
<Link href="/community">{t.nav.community}</Link>
|
||
</nav>
|
||
|
||
<div className="section-header reveal">
|
||
<span className="section-tag">{t.meetups.tag}</span>
|
||
<h1>{t.meetups.title}</h1>
|
||
<p>{t.meetups.subtitle}</p>
|
||
{!token && (
|
||
<p className="meetups-login-hint">
|
||
<Link href="/login?next=/meetups">登录</Link> 后报名,名额会同步到你的账号
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="meetups-toolbar reveal">
|
||
<Link href="/meetups/host" className="btn btn-primary btn-sm">{t.meetups.hostBtn}</Link>
|
||
{(["all", "online", "offline", "hybrid"] as const).map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
className={`filter-btn${mode === m ? " active" : ""}`}
|
||
onClick={() => setMode(m)}
|
||
>
|
||
{m === "all" ? t.meetups.all : MODE_LABELS[m]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="meetups-grid">
|
||
{filtered.map((m) => {
|
||
const joined = rsvpIds.has(m.id);
|
||
return (
|
||
<article key={m.id} className="meetup-card reveal">
|
||
<div className="meetup-card-top">
|
||
<span className="meetup-emoji">{m.emoji}</span>
|
||
<div>
|
||
<h3>{m.title}</h3>
|
||
<p className="meetup-meta">
|
||
{MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<p className="meetup-desc">{m.description}</p>
|
||
<p className="meetup-venue">📍 {m.venue}</p>
|
||
<div className="meetup-tags">
|
||
{m.tags.map((tag) => (
|
||
<span key={tag} className="meetup-tag">{tag}</span>
|
||
))}
|
||
</div>
|
||
<footer className="meetup-footer">
|
||
<span>
|
||
{m.rsvp_count}/{m.max_attendees} {t.meetups.rsvp}
|
||
</span>
|
||
<span>{m.organizer}</span>
|
||
<div className="meetup-footer-actions">
|
||
{(m.mode === "online" || m.mode === "hybrid") && (
|
||
<Link href={`/meetups/${m.id}/live`} className="btn btn-sm meetup-live-btn">
|
||
{t.meetups.liveBtn}
|
||
</Link>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm${joined ? " btn-ghost is-done" : " btn-primary"}`}
|
||
onClick={() => handleRsvp(m)}
|
||
disabled={busyId === m.id}
|
||
>
|
||
{joined ? "取消报名" : t.meetups.rsvpBtn}
|
||
</button>
|
||
</div>
|
||
</footer>
|
||
{m.destination_slug && (
|
||
<Link href={`/destinations/${m.destination_slug}`} className="meetup-city-link">
|
||
{t.meetups.viewCity} →
|
||
</Link>
|
||
)}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{filtered.length === 0 && (
|
||
<div className="notif-empty reveal">
|
||
<p className="dest-empty">{t.meetups.empty}</p>
|
||
<div className="dating-empty-actions">
|
||
{mode !== "all" && (
|
||
<button type="button" className="btn btn-sm" onClick={() => setMode("all")}>
|
||
查看全部
|
||
</button>
|
||
)}
|
||
<Link href="/meetups/host" className="btn btn-primary btn-sm">
|
||
{t.meetups.hostBtn}
|
||
</Link>
|
||
<Link href="/community" className="btn btn-sm">
|
||
{t.nav.community}
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<NewsletterSubscribe source="meetups" />
|
||
<RingNext steps={rings.afterMeetups} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|