"use client"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Link, useLocale, useTranslation } from "@/i18n/navigation"; import { apiFetch } from "@/app/lib/api-client"; import { buildMeetupChatUrl, buildMiroTalkJoinUrl, DEFAULT_ONLINE_FEATURES, featureLabels, getLoungeServerLabel, getMiroTalkServerLabel, MIROTALK_PROVIDER_LABEL, type MeetupAccessLevel, type MeetupMode, WEB_CHAT_PROVIDER_LABEL, } from "@/app/lib/meetups"; import { mediaUrl } from "@/app/lib/media"; import { avatarForIndex } from "@/app/data/avatars"; interface Attendee { name: string; photo: string; } interface Meetup { id: string; city: string; emoji: string; date: string; time: string; venue: string; address: string; description: string; rsvpCount: number; gradientFrom: string; gradientTo: string; attendees: Attendee[]; isUpcoming: boolean; mode: MeetupMode; accessLevel: MeetupAccessLevel; meetingProvider: string; mirotalkRoom: string; meetingUrl: string; replayUrl: string; features: string[]; maxAttendees: number; organizer: string; } interface ViewerUser { id?: string; email?: string; name?: string; vip?: boolean; role?: string; [key: string]: unknown; } interface ViewerState { loaded: boolean; user: ViewerUser | null; vip: boolean; } type ModeFilter = "all" | MeetupMode; const allMeetups: Meetup[] = [ { id: "online-1", city: "线上", emoji: "💻", date: "2026-07-09", time: "20:00", venue: "MiroTalk 多人视频房", address: "线上活动 · 自动生成入会链接", description: "围绕远程工作的节奏、异地协作和旅居规划做一场开放圆桌,适合刚加入社区的新成员。", rsvpCount: 36, gradientFrom: "#0f766e", gradientTo: "#2563eb", attendees: [{ name: "林晓", photo: avatarForIndex(0) }, { name: "张明", photo: avatarForIndex(4) }, { name: "陈静", photo: avatarForIndex(2) }], isUpcoming: true, mode: "online", accessLevel: "members", meetingProvider: "mirotalk", mirotalkRoom: "nomadcna-remote-work-open-office", meetingUrl: "", replayUrl: "", features: DEFAULT_ONLINE_FEATURES, maxAttendees: 80, organizer: "NomadCNA", }, { id: "1", city: "大理", emoji: "🏯", date: "2026-07-15", time: "14:00", venue: "大理古城人民路咖啡馆", address: "大理市大理古城人民路中段", description: "苍山脚下,洱海边,与大理的慢生活游民们一起喝咖啡、晒太阳、聊人生。", rsvpCount: 12, gradientFrom: "#3b82f6", gradientTo: "#4f46e5", attendees: [{ name: "林晓", photo: avatarForIndex(0) }, { name: "张明", photo: avatarForIndex(4) }, { name: "陈静", photo: avatarForIndex(2) }], isUpcoming: true, mode: "hybrid", accessLevel: "vip", meetingProvider: "mirotalk", mirotalkRoom: "nomadcna-dali-slow-life-roundtable", meetingUrl: "", replayUrl: "", features: ["group_video", "screen_share", "whiteboard", "recording", "host_controls"], maxAttendees: 32, organizer: "大理城市主理人", }, { id: "2", city: "成都", emoji: "🐼", date: "2026-07-18", time: "19:00", venue: "方所书店", address: "成都市锦江区中纱帽街8号远洋太古里", description: "天府之国游民聚会,在文艺书店里品茶聊天,探讨成都的慢生活与远程工作可能性。", rsvpCount: 9, gradientFrom: "#22c55e", gradientTo: "#10b981", attendees: [{ name: "杨帆", photo: avatarForIndex(28) }, { name: "赵磊", photo: avatarForIndex(10) }], isUpcoming: true, mode: "offline", accessLevel: "public", meetingProvider: "", mirotalkRoom: "", meetingUrl: "", replayUrl: "", features: [], maxAttendees: 20, organizer: "成都游民小组", }, { id: "3", city: "深圳", emoji: "🌃", date: "2026-07-20", time: "19:30", venue: "WeWork 卓越前海壹号", address: "深圳市南山区前海深港合作区卓越前海壹号", description: "深圳数字游民线下见面会,探讨创业、远程工作与湾区发展机遇。", rsvpCount: 15, gradientFrom: "#ff4d4f", gradientTo: "#ff7a45", attendees: [{ name: "李娜", photo: avatarForIndex(12) }, { name: "王强", photo: avatarForIndex(18) }, { name: "刘洋", photo: avatarForIndex(30) }], isUpcoming: true, mode: "hybrid", accessLevel: "members", meetingProvider: "mirotalk", mirotalkRoom: "nomadcna-shenzhen-maker-night", meetingUrl: "", replayUrl: "", features: ["group_video", "screen_share", "breakout", "host_controls"], maxAttendees: 48, organizer: "深圳湾区主理人", }, { id: "online-2", city: "亚太", emoji: "🌐", date: "2026-07-24", time: "21:00", venue: "MiroTalk 公开直播间", address: "线上活动 · 亚洲时区友好", description: "跨城市远程工作者分享工具栈、客户沟通和时区协作经验,公开旁听,登录成员可参与发言。", rsvpCount: 54, gradientFrom: "#7c3aed", gradientTo: "#0d9488", attendees: [{ name: "许婷", photo: avatarForIndex(6) }, { name: "马超", photo: avatarForIndex(17) }, { name: "罗敏", photo: avatarForIndex(15) }], isUpcoming: true, mode: "online", accessLevel: "public", meetingProvider: "mirotalk", mirotalkRoom: "nomadcna-asia-remote-stack", meetingUrl: "", replayUrl: "", features: ["group_video", "screen_share", "whiteboard"], maxAttendees: 120, organizer: "NomadCNA", }, { id: "5", city: "杭州", emoji: "⛲", date: "2026-07-25", time: "18:00", venue: "梦想小镇·良仓咖啡", address: "杭州市余杭区梦想小镇互联网村", description: "西湖畔数字游民聚会,在创业氛围浓厚的梦想小镇交流产品与运营经验。", rsvpCount: 11, gradientFrom: "#06b6d4", gradientTo: "#0d9488", attendees: [{ name: "周杰", photo: avatarForIndex(10) }, { name: "吴芳", photo: avatarForIndex(24) }], isUpcoming: true, mode: "offline", accessLevel: "public", meetingProvider: "", mirotalkRoom: "", meetingUrl: "", replayUrl: "", features: [], maxAttendees: 24, organizer: "杭州产品游民", }, { id: "6", city: "厦门", emoji: "🌊", date: "2026-07-28", time: "15:00", venue: "鼓浪屿海角8号咖啡馆", address: "厦门市思明区鼓浪屿龙头路", description: "海岛慢生活聚会,在鼓浪屿的文艺咖啡馆里结识热爱海边远程工作的游民朋友。", rsvpCount: 7, gradientFrom: "#0ea5e9", gradientTo: "#0284c7", attendees: [{ name: "赵磊", photo: avatarForIndex(10) }, { name: "黄薇", photo: avatarForIndex(22) }], isUpcoming: true, mode: "offline", accessLevel: "public", meetingProvider: "", mirotalkRoom: "", meetingUrl: "", replayUrl: "", features: [], maxAttendees: 18, organizer: "厦门海岛游民", }, { id: "7", city: "北京", emoji: "🏛️", date: "2026-07-30", time: "19:00", venue: "氪空间 望京SOHO", address: "北京市朝阳区望京街10号望京SOHO T3", description: "首都数字游民聚会,在望京科技圈交流北漂远程工作与创业故事。", rsvpCount: 14, gradientFrom: "#6366f1", gradientTo: "#4f46e5", attendees: [{ name: "陈思", photo: avatarForIndex(15) }, { name: "郑凯", photo: avatarForIndex(29) }], isUpcoming: true, mode: "hybrid", accessLevel: "hosts", meetingProvider: "mirotalk", mirotalkRoom: "nomadcna-beijing-host-lab", meetingUrl: "", replayUrl: "", features: ["group_video", "recording", "host_controls"], maxAttendees: 26, organizer: "北京活动主持组", }, { id: "10", city: "南京", emoji: "🍃", date: "2026-05-05", time: "19:00", venue: "先锋书店 五台山店", address: "南京市鼓楼区广州路173号五台山体育馆地下", description: "金陵数字游民聚会,在南京最具文艺气息的书店里交流古都文化与远程工作。", rsvpCount: 6, gradientFrom: "#84cc16", gradientTo: "#65a30d", attendees: [{ name: "杨帆", photo: avatarForIndex(28) }], isUpcoming: false, mode: "offline", accessLevel: "public", meetingProvider: "", mirotalkRoom: "", meetingUrl: "", replayUrl: "", features: [], maxAttendees: 18, organizer: "南京游民小组", }, { id: "11", city: "重庆", emoji: "🌶️", date: "2026-05-12", time: "18:30", venue: "洪崖洞观景咖啡馆", address: "重庆市渝中区嘉滨路88号洪崖洞", description: "山城数字游民聚会,在魔幻8D城市里结识热爱火锅与远程工作的游民朋友。", rsvpCount: 10, gradientFrom: "#dc2626", gradientTo: "#b91c1c", attendees: [{ name: "黄薇", photo: avatarForIndex(22) }, { name: "孙浩", photo: avatarForIndex(18) }], isUpcoming: false, mode: "hybrid", accessLevel: "members", meetingProvider: "mirotalk", mirotalkRoom: "nomadcna-chongqing-recap", meetingUrl: "", replayUrl: "", features: ["group_video", "recording"], maxAttendees: 30, organizer: "重庆游民小组", }, ]; const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; const copy = { zh: { center: "活动中心", onlineReady: "线上活动已接入 MiroTalk P2P 视频房", dataPolicy: "使用自建 MiroTalk 作为 WebRTC 信令/中继入口,按用户身份展示报名数据、视频入口、回放和主持人控场能力。", onlineStats: "线上 / 混合", mirotalkStats: "P2P 房间", permissionStats: "权限层级", allModes: "全部", offline: "线下", online: "线上", hybrid: "混合", people: "人报名", max: "上限", details: "查看详情", joinRoom: "进入视频房", joinChat: "进入群聊", embedRoom: "在当前页打开", closeEmbed: "收起房间", loginRequired: "登录成员可进入", vipRequired: "VIP 专属活动", hostRequired: "主持人权限", publicAccess: "公开", membersAccess: "登录成员", vipAccess: "VIP", hostsAccess: "主持人", organizer: "主办方", featureTitle: "活动功能", permissionTitle: "数据与权限", visiblePublic: "公开:时间、城市、主题、报名数", visibleMember: "登录成员:参会名单、线上房间、活动提醒", visibleVip: "VIP:回放、资料和专属圆桌", visibleHost: "主持人:房间管理、录制和权限配置", room: "房间", provider: "视频服务", chatService: "Web 群聊", chatServer: "群聊入口", relayServer: "信令/中继", noAccess: "当前身份不能进入该视频房", countPrefix: "共", upcomingCountSuffix: "场即将举办的活动", previousCountSuffix: "场往期活动", emptyUpcoming: "暂无即将举办的活动", emptyPrevious: "暂无往期活动记录", authPublicHint: "公开活动可直接打开,登录后会使用你的社区身份进入房间。", chatHint: "群聊默认进入 #nomadcna 与 #online-events。", }, en: { center: "Events Center", onlineReady: "Online events now support MiroTalk P2P rooms", dataPolicy: "Self-hosted MiroTalk provides the WebRTC signaling/relay entry while user identity controls attendee data, video access, replays, and host controls.", onlineStats: "Online / hybrid", mirotalkStats: "P2P rooms", permissionStats: "Access tiers", allModes: "All", offline: "Offline", online: "Online", hybrid: "Hybrid", people: "RSVPs", max: "Limit", details: "Details", joinRoom: "Join video room", joinChat: "Join group chat", embedRoom: "Open inline", closeEmbed: "Hide room", loginRequired: "Members only", vipRequired: "VIP only", hostRequired: "Hosts only", publicAccess: "Public", membersAccess: "Members", vipAccess: "VIP", hostsAccess: "Hosts", organizer: "Organizer", featureTitle: "Event features", permissionTitle: "Data & permissions", visiblePublic: "Public: time, city, topic, RSVP count", visibleMember: "Members: attendee list, online room, event reminders", visibleVip: "VIP: replays, resources, private roundtables", visibleHost: "Hosts: room management, recording, access rules", room: "Room", provider: "Video service", chatService: "Web group chat", chatServer: "Chat entry", relayServer: "Signaling / relay", noAccess: "Your current role cannot enter this video room", countPrefix: "", upcomingCountSuffix: "upcoming events", previousCountSuffix: "past events", emptyUpcoming: "No upcoming events yet", emptyPrevious: "No past events yet", authPublicHint: "Public events open directly. After login, your community identity is used in the room.", chatHint: "Chat opens with #nomadcna and #online-events by default.", }, }; function getCopy(locale: string) { return locale === "zh" ? copy.zh : copy.en; } function formatMeetupDate(dateStr: string, time: string): string { const normalizedDate = dateStr.includes("T") || dateStr.includes(" ") ? dateStr.slice(0, 10) : dateStr; const d = new Date(normalizedDate + "T" + time); if (Number.isNaN(d.getTime())) return `${normalizedDate} ${time.slice(0, 5)}`; const weekday = weekdays[d.getDay()]; const year = d.getFullYear(); const month = d.getMonth() + 1; const day = d.getDate(); const timeFormatted = time.slice(0, 5); return `${weekday}, ${year}年${month}月${day}日, ${timeFormatted}`; } function toMeetupMode(value: unknown): MeetupMode { return value === "online" || value === "hybrid" || value === "offline" ? value : "offline"; } function toAccessLevel(value: unknown): MeetupAccessLevel { return value === "members" || value === "vip" || value === "hosts" || value === "public" ? value : "public"; } function mapMeetup(item: Partial & { id: string; status?: string }): Meetup { const mode = toMeetupMode(item.mode || (item.meetingUrl || item.mirotalkRoom ? "online" : "offline")); return { id: item.id, city: item.city || (mode === "online" ? "线上" : "同城"), emoji: item.emoji || (mode === "online" ? "💻" : "📍"), date: item.date || new Date().toISOString().slice(0, 10), time: item.time || "19:00", venue: item.venue || (mode === "online" ? "MiroTalk 视频房" : "待定地点"), address: item.address || (mode === "online" ? "线上活动" : "活动地址确认中"), description: item.description || "数字游民交流活动。", rsvpCount: Number(item.rsvpCount || 0), gradientFrom: item.gradientFrom || (mode === "online" ? "#0f766e" : "#ff4d4f"), gradientTo: item.gradientTo || (mode === "online" ? "#2563eb" : "#ff7a45"), attendees: Array.isArray(item.attendees) ? item.attendees : [], isUpcoming: Boolean(item.isUpcoming ?? true), mode, accessLevel: toAccessLevel(item.accessLevel), meetingProvider: item.meetingProvider || (mode === "offline" ? "" : "mirotalk"), mirotalkRoom: item.mirotalkRoom || "", meetingUrl: item.meetingUrl || "", replayUrl: item.replayUrl || "", features: Array.isArray(item.features) ? item.features.map(String) : mode === "offline" ? [] : DEFAULT_ONLINE_FEATURES, maxAttendees: Number(item.maxAttendees || 0), organizer: item.organizer || "NomadCNA", }; } function modeLabel(mode: MeetupMode, locale: string) { const c = getCopy(locale); return mode === "online" ? c.online : mode === "hybrid" ? c.hybrid : c.offline; } function accessLabel(accessLevel: MeetupAccessLevel, locale: string) { const c = getCopy(locale); return { public: c.publicAccess, members: c.membersAccess, vip: c.vipAccess, hosts: c.hostsAccess, }[accessLevel]; } function viewerDisplayName(viewer: ViewerState): string { const name = String(viewer.user?.name || "").trim(); if (name) return name; const email = String(viewer.user?.email || "").trim(); return email ? email.split("@")[0] : "NomadCNA"; } function isHostViewer(viewer: ViewerState): boolean { const role = String(viewer.user?.role || viewer.user?.["memberRole"] || viewer.user?.["siteRole"] || "").toLowerCase(); return ["admin", "owner", "host", "organizer", "moderator"].includes(role); } function canAccessMeetup(meetup: Meetup, viewer: ViewerState): boolean { if (meetup.accessLevel === "public") return true; if (meetup.accessLevel === "members") return Boolean(viewer.user); if (meetup.accessLevel === "vip") return Boolean(viewer.vip || viewer.user?.vip); return isHostViewer(viewer); } function lockedReason(meetup: Meetup, locale: string) { const c = getCopy(locale); if (meetup.accessLevel === "members") return c.loginRequired; if (meetup.accessLevel === "vip") return c.vipRequired; if (meetup.accessLevel === "hosts") return c.hostRequired; return c.noAccess; } function Badge({ children, tone = "gray" }: { children: ReactNode; tone?: "gray" | "red" | "green" | "blue" | "purple" }) { const toneClass = { gray: "border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300", red: "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300", green: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300", blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300", purple: "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/60 dark:bg-violet-950/40 dark:text-violet-300", }[tone]; return ( {children} ); } function MeetupCard({ meetup, locale, viewer, onSelect }: { meetup: Meetup; locale: string; viewer: ViewerState; onSelect: (meetup: Meetup) => void }) { const c = getCopy(locale); const formattedDate = formatMeetupDate(meetup.date, meetup.time); const canJoin = canAccessMeetup(meetup, viewer); const modeTone = meetup.mode === "online" ? "green" : meetup.mode === "hybrid" ? "blue" : "gray"; const accessTone = meetup.accessLevel === "vip" ? "purple" : meetup.accessLevel === "hosts" ? "red" : meetup.accessLevel === "members" ? "blue" : "gray"; return (
{modeLabel(meetup.mode, locale)} {meetup.meetingProvider === "mirotalk" && MiroTalk}

{formattedDate}

{meetup.emoji} {meetup.city}

{meetup.rsvpCount} {c.people}
{modeLabel(meetup.mode, locale)} {accessLabel(meetup.accessLevel, locale)} {!!meetup.maxAttendees && {c.max} {meetup.maxAttendees}}

@ {meetup.venue}

{meetup.address}

{meetup.description}

{meetup.features.length > 0 && (
{featureLabels(meetup.features, locale).slice(0, 3).map((feature) => ( {feature} ))}
)}
{meetup.attendees.map((a, index) => ( {a.name} ))}
{locale === "zh" ? "已报名成员" : "attendees"}
); } function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup; locale: string; viewer: ViewerState; onClose: () => void }) { const c = getCopy(locale); const [embedOpen, setEmbedOpen] = useState(false); const canJoin = canAccessMeetup(meetup, viewer); const joinUrl = buildMiroTalkJoinUrl(meetup, viewerDisplayName(viewer)); const chatUrl = buildMeetupChatUrl(); const mirotalkServer = getMiroTalkServerLabel(); const loungeServer = getLoungeServerLabel(); const isVideoEvent = meetup.mode !== "offline"; return (
{modeLabel(meetup.mode, locale)} {accessLabel(meetup.accessLevel, locale)} {meetup.meetingProvider === "mirotalk" && {MIROTALK_PROVIDER_LABEL}}

{meetup.emoji} {meetup.city} · {meetup.venue}

{meetup.description}

{locale === "zh" ? "时间" : "Time"}

{formatMeetupDate(meetup.date, meetup.time)}

{c.organizer}

{meetup.organizer}

{locale === "zh" ? "地点" : "Location"}

{meetup.venue}

{meetup.address}

{isVideoEvent && (

{c.provider}

{MIROTALK_PROVIDER_LABEL}

{c.room}: {meetup.mirotalkRoom || meetup.id}

{c.relayServer}: {mirotalkServer}

{c.chatService}

{WEB_CHAT_PROVIDER_LABEL}

{c.chatServer}: {loungeServer}

{canJoin ? (
{c.joinRoom} {c.joinChat}
) : (

{lockedReason(meetup, locale)}

)}
{canJoin && meetup.accessLevel === "public" && (

{c.authPublicHint}

)} {canJoin && (

{c.chatHint}

)} {embedOpen && canJoin && (