Wire connect-ring notifications and durable meetup RSVP.
Require login for meetup RSVP with cancel, notify organizers/authors/posters on RSVP/reply/gig/match, optional ntfy + Listmonk hooks, and honest newsletter messaging. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
47bbc7dd0f
commit
f215b684f6
@ -21,6 +21,8 @@
|
|||||||
- 顶栏:**探索 / 连接 / 成长** 三组下拉,**EN · 主题 · 通知** 固定在右上
|
- 顶栏:**探索 / 连接 / 成长** 三组下拉,**EN · 主题 · 通知** 固定在右上
|
||||||
- `Ctrl+K` 空态只给三环捷径;次要页面要搜索才出现
|
- `Ctrl+K` 空态只给三环捷径;次要页面要搜索才出现
|
||||||
- 工具箱默认精选 +「展开全部」,不再当全站站点地图
|
- 工具箱默认精选 +「展开全部」,不再当全站站点地图
|
||||||
|
- **连接闭环:** 活动报名需登录(可取消);匹配 / 报名 / 讨论回复 / 赏金申请会写入通知中心;可选 ntfy 推送(`NTFY_ENABLED` + 设置里打开「推送」)
|
||||||
|
- **订阅:** 写入 PocketBase;配置 `LISTMONK_*` 后同步 Listmonk,未配置时文案如实提示「已登记」
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,12 @@ class Settings(BaseSettings):
|
|||||||
ntfy_enabled: bool = False
|
ntfy_enabled: bool = False
|
||||||
ntfy_url: str = "http://127.0.0.1:2586"
|
ntfy_url: str = "http://127.0.0.1:2586"
|
||||||
|
|
||||||
|
# Optional Listmonk newsletter (leave empty = store only)
|
||||||
|
listmonk_url: str = ""
|
||||||
|
listmonk_user: str = ""
|
||||||
|
listmonk_password: str = ""
|
||||||
|
listmonk_list_id: int = 1
|
||||||
|
|
||||||
# Payment (from env on server)
|
# Payment (from env on server)
|
||||||
dev_auto_pay: bool = False
|
dev_auto_pay: bool = False
|
||||||
payment_provider: str = "zpay"
|
payment_provider: str = "zpay"
|
||||||
|
|||||||
@ -262,16 +262,30 @@ async def get_meetup(meetup_id: str):
|
|||||||
|
|
||||||
@router.post("/meetups/rsvp")
|
@router.post("/meetups/rsvp")
|
||||||
async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)):
|
async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)):
|
||||||
user_id = None
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
if authorization and authorization.startswith("Bearer "):
|
raise HTTPException(401, "请先登录后再报名,以便保留名额")
|
||||||
user = get_user_by_token(authorization[7:])
|
user = get_user_by_token(authorization[7:])
|
||||||
if user:
|
if not user:
|
||||||
user_id = user["id"]
|
raise HTTPException(401, "请先登录后再报名")
|
||||||
|
user_id = user["id"]
|
||||||
res = community_store.rsvp_meetup(body.meetup_id, user_id)
|
res = community_store.rsvp_meetup(body.meetup_id, user_id)
|
||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
if res.get("error") == "not_found":
|
if res.get("error") == "not_found":
|
||||||
raise HTTPException(404, "活动不存在")
|
raise HTTPException(404, "活动不存在")
|
||||||
raise HTTPException(400, "活动已满员")
|
raise HTTPException(400, "活动已满员")
|
||||||
|
|
||||||
|
meetup = community_store.get_meetup(body.meetup_id) or {}
|
||||||
|
organizer_id = meetup.get("organizer_id") or ""
|
||||||
|
if organizer_id and organizer_id != user_id:
|
||||||
|
from app.services.notify import notify_user
|
||||||
|
|
||||||
|
notify_user(
|
||||||
|
organizer_id,
|
||||||
|
"新的活动报名",
|
||||||
|
f"{user.get('name', '游民')} 报名了「{meetup.get('title', '活动')}」",
|
||||||
|
f"/meetups",
|
||||||
|
"meetup",
|
||||||
|
)
|
||||||
return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]}
|
return {"success": True, "message": res["message"], "rsvp_count": res["rsvp_count"]}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,18 @@ async def post_reply(
|
|||||||
reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji)
|
reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji)
|
||||||
if not reply:
|
if not reply:
|
||||||
raise HTTPException(404, "讨论不存在")
|
raise HTTPException(404, "讨论不存在")
|
||||||
|
discussion = community_store.get_discussion(discussion_id, increment_view=False) or {}
|
||||||
|
author_id = discussion.get("author_id") or ""
|
||||||
|
if author_id and author_id != user["id"]:
|
||||||
|
from app.services.notify import notify_user
|
||||||
|
|
||||||
|
notify_user(
|
||||||
|
author_id,
|
||||||
|
"讨论有新回复",
|
||||||
|
f"{user['name']} 回复了「{discussion.get('title', '话题')}」",
|
||||||
|
f"/community/{discussion_id}",
|
||||||
|
"community",
|
||||||
|
)
|
||||||
return {"success": True, "reply": reply}
|
return {"success": True, "reply": reply}
|
||||||
|
|
||||||
|
|
||||||
@ -87,7 +99,18 @@ async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | Non
|
|||||||
res = community_store.apply_gig(gig_id, user["id"], user["name"], body.message)
|
res = community_store.apply_gig(gig_id, user["id"], user["name"], body.message)
|
||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
raise HTTPException(400, res.get("error", "申请失败"))
|
raise HTTPException(400, res.get("error", "申请失败"))
|
||||||
return res
|
poster_id = res.get("poster_id") or ""
|
||||||
|
if poster_id and poster_id != user["id"]:
|
||||||
|
from app.services.notify import notify_user
|
||||||
|
|
||||||
|
notify_user(
|
||||||
|
poster_id,
|
||||||
|
"赏金任务有新申请",
|
||||||
|
f"{user['name']} 申请了「{res.get('title') or '任务'}」",
|
||||||
|
"/gigs",
|
||||||
|
"gig",
|
||||||
|
)
|
||||||
|
return {"ok": True, "message": res.get("message", "申请已提交")}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/notifications", response_model=list[NotificationItem])
|
@router.get("/notifications", response_model=list[NotificationItem])
|
||||||
|
|||||||
@ -412,7 +412,12 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
|
|||||||
"gig_applications",
|
"gig_applications",
|
||||||
{"gigId": g["id"], "userId": user_id, "applicant": user_name, "message": message, "status": "pending"},
|
{"gigId": g["id"], "userId": user_id, "applicant": user_name, "message": message, "status": "pending"},
|
||||||
)
|
)
|
||||||
return {"ok": True, "message": "申请已提交"}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "申请已提交",
|
||||||
|
"poster_id": g.get("posterId", ""),
|
||||||
|
"title": g.get("title", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Notifications ──────────────────────────────────────────────────────
|
# ── Notifications ──────────────────────────────────────────────────────
|
||||||
@ -727,9 +732,15 @@ def _json_create_gig(user_id: str, user_name: str, payload: dict) -> dict:
|
|||||||
|
|
||||||
def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
|
def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
|
||||||
_jload()
|
_jload()
|
||||||
|
gig = next((g for g in (_j.get("gigs") or []) if g.get("id") == gig_id), {}) or {}
|
||||||
_j.setdefault("gig_apps", []).append({"gig_id": gig_id, "user_id": user_id, "user_name": user_name, "message": message})
|
_j.setdefault("gig_apps", []).append({"gig_id": gig_id, "user_id": user_id, "user_name": user_name, "message": message})
|
||||||
_jsave()
|
_jsave()
|
||||||
return {"ok": True, "message": "申请已提交"}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "申请已提交",
|
||||||
|
"poster_id": gig.get("poster_id", ""),
|
||||||
|
"title": gig.get("title", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _json_add_notification(user_id: str, title: str, body: str, link: str, category: str) -> None:
|
def _json_add_notification(user_id: str, title: str, body: str, link: str, category: str) -> None:
|
||||||
|
|||||||
58
backend/app/services/notify.py
Normal file
58
backend/app/services/notify.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"""Unified in-app + optional ntfy push notifications."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services import community_store, platform_store
|
||||||
|
from app.services.ntfy_push import send_push
|
||||||
|
|
||||||
|
_PREF_KEY = {
|
||||||
|
"match": "match",
|
||||||
|
"meetup": "meetup",
|
||||||
|
"community": "community",
|
||||||
|
"gig": "community",
|
||||||
|
"general": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _topic_for(user_id: str) -> str:
|
||||||
|
safe = re.sub(r"[^a-zA-Z0-9_-]", "", user_id)[:48] or "guest"
|
||||||
|
return f"nomadro-{safe}"
|
||||||
|
|
||||||
|
|
||||||
|
def notify_user(
|
||||||
|
user_id: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
link: str = "",
|
||||||
|
category: str = "general",
|
||||||
|
) -> bool:
|
||||||
|
"""Persist inbox notification; optionally fire ntfy when push prefs allow."""
|
||||||
|
if not user_id:
|
||||||
|
return False
|
||||||
|
prefs = platform_store.get_notif_prefs(user_id)
|
||||||
|
pref_key = _PREF_KEY.get(category)
|
||||||
|
if pref_key is not None and not prefs.get(pref_key, True):
|
||||||
|
return False
|
||||||
|
|
||||||
|
community_store.add_notification(user_id, title, body, link, category)
|
||||||
|
|
||||||
|
if prefs.get("push") and settings.ntfy_enabled:
|
||||||
|
click = link if link.startswith("http") else f"{settings.site_base_url.rstrip('/')}{link or '/notifications'}"
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.create_task(
|
||||||
|
send_push(
|
||||||
|
_topic_for(user_id),
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
tags=["nomadro", category],
|
||||||
|
click_url=click,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
# Outside async context — best-effort sync skip; inbox already saved
|
||||||
|
pass
|
||||||
|
return True
|
||||||
@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb
|
from app.services.pb_repo import q, safe_create, safe_first, safe_list, safe_update, use_pb
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json"
|
_JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json"
|
||||||
_j: dict = {}
|
_j: dict = {}
|
||||||
@ -117,19 +118,55 @@ def add_report(reporter_id: str | None, target_type: str, target_id: str, reason
|
|||||||
|
|
||||||
|
|
||||||
def subscribe_newsletter(email: str, source: str = "site") -> dict:
|
def subscribe_newsletter(email: str, source: str = "site") -> dict:
|
||||||
|
"""Save subscription locally; sync to Listmonk when configured."""
|
||||||
|
email = email.strip().lower()
|
||||||
|
synced = False
|
||||||
|
if settings.listmonk_url and settings.listmonk_user:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
base = settings.listmonk_url.rstrip("/")
|
||||||
|
auth = (settings.listmonk_user, settings.listmonk_password)
|
||||||
|
payload = {
|
||||||
|
"email": email,
|
||||||
|
"name": email.split("@")[0],
|
||||||
|
"status": "enabled",
|
||||||
|
"lists": [settings.listmonk_list_id],
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=8.0) as client:
|
||||||
|
r = client.post(f"{base}/api/subscribers", json=payload, auth=auth)
|
||||||
|
if r.status_code in (200, 409):
|
||||||
|
synced = True
|
||||||
|
elif r.status_code == 400 and "exists" in (r.text or "").lower():
|
||||||
|
synced = True
|
||||||
|
except Exception:
|
||||||
|
synced = False
|
||||||
|
|
||||||
if use_pb():
|
if use_pb():
|
||||||
dup = safe_first("subscriptions", filter=f"email={q(email)}")
|
dup = safe_first("subscriptions", filter=f"email={q(email)}")
|
||||||
if dup:
|
if dup:
|
||||||
return {"ok": True, "message": "已订阅"}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "已订阅" if synced else "已登记订阅(邮件系统稍后开通)",
|
||||||
|
"synced": synced,
|
||||||
|
}
|
||||||
safe_create("subscriptions", {"email": email})
|
safe_create("subscriptions", {"email": email})
|
||||||
return {"ok": True, "message": "订阅成功"}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送",
|
||||||
|
"synced": synced,
|
||||||
|
}
|
||||||
_jload()
|
_jload()
|
||||||
for n in _j.get("newsletter") or []:
|
for n in _j.get("newsletter") or []:
|
||||||
if n["email"] == email:
|
if n["email"] == email:
|
||||||
return {"ok": True, "message": "已订阅"}
|
return {"ok": True, "message": "已订阅", "synced": synced}
|
||||||
_j.setdefault("newsletter", []).append({"email": email, "source": source, "created_at": _now()})
|
_j.setdefault("newsletter", []).append({"email": email, "source": source, "created_at": _now()})
|
||||||
_jsave()
|
_jsave()
|
||||||
return {"ok": True, "message": "订阅成功"}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送",
|
||||||
|
"synced": synced,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_notif_prefs(user_id: str) -> dict:
|
def get_notif_prefs(user_id: str) -> dict:
|
||||||
|
|||||||
@ -139,9 +139,8 @@ class PocketBaseService:
|
|||||||
|
|
||||||
result = await self.create_record("subscriptions", {"email": email})
|
result = await self.create_record("subscriptions", {"email": email})
|
||||||
if result:
|
if result:
|
||||||
return True, "订阅成功!欢迎加入 nomadro 社区 🎉"
|
return True, "已登记订阅。邮件推送开通后会同步发送。"
|
||||||
# fallback: always succeed in demo mode
|
return False, "订阅暂时不可用,请稍后再试"
|
||||||
return True, "订阅成功!欢迎加入 nomadro 社区 🎉"
|
|
||||||
|
|
||||||
def _map_destination(self, r: dict) -> dict:
|
def _map_destination(self, r: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -396,17 +396,21 @@ def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dic
|
|||||||
if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id):
|
if action in LIKE_ACTIONS and _has_reciprocal_like(profile.get("userId", ""), profile_id):
|
||||||
matched = _ensure_match(user_id, profile, intent)
|
matched = _ensure_match(user_id, profile, intent)
|
||||||
if matched:
|
if matched:
|
||||||
from app.services import community_store
|
from app.services.notify import notify_user
|
||||||
|
|
||||||
my_prof = get_public_profile(user_id) or {}
|
my_prof = get_public_profile(user_id) or {}
|
||||||
peer_name = profile.get("name", "游民")
|
peer_name = profile.get("name", "游民")
|
||||||
conv_id = matched.get("conversationId", "")
|
conv_id = matched.get("conversationId", "")
|
||||||
link = f"/chat/{conv_id}" if conv_id else "/chat"
|
link = f"/chat/{conv_id}" if conv_id else "/chat"
|
||||||
community_store.add_notification(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link)
|
notify_user(user_id, "匹配成功 🎉", f"你和 {peer_name} 互相喜欢了,快去聊天吧", link, "match")
|
||||||
peer_uid = profile.get("userId")
|
peer_uid = profile.get("userId")
|
||||||
if peer_uid:
|
if peer_uid:
|
||||||
community_store.add_notification(
|
notify_user(
|
||||||
peer_uid, "匹配成功 🎉", f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧", link
|
peer_uid,
|
||||||
|
"匹配成功 🎉",
|
||||||
|
f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧",
|
||||||
|
link,
|
||||||
|
"match",
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"ok": True, "matched": bool(matched), "match": matched}
|
return {"ok": True, "matched": bool(matched), "match": matched}
|
||||||
|
|||||||
@ -24,6 +24,12 @@ S3_UPLOAD_PREFIX=nomadweb
|
|||||||
NTFY_ENABLED=true
|
NTFY_ENABLED=true
|
||||||
NTFY_URL=http://127.0.0.1:2586
|
NTFY_URL=http://127.0.0.1:2586
|
||||||
|
|
||||||
|
# Optional Listmonk (leave empty = store subscriptions only)
|
||||||
|
LISTMONK_URL=
|
||||||
|
LISTMONK_USER=
|
||||||
|
LISTMONK_PASSWORD=
|
||||||
|
LISTMONK_LIST_ID=1
|
||||||
|
|
||||||
ZPAY_PID=
|
ZPAY_PID=
|
||||||
ZPAY_KEY=
|
ZPAY_KEY=
|
||||||
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php
|
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php
|
||||||
|
|||||||
@ -11004,3 +11004,13 @@ img { max-width: 100%; display: block; }
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meetups-login-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.meetups-login-hint a {
|
||||||
|
color: var(--accent-3, #4ecdc4);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useEffect } from "react";
|
import { useMemo, useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useToast } from "@/lib/toast";
|
import { useToast } from "@/lib/toast";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
@ -26,11 +27,16 @@ export default function MeetupsClient({ meetups }: Props) {
|
|||||||
const rings = useRingSteps();
|
const rings = useRingSteps();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
|
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
|
||||||
const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set());
|
const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set());
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) {
|
||||||
|
setRsvpIds(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
api.getMyRsvps(token).then((r) => setRsvpIds(new Set(r.ids))).catch(() => {});
|
api.getMyRsvps(token).then((r) => setRsvpIds(new Set(r.ids))).catch(() => {});
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
@ -40,17 +46,35 @@ export default function MeetupsClient({ meetups }: Props) {
|
|||||||
}, [meetups, mode]);
|
}, [meetups, mode]);
|
||||||
|
|
||||||
const handleRsvp = async (meetup: Meetup) => {
|
const handleRsvp = async (meetup: Meetup) => {
|
||||||
if (rsvpIds.has(meetup.id)) {
|
if (!token) {
|
||||||
toast(t.meetups.alreadyRsvp, "info");
|
toast("登录后报名,名额才会保留", "info");
|
||||||
|
router.push(`/login?next=${encodeURIComponent("/meetups")}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (rsvpIds.has(meetup.id)) {
|
||||||
|
setBusyId(meetup.id);
|
||||||
|
try {
|
||||||
|
await api.cancelMeetupRsvp(token, meetup.id);
|
||||||
|
const next = new Set(rsvpIds);
|
||||||
|
next.delete(meetup.id);
|
||||||
|
setRsvpIds(next);
|
||||||
|
toast("已取消报名", "success");
|
||||||
|
} catch {
|
||||||
|
toast(t.meetups.rsvpFail, "error");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusyId(meetup.id);
|
||||||
try {
|
try {
|
||||||
const res = await api.rsvpMeetup(meetup.id, undefined, token || undefined);
|
const res = await api.rsvpMeetup(meetup.id, undefined, token);
|
||||||
const next = new Set(rsvpIds).add(meetup.id);
|
setRsvpIds(new Set(rsvpIds).add(meetup.id));
|
||||||
setRsvpIds(next);
|
|
||||||
toast(res.message || t.meetups.rsvpOk, "success");
|
toast(res.message || t.meetups.rsvpOk, "success");
|
||||||
} catch {
|
} catch {
|
||||||
toast(t.meetups.rsvpFail, "error");
|
toast(t.meetups.rsvpFail, "error");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,6 +91,11 @@ export default function MeetupsClient({ meetups }: Props) {
|
|||||||
<span className="section-tag">{t.meetups.tag}</span>
|
<span className="section-tag">{t.meetups.tag}</span>
|
||||||
<h1>{t.meetups.title}</h1>
|
<h1>{t.meetups.title}</h1>
|
||||||
<p>{t.meetups.subtitle}</p>
|
<p>{t.meetups.subtitle}</p>
|
||||||
|
{!token && (
|
||||||
|
<p className="meetups-login-hint">
|
||||||
|
<Link href="/login?next=/meetups">登录</Link> 后报名,名额会同步到你的账号
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="meetups-toolbar reveal">
|
<div className="meetups-toolbar reveal">
|
||||||
@ -84,57 +113,58 @@ export default function MeetupsClient({ meetups }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="meetups-grid">
|
<div className="meetups-grid">
|
||||||
{filtered.map((m) => (
|
{filtered.map((m) => {
|
||||||
<article key={m.id} className="meetup-card reveal">
|
const joined = rsvpIds.has(m.id);
|
||||||
<div className="meetup-card-top">
|
return (
|
||||||
<span className="meetup-emoji">{m.emoji}</span>
|
<article key={m.id} className="meetup-card reveal">
|
||||||
<div>
|
<div className="meetup-card-top">
|
||||||
<h3>{m.title}</h3>
|
<span className="meetup-emoji">{m.emoji}</span>
|
||||||
<p className="meetup-meta">
|
<div>
|
||||||
{MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time}
|
<h3>{m.title}</h3>
|
||||||
</p>
|
<p className="meetup-meta">
|
||||||
|
{MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="meetup-desc">{m.description}</p>
|
||||||
<p className="meetup-desc">{m.description}</p>
|
<p className="meetup-venue">📍 {m.venue}</p>
|
||||||
<p className="meetup-venue">📍 {m.venue}</p>
|
<div className="meetup-tags">
|
||||||
<div className="meetup-tags">
|
{m.tags.map((tag) => (
|
||||||
{m.tags.map((tag) => (
|
<span key={tag} className="meetup-tag">{tag}</span>
|
||||||
<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>
|
</div>
|
||||||
</footer>
|
<footer className="meetup-footer">
|
||||||
{m.destination_slug && (
|
<span>
|
||||||
<Link href={`/destinations/${m.destination_slug}`} className="meetup-city-link">
|
{m.rsvp_count}/{m.max_attendees} {t.meetups.rsvp}
|
||||||
{t.meetups.viewCity} →
|
</span>
|
||||||
</Link>
|
<span>{m.organizer}</span>
|
||||||
)}
|
<div className="meetup-footer-actions">
|
||||||
</article>
|
{(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-sm${joined ? " btn-ghost is-done" : " btn-primary"}`}
|
||||||
|
onClick={() => handleRsvp(m)}
|
||||||
|
disabled={busyId === m.id}
|
||||||
|
>
|
||||||
|
{joined ? "取消报名" : 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>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && <p className="dest-empty">{t.meetups.empty}</p>}
|
||||||
<p className="dest-empty">{t.meetups.empty}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NewsletterSubscribe source="meetups" />
|
<NewsletterSubscribe source="meetups" />
|
||||||
<RingNext steps={rings.afterMeetups} />
|
<RingNext steps={rings.afterMeetups} />
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export default function NotificationSettingsClient() {
|
|||||||
<h1>{t.notifSettings.title}</h1>
|
<h1>{t.notifSettings.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="join-card reveal">
|
<div className="join-card reveal">
|
||||||
{(["match", "meetup", "community", "email", "marketing"] as const).map((k) => (
|
{(["match", "meetup", "community", "push", "email", "marketing"] as const).map((k) => (
|
||||||
<label key={k} className="notif-pref-row">
|
<label key={k} className="notif-pref-row">
|
||||||
<span>{t.notifSettings[k]}</span>
|
<span>{t.notifSettings[k]}</span>
|
||||||
<input type="checkbox" checked={!!prefs[k]} onChange={() => toggle(k)} />
|
<input type="checkbox" checked={!!prefs[k]} onChange={() => toggle(k)} />
|
||||||
|
|||||||
@ -617,6 +617,7 @@ export const zh = {
|
|||||||
match: "匹配通知",
|
match: "匹配通知",
|
||||||
meetup: "活动提醒",
|
meetup: "活动提醒",
|
||||||
community: "社区动态",
|
community: "社区动态",
|
||||||
|
push: "推送通知 (ntfy)",
|
||||||
email: "邮件摘要",
|
email: "邮件摘要",
|
||||||
marketing: "活动营销",
|
marketing: "活动营销",
|
||||||
},
|
},
|
||||||
@ -1256,6 +1257,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
match: "Match alerts",
|
match: "Match alerts",
|
||||||
meetup: "Event reminders",
|
meetup: "Event reminders",
|
||||||
community: "Community",
|
community: "Community",
|
||||||
|
push: "Push (ntfy)",
|
||||||
email: "Email digest",
|
email: "Email digest",
|
||||||
marketing: "Marketing",
|
marketing: "Marketing",
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user