Require RSVP for live rooms, add schedule banner, and chat unread badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-09-04 09:00:01 -05:00
parent 5d741df77b
commit 67fd09341b
7 changed files with 149 additions and 20 deletions

View File

@ -372,6 +372,20 @@ async def meetup_session(meetup_id: str, authorization: str | None = Header(None
if mode not in ("online", "hybrid"): if mode not in ("online", "hybrid"):
can_join = False can_join = False
lock = lock or "offline" lock = lock or "offline"
elif can_join:
# Live rooms need login + RSVP (organizer exempt)
if not user:
can_join = False
lock = "login_required"
else:
organizer_id = meetup.get("organizer_id") or ""
is_host = bool(organizer_id) and organizer_id == user["id"]
if not is_host:
rsvps = community_store.user_rsvp_ids(user["id"])
mid = meetup.get("id") or meetup_id
if mid not in rsvps and meetup_id not in rsvps:
can_join = False
lock = "rsvp_required"
session = MeetupSession( session = MeetupSession(
canJoin=can_join, canJoin=can_join,

View File

@ -8,6 +8,14 @@ export const metadata: Metadata = {
}; };
const LOGS = [ const LOGS = [
{
date: "2026-09-04",
tag: "直播报名 · 私信角标",
items: [
"线上直播需登录并报名后进入;支持一键报名进房;显示活动时间;手机默认视频布局",
"右上角私信图标显示未读数",
],
},
{ {
date: "2026-09-04", date: "2026-09-04",
tag: "直播 iframe 修复", tag: "直播 iframe 修复",

View File

@ -11057,10 +11057,24 @@ a.weather-card:hover {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: var(--text-muted);
} }
.live-schedule-banner,
.live-schedule {
padding: 8px 20px;
font-size: 0.85rem;
color: var(--text-muted);
background: var(--bg-card);
border-bottom: var(--border-glass);
}
.live-schedule {
padding: 0;
border: none;
background: transparent;
margin-bottom: 0.5rem;
}
.live-stage { .live-stage {
flex: 1; flex: 1;
display: grid; display: grid;
min-height: calc(100vh - 110px); min-height: 0;
} }
.live-stage.layout-split { grid-template-columns: 1fr 380px; } .live-stage.layout-split { grid-template-columns: 1fr 380px; }
.live-stage.layout-video { grid-template-columns: 1fr; } .live-stage.layout-video { grid-template-columns: 1fr; }
@ -11202,10 +11216,14 @@ a.weather-card:hover {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-start;
} }
.live-external-links {
padding: 8px 12px;
}
.live-stage { .live-stage {
min-height: calc(100vh - 110px); min-height: 50vh;
} }
.live-stage.layout-split { grid-template-columns: 1fr; } .live-stage.layout-split { grid-template-columns: 1fr; }
.live-iframe { min-height: 50vh; }
.live-iframe { min-height: 280px; } .live-iframe { min-height: 280px; }
.live-chat { border-left: none; border-top: var(--border-glass); min-height: 280px; } .live-chat { border-left: none; border-top: var(--border-glass); min-height: 280px; }
} }

View File

@ -14,7 +14,12 @@ export default async function MeetupLivePage({ params }: { params: Promise<{ id:
return ( return (
<SiteShell showFooter={false}> <SiteShell showFooter={false}>
<MeetupLiveClient meetupId={id} title={title} /> <MeetupLiveClient
meetupId={id}
title={title}
date={meetup?.date || ""}
time={meetup?.time || ""}
/>
</SiteShell> </SiteShell>
); );
} }

View File

@ -5,19 +5,45 @@ import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import type { MeetupSession } from "@/lib/types"; import type { MeetupSession } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
export default function MeetupLiveClient({ meetupId, title }: { meetupId: string; title: string }) { type Layout = "split" | "video" | "chat";
function defaultLayout(): Layout {
if (typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches) {
return "video";
}
return "split";
}
export default function MeetupLiveClient({
meetupId,
title,
date = "",
time = "",
}: {
meetupId: string;
title: string;
date?: string;
time?: string;
}) {
const { token } = useAuth(); const { token } = useAuth();
const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [session, setSession] = useState<MeetupSession | null>(null); const [session, setSession] = useState<MeetupSession | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [layout, setLayout] = useState<"split" | "video" | "chat">("split"); const [layout, setLayout] = useState<Layout>("split");
const [rsvping, setRsvping] = useState(false);
useEffect(() => { useEffect(() => {
setLayout(defaultLayout());
}, []);
const loadSession = () => {
setError(false); setError(false);
setSession(null); setSession(null);
api api
@ -27,8 +53,29 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
setSession(null); setSession(null);
setError(true); setError(true);
}); });
};
useEffect(() => {
loadSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meetupId, token]); }, [meetupId, token]);
const handleRsvpAndJoin = async () => {
if (!token) {
return;
}
setRsvping(true);
try {
await api.rsvpMeetup(meetupId, undefined, token);
toast(t.meetups.rsvpOk, "success");
loadSession();
} catch {
toast(t.meetups.rsvpFail, "error");
} finally {
setRsvping(false);
}
};
if (error) { if (error) {
return ( return (
<div className="live-page"> <div className="live-page">
@ -64,26 +111,45 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
? t.live.hostRequired ? t.live.hostRequired
: reason === "offline" : reason === "offline"
? t.live.offlineOnly ? t.live.offlineOnly
: reason === "rsvp_required"
? t.live.rsvpRequired
: t.live.loginRequired; : t.live.loginRequired;
const href =
reason === "vip_required"
? "/join"
: reason === "offline"
? "/meetups"
: `/login?next=/meetups/${meetupId}/live`;
const label =
reason === "vip_required" ? t.join.payBtn : reason === "offline" ? t.live.back : t.nav.login;
return ( return (
<div className="live-page"> <div className="live-page">
<div className="container"> <div className="container">
<div className="live-gate reveal"> <div className="live-gate reveal">
<h2>{title}</h2> <h2>{title}</h2>
{(date || time) && (
<p className="live-schedule">
🗓️ {date}
{time ? ` ${time}` : ""}
</p>
)}
<p>{copy}</p> <p>{copy}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<Link href={href} className="btn btn-primary"> {reason === "rsvp_required" && token ? (
{label} <button
type="button"
className="btn btn-primary"
disabled={rsvping}
onClick={() => void handleRsvpAndJoin()}
>
{rsvping ? "…" : t.live.rsvpJoin}
</button>
) : reason === "vip_required" ? (
<Link href="/join" className="btn btn-primary">
{t.join.payBtn}
</Link> </Link>
) : reason === "offline" ? (
<Link href="/meetups" className="btn btn-primary">
{t.live.back}
</Link>
) : (
<Link href={`/login?next=/meetups/${meetupId}/live`} className="btn btn-primary">
{t.nav.login}
</Link>
)}
<Link href="/meetups" className="btn"> <Link href="/meetups" className="btn">
{t.live.back} {t.live.back}
</Link> </Link>
@ -94,6 +160,11 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
); );
} }
const scheduleLabel =
date || time
? t.live.startsAt.replace("{when}", `${date}${time ? ` ${time}` : ""}`.trim())
: "";
return ( return (
<div className="live-page"> <div className="live-page">
<div className="live-toolbar"> <div className="live-toolbar">
@ -112,6 +183,7 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
))} ))}
</div> </div>
</div> </div>
{scheduleLabel && <div className="live-schedule-banner">{scheduleLabel}</div>}
<div className="live-external-links"> <div className="live-external-links">
{session.videoUrl && ( {session.videoUrl && (
<a href={session.videoUrl} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-primary"> <a href={session.videoUrl} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-primary">

View File

@ -20,15 +20,21 @@ export default function Navbar() {
const [openGroup, setOpenGroup] = useState<string | null>(null); const [openGroup, setOpenGroup] = useState<string | null>(null);
const [active, setActive] = useState(""); const [active, setActive] = useState("");
const [unread, setUnread] = useState(0); const [unread, setUnread] = useState(0);
const [chatUnread, setChatUnread] = useState(0);
const navRef = useRef<HTMLElement>(null); const navRef = useRef<HTMLElement>(null);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
setUnread(0); setUnread(0);
setChatUnread(0);
return; return;
} }
const refresh = () => { const refresh = () => {
api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0)); api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0));
api
.getConversations(token)
.then((list) => setChatUnread(list.reduce((s, c) => s + (c.unreadCount || 0), 0)))
.catch(() => setChatUnread(0));
}; };
refresh(); refresh();
const id = window.setInterval(refresh, 60000); const id = window.setInterval(refresh, 60000);
@ -235,7 +241,7 @@ export default function Navbar() {
</button> </button>
{user ? ( {user ? (
<Link href="/chat" className="nav-notify" aria-label={t.nav.chat}> <Link href="/chat" className="nav-notify" aria-label={t.nav.chat}>
✉️ ✉️{chatUnread > 0 && <em className="nav-notify-badge">{chatUnread}</em>}
</Link> </Link>
) : ( ) : (
<Link <Link

View File

@ -509,7 +509,10 @@ export const zh = {
}, },
live: { live: {
loading: "加载直播间…", loading: "加载直播间…",
loginRequired: "登录并报名后可进入直播", loginRequired: "登录后可进入直播",
rsvpRequired: "报名后即可进入直播间",
rsvpJoin: "报名并进入",
startsAt: "活动时间:{when} · 到点后房间会更热闹",
vipRequired: "VIP 会员可进入此活动直播", vipRequired: "VIP 会员可进入此活动直播",
back: "返回活动", back: "返回活动",
unavailable: "无法打开直播间", unavailable: "无法打开直播间",
@ -1787,7 +1790,10 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
}, },
live: { live: {
loading: "Loading live room…", loading: "Loading live room…",
loginRequired: "Sign in and RSVP to join the live room", loginRequired: "Sign in to join the live room",
rsvpRequired: "RSVP to enter the live room",
rsvpJoin: "RSVP and join",
startsAt: "Starts {when} · the room fills closer to start",
vipRequired: "VIP required for this live event", vipRequired: "VIP required for this live event",
back: "Back to events", back: "Back to events",
unavailable: "Can't open live room", unavailable: "Can't open live room",