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>
145 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|