Require RSVP for live rooms, add schedule banner, and chat unread badge.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5d741df77b
commit
67fd09341b
@ -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,
|
||||||
|
|||||||
@ -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 修复",
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user