nomadweb/frontend/src/components/MeetupsClient.tsx
eric 775f90dc6d Restructure features into linked rings instead of flat dumps.
Group nav into Explore/Connect/Grow, slim home and tools surfaces, and add progressive next-step CTAs across hubs so users follow one path at a time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 18:57:10 -05:00

145 lines
4.8 KiB
TypeScript

"use client";
import { useMemo, useState, useEffect } from "react";
import Link from "next/link";
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 [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set());
useEffect(() => {
if (!token) 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 (rsvpIds.has(meetup.id)) {
toast(t.meetups.alreadyRsvp, "info");
return;
}
try {
const res = await api.rsvpMeetup(meetup.id, undefined, token || undefined);
const next = new Set(rsvpIds).add(meetup.id);
setRsvpIds(next);
toast(res.message || t.meetups.rsvpOk, "success");
} catch {
toast(t.meetups.rsvpFail, "error");
}
};
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>
</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) => (
<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-primary btn-sm${rsvpIds.has(m.id) ? " is-done" : ""}`}
onClick={() => handleRsvp(m)}
disabled={rsvpIds.has(m.id)}
>
{rsvpIds.has(m.id) ? t.meetups.rsvpDone : 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 && (
<p className="dest-empty">{t.meetups.empty}</p>
)}
<NewsletterSubscribe source="meetups" />
<RingNext steps={rings.afterMeetups} />
</div>
</div>
);
}