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 · 主题 · 通知** 固定在右上
|
||||
- `Ctrl+K` 空态只给三环捷径;次要页面要搜索才出现
|
||||
- 工具箱默认精选 +「展开全部」,不再当全站站点地图
|
||||
- **连接闭环:** 活动报名需登录(可取消);匹配 / 报名 / 讨论回复 / 赏金申请会写入通知中心;可选 ntfy 推送(`NTFY_ENABLED` + 设置里打开「推送」)
|
||||
- **订阅:** 写入 PocketBase;配置 `LISTMONK_*` 后同步 Listmonk,未配置时文案如实提示「已登记」
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
@ -24,6 +24,12 @@ class Settings(BaseSettings):
|
||||
ntfy_enabled: bool = False
|
||||
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)
|
||||
dev_auto_pay: bool = False
|
||||
payment_provider: str = "zpay"
|
||||
|
||||
@ -262,16 +262,30 @@ async def get_meetup(meetup_id: str):
|
||||
|
||||
@router.post("/meetups/rsvp")
|
||||
async def rsvp_meetup(body: MeetupRsvpRequest, authorization: str | None = Header(None)):
|
||||
user_id = None
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
user = get_user_by_token(authorization[7:])
|
||||
if user:
|
||||
user_id = user["id"]
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(401, "请先登录后再报名,以便保留名额")
|
||||
user = get_user_by_token(authorization[7:])
|
||||
if not user:
|
||||
raise HTTPException(401, "请先登录后再报名")
|
||||
user_id = user["id"]
|
||||
res = community_store.rsvp_meetup(body.meetup_id, user_id)
|
||||
if not res.get("ok"):
|
||||
if res.get("error") == "not_found":
|
||||
raise HTTPException(404, "活动不存在")
|
||||
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"]}
|
||||
|
||||
|
||||
|
||||
@ -38,6 +38,18 @@ async def post_reply(
|
||||
reply = community_store.add_reply(discussion_id, user["id"], user["name"], body.content, body.author_emoji)
|
||||
if not reply:
|
||||
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}
|
||||
|
||||
|
||||
@ -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)
|
||||
if not res.get("ok"):
|
||||
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])
|
||||
|
||||
@ -412,7 +412,12 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
|
||||
"gig_applications",
|
||||
{"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 ──────────────────────────────────────────────────────
|
||||
@ -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:
|
||||
_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})
|
||||
_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:
|
||||
|
||||
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 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"
|
||||
_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:
|
||||
"""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():
|
||||
dup = safe_first("subscriptions", filter=f"email={q(email)}")
|
||||
if dup:
|
||||
return {"ok": True, "message": "已订阅"}
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "已订阅" if synced else "已登记订阅(邮件系统稍后开通)",
|
||||
"synced": synced,
|
||||
}
|
||||
safe_create("subscriptions", {"email": email})
|
||||
return {"ok": True, "message": "订阅成功"}
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送",
|
||||
"synced": synced,
|
||||
}
|
||||
_jload()
|
||||
for n in _j.get("newsletter") or []:
|
||||
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()})
|
||||
_jsave()
|
||||
return {"ok": True, "message": "订阅成功"}
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "订阅成功" if synced else "已登记,邮件推送开通后会同步发送",
|
||||
"synced": synced,
|
||||
}
|
||||
|
||||
|
||||
def get_notif_prefs(user_id: str) -> dict:
|
||||
|
||||
@ -139,9 +139,8 @@ class PocketBaseService:
|
||||
|
||||
result = await self.create_record("subscriptions", {"email": email})
|
||||
if result:
|
||||
return True, "订阅成功!欢迎加入 nomadro 社区 🎉"
|
||||
# fallback: always succeed in demo mode
|
||||
return True, "订阅成功!欢迎加入 nomadro 社区 🎉"
|
||||
return True, "已登记订阅。邮件推送开通后会同步发送。"
|
||||
return False, "订阅暂时不可用,请稍后再试"
|
||||
|
||||
def _map_destination(self, r: dict) -> dict:
|
||||
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):
|
||||
matched = _ensure_match(user_id, profile, intent)
|
||||
if matched:
|
||||
from app.services import community_store
|
||||
from app.services.notify import notify_user
|
||||
|
||||
my_prof = get_public_profile(user_id) or {}
|
||||
peer_name = profile.get("name", "游民")
|
||||
conv_id = matched.get("conversationId", "")
|
||||
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")
|
||||
if peer_uid:
|
||||
community_store.add_notification(
|
||||
peer_uid, "匹配成功 🎉", f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧", link
|
||||
notify_user(
|
||||
peer_uid,
|
||||
"匹配成功 🎉",
|
||||
f"你和 {my_prof.get('name', '游民')} 互相喜欢了,快去聊天吧",
|
||||
link,
|
||||
"match",
|
||||
)
|
||||
|
||||
return {"ok": True, "matched": bool(matched), "match": matched}
|
||||
|
||||
@ -24,6 +24,12 @@ S3_UPLOAD_PREFIX=nomadweb
|
||||
NTFY_ENABLED=true
|
||||
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_KEY=
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
.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 Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
@ -26,11 +27,16 @@ export default function MeetupsClient({ meetups }: Props) {
|
||||
const rings = useRingSteps();
|
||||
const { toast } = useToast();
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
|
||||
const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set());
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
if (!token) {
|
||||
setRsvpIds(new Set());
|
||||
return;
|
||||
}
|
||||
api.getMyRsvps(token).then((r) => setRsvpIds(new Set(r.ids))).catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
@ -40,17 +46,35 @@ export default function MeetupsClient({ meetups }: Props) {
|
||||
}, [meetups, mode]);
|
||||
|
||||
const handleRsvp = async (meetup: Meetup) => {
|
||||
if (rsvpIds.has(meetup.id)) {
|
||||
toast(t.meetups.alreadyRsvp, "info");
|
||||
if (!token) {
|
||||
toast("登录后报名,名额才会保留", "info");
|
||||
router.push(`/login?next=${encodeURIComponent("/meetups")}`);
|
||||
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 {
|
||||
const res = await api.rsvpMeetup(meetup.id, undefined, token || undefined);
|
||||
const next = new Set(rsvpIds).add(meetup.id);
|
||||
setRsvpIds(next);
|
||||
const res = await api.rsvpMeetup(meetup.id, undefined, token);
|
||||
setRsvpIds(new Set(rsvpIds).add(meetup.id));
|
||||
toast(res.message || t.meetups.rsvpOk, "success");
|
||||
} catch {
|
||||
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>
|
||||
<h1>{t.meetups.title}</h1>
|
||||
<p>{t.meetups.subtitle}</p>
|
||||
{!token && (
|
||||
<p className="meetups-login-hint">
|
||||
<Link href="/login?next=/meetups">登录</Link> 后报名,名额会同步到你的账号
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="meetups-toolbar reveal">
|
||||
@ -84,57 +113,58 @@ export default function MeetupsClient({ meetups }: Props) {
|
||||
</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>
|
||||
{filtered.map((m) => {
|
||||
const joined = rsvpIds.has(m.id);
|
||||
return (
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
{m.destination_slug && (
|
||||
<Link href={`/destinations/${m.destination_slug}`} className="meetup-city-link">
|
||||
{t.meetups.viewCity} →
|
||||
</Link>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
<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-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>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="dest-empty">{t.meetups.empty}</p>
|
||||
)}
|
||||
{filtered.length === 0 && <p className="dest-empty">{t.meetups.empty}</p>}
|
||||
|
||||
<NewsletterSubscribe source="meetups" />
|
||||
<RingNext steps={rings.afterMeetups} />
|
||||
|
||||
@ -52,7 +52,7 @@ export default function NotificationSettingsClient() {
|
||||
<h1>{t.notifSettings.title}</h1>
|
||||
</div>
|
||||
<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">
|
||||
<span>{t.notifSettings[k]}</span>
|
||||
<input type="checkbox" checked={!!prefs[k]} onChange={() => toggle(k)} />
|
||||
|
||||
@ -617,6 +617,7 @@ export const zh = {
|
||||
match: "匹配通知",
|
||||
meetup: "活动提醒",
|
||||
community: "社区动态",
|
||||
push: "推送通知 (ntfy)",
|
||||
email: "邮件摘要",
|
||||
marketing: "活动营销",
|
||||
},
|
||||
@ -1256,6 +1257,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
match: "Match alerts",
|
||||
meetup: "Event reminders",
|
||||
community: "Community",
|
||||
push: "Push (ntfy)",
|
||||
email: "Email digest",
|
||||
marketing: "Marketing",
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user