nomadweb/frontend/src/components/MeetupsClient.tsx
eric 0ce48023ae Polish explore hubs: feedback API, live gates, AI tips, empty states.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:45:58 -05:00

192 lines
6.4 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 { 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>
);
}