feat: complete NomadCNA parity layer with platform APIs and pages

Add services, videos, AI assistant, map/weather, gigs post, notifications settings, static legal pages, Google OAuth, payment router, real-user matching, newsletter, and content submission — all using nomadweb UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-08-30 10:13:17 -05:00
parent ff7b721acb
commit 48408d3079
60 changed files with 2406 additions and 21 deletions

2
.gitignore vendored
View File

@ -9,7 +9,7 @@ frontend/.env.local
# Auth / plan user store (runtime)
backend/app/data/user_store.json
backend/app/data/social_store.json
backend/app/data/community_store.json
backend/app/data/platform_store.json
# Production secrets (use deploy/nomadro-api.env.example)
deploy/nomadro-api.env

View File

@ -0,0 +1,116 @@
"""Platform content: services, videos, help, member map seeds."""
SERVICES = [
{
"id": "visa-consult",
"title": "签证方案咨询",
"description": "一对一梳理 D7/D8/DTV 等长期签证路径,含材料清单与时间线。",
"emoji": "📋",
"price": "¥299 起",
"category": "签证",
"provider": "nomadro 认证顾问",
},
{
"id": "tax-review",
"title": "税务居民评估",
"description": "跨境收入、183 天规则与双重征税协定初步评估(非正式法律意见)。",
"emoji": "🧾",
"price": "¥399 起",
"category": "税务",
"provider": "合作会计师",
},
{
"id": "relocation",
"title": "落地安家陪跑",
"description": "选房区、办 SIM、开户、联合办公 —— 7 天线上陪跑。",
"emoji": "🏠",
"price": "¥899 起",
"category": "落地",
"provider": "城市志愿者",
},
{
"id": "remote-job",
"title": "远程简历优化",
"description": "面向欧美远程岗位的英文简历与 LinkedIn 优化。",
"emoji": "💼",
"price": "¥199 起",
"category": "职业",
"provider": "HR 志愿者",
},
]
VIDEOS = [
{
"id": "chiangmai-2025",
"slug": "chiangmai-2025",
"title": "清迈:月入 8000 的游民一天",
"excerpt": "联合办公、夜市、签证续签与社区活动实录。",
"emoji": "🌴",
"duration": "18:42",
"guest": "小林",
"city": "清迈",
"published_at": "2026-03-12",
"tags": ["泰国", "成本", "社区"],
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
},
{
"id": "lisbon-d7",
"slug": "lisbon-d7",
"title": "里斯本 D7 签证上岸访谈",
"excerpt": "从材料准备到 NIF、银行账户的完整时间线。",
"emoji": "🇵🇹",
"duration": "24:10",
"guest": "Marco",
"city": "里斯本",
"published_at": "2026-02-08",
"tags": ["葡萄牙", "签证"],
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
},
{
"id": "bali-cowork",
"slug": "bali-cowork",
"title": "巴厘岛联合办公巡礼",
"excerpt": "对比 Canggu / Ubud 五家热门空间网速与月票。",
"emoji": "🏝️",
"duration": "15:33",
"guest": "Yuki",
"city": "巴厘岛",
"published_at": "2026-01-20",
"tags": ["印尼", "办公"],
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
},
]
MEMBER_MAP = [
{"name": "小林", "city": "清迈", "lat": 18.79, "lng": 98.98, "emoji": "🧑‍💻"},
{"name": "Marco", "city": "里斯本", "lat": 38.72, "lng": -9.14, "emoji": "🇵🇹"},
{"name": "Yuki", "city": "巴厘岛", "lat": -8.34, "lng": 115.09, "emoji": "🏝️"},
{"name": "Alex", "city": "墨西哥城", "lat": 19.43, "lng": -99.13, "emoji": "🌮"},
{"name": "Sofia", "city": "巴塞罗那", "lat": 41.39, "lng": 2.17, "emoji": "☀️"},
{"name": "Ken", "city": "东京", "lat": 35.68, "lng": 139.69, "emoji": "🗼"},
{"name": "Lina", "city": "大理", "lat": 25.60, "lng": 100.27, "emoji": "🏔️"},
{"name": "Omar", "city": "迪拜", "lat": 25.20, "lng": 55.27, "emoji": "🏙️"},
]
CITY_RANKING = [
{"slug": "chiangmai", "name": "清迈", "score": 92, "nomads": "12k+"},
{"slug": "lisbon", "name": "里斯本", "score": 89, "nomads": "8k+"},
{"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"},
{"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"},
{"slug": "barcelona", "name": "巴塞罗那", "score": 84, "nomads": "7k+"},
]
DAILY_TIPS = {
"1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"},
"2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"},
"3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"},
}
WEATHER_CITIES = {
"chiangmai": {"lat": 18.79, "lng": 98.98, "name": "清迈"},
"lisbon": {"lat": 38.72, "lng": -9.14, "name": "里斯本"},
"bali": {"lat": -8.34, "lng": 115.09, "name": "巴厘岛"},
"mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"},
"barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"},
"tokyo": {"lat": 35.68, "lng": 139.69, "name": "东京"},
}

View File

@ -2,7 +2,7 @@
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import api, auth, community, payment, social
from app.routers import api, auth, community, payment, platform, social
app = FastAPI(
title="nomadro API",
@ -22,6 +22,8 @@ app.include_router(api.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
app.include_router(social.router, prefix="/api/v1")
app.include_router(community.router, prefix="/api/v1")
app.include_router(payment.router, prefix="/api/v1")
app.include_router(platform.router, prefix="/api/v1")
@app.get("/")

View File

@ -263,7 +263,7 @@ async def list_discussions(category: str | None = Query(None)):
@router.get("/discussions/{discussion_id}", response_model=DiscussionDetail)
async def get_discussion(discussion_id: str):
d = community_store.get_discussion(discussion_id)
d = community_store.get_discussion(discussion_id, increment_view=True)
if not d:
raise HTTPException(404, "讨论不存在")
return DiscussionDetail(**d)

View File

@ -1,13 +1,15 @@
from fastapi import APIRouter, Header, HTTPException
import httpx
from app.schemas import (
AuthLogin, AuthRegister, AuthResponse, Destination,
AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest,
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
UserPlanPayload,
)
from app.services.auth import (
get_favorites, get_plan, get_user_by_token,
login_demo, login_user, register_user, save_plan, toggle_favorite,
login_demo, login_google, login_user, register_user, save_plan, toggle_favorite,
)
from app.services.pocketbase import pb
@ -48,6 +50,29 @@ async def demo_login():
return AuthResponse(**result)
@router.post("/google", response_model=AuthResponse)
async def google_login(body: GoogleLoginRequest):
try:
async with httpx.AsyncClient(timeout=10) as client:
res = await client.get(
"https://oauth2.googleapis.com/tokeninfo",
params={"id_token": body.id_token},
)
res.raise_for_status()
data = res.json()
except httpx.HTTPError:
raise HTTPException(401, "Google 登录验证失败")
email = data.get("email")
verified = data.get("email_verified")
if not email or verified not in (True, "true"):
raise HTTPException(401, "无效的 Google 账号")
name = data.get("name") or email.split("@")[0]
result = login_google(email, name)
if not result:
raise HTTPException(500, "登录失败")
return AuthResponse(**result)
@router.get("/favorites", response_model=FavoriteResponse)
async def list_favorites(authorization: str | None = Header(None)):
token = _extract_token(authorization)

View File

@ -3,7 +3,7 @@
from fastapi import APIRouter, Header, HTTPException, Query
from app.schemas import (
CreateDiscussionRequest, CreateMeetupRequest, CreateReplyRequest,
CreateDiscussionRequest, CreateGigRequest, CreateMeetupRequest, CreateReplyRequest,
FeedbackRequest, GigApplyRequest, GigItem, NotificationItem, StatsOverview,
)
from app.services.auth import get_user_by_token
@ -74,6 +74,13 @@ async def list_gigs():
return [GigItem(**g) for g in community_store.list_gigs()]
@router.post("/gigs")
async def create_gig(body: CreateGigRequest, authorization: str | None = Header(None)):
user = _user(authorization)
g = community_store.create_gig(user["id"], user["name"], body.model_dump())
return {"success": True, "gig": g}
@router.post("/gigs/{gig_id}/apply")
async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | None = Header(None)):
user = _user(authorization)
@ -96,6 +103,17 @@ async def mark_read(authorization: str | None = Header(None)):
return {"success": True}
@router.post("/notifications/{notif_id}/{action}")
async def notification_action(notif_id: str, action: str, authorization: str | None = Header(None)):
user = _user(authorization)
if action not in ("read", "unread", "archive", "restore", "pin", "unpin"):
raise HTTPException(400, "无效操作")
n = community_store.update_notification(user["id"], notif_id, action)
if not n:
raise HTTPException(404, "通知不存在")
return {"success": True, "notification": n}
@router.post("/feedback")
async def submit_feedback(body: FeedbackRequest, authorization: str | None = Header(None)):
user = None

View File

@ -0,0 +1,185 @@
"""Platform APIs: services, videos, weather, AI, stats, content."""
from fastapi import APIRouter, Header, HTTPException, Query
from app.schemas import (
AiAssistantRequest, AiAssistantResponse, ContentSubmissionRequest,
NewsletterRequest, ReportRequest, ServiceItem, ServiceLeadRequest,
VideoItem, VolunteerRequest,
)
from app.data import platform_data
from app.services import community_store, social_store
from app.services.auth import get_user_by_token
from app.services import platform_store
from app.services.weather_service import get_city_weather, list_weather
from app.services.ai_service import assistant_reply
from app.services.pocketbase import pb
router = APIRouter(tags=["platform"])
def _optional_user(authorization: str | None) -> dict | None:
if authorization and authorization.startswith("Bearer "):
return get_user_by_token(authorization[7:])
return None
def _user(authorization: str | None) -> dict:
u = _optional_user(authorization)
if not u:
raise HTTPException(401, "未登录")
return u
@router.get("/services", response_model=list[ServiceItem])
async def list_services():
return [ServiceItem(**s) for s in platform_data.SERVICES]
@router.post("/services/leads")
async def service_lead(body: ServiceLeadRequest, authorization: str | None = Header(None)):
user = _optional_user(authorization)
item = platform_store.add_service_lead(
body.service_id,
user["id"] if user else None,
body.name,
body.email,
body.message,
)
return {"success": True, "id": item["id"]}
@router.get("/videos", response_model=list[VideoItem])
async def list_videos():
return [VideoItem(**v) for v in platform_data.VIDEOS]
@router.get("/videos/{slug}", response_model=VideoItem)
async def get_video(slug: str):
v = next((x for x in platform_data.VIDEOS if x["slug"] == slug), None)
if not v:
raise HTTPException(404, "视频不存在")
return VideoItem(**v)
@router.get("/content/home")
async def content_home():
return {
"featured": platform_data.VIDEOS[:2],
"videos_count": len(platform_data.VIDEOS),
"services_count": len(platform_data.SERVICES),
}
@router.post("/content/submissions")
async def content_submission(body: ContentSubmissionRequest, authorization: str | None = Header(None)):
user = _optional_user(authorization)
item = platform_store.add_content_submission(
user["id"] if user else None,
body.name,
body.title,
body.content_type,
body.url,
body.notes,
)
return {"success": True, "id": item["id"]}
@router.get("/weather/cities")
async def weather_cities():
return await list_weather()
@router.get("/weather/cities/{slug}")
async def weather_city(slug: str):
w = await get_city_weather(slug)
if not w:
raise HTTPException(404, "城市不存在")
return w
@router.post("/ai/assistant", response_model=AiAssistantResponse)
async def ai_assistant(body: AiAssistantRequest):
dests = await pb.get_destinations()
result = assistant_reply(body.message, dests)
return AiAssistantResponse(**result)
@router.post("/newsletter/subscribe")
async def newsletter_subscribe(body: NewsletterRequest):
res = platform_store.subscribe_newsletter(body.email, body.source)
return res
@router.get("/stats/member-map")
async def member_map():
return {"members": platform_data.MEMBER_MAP}
@router.get("/stats/city-ranking")
async def city_ranking():
return {"items": platform_data.CITY_RANKING}
@router.post("/cities/{slug}/volunteer-applications")
async def volunteer_apply(slug: str, body: VolunteerRequest, authorization: str | None = Header(None)):
user = _optional_user(authorization)
item = platform_store.add_volunteer(slug, user["id"] if user else None, body.name, body.email, body.message)
return {"success": True, "id": item["id"]}
@router.post("/reports")
async def submit_report(body: ReportRequest, authorization: str | None = Header(None)):
user = _optional_user(authorization)
item = platform_store.add_report(
user["id"] if user else None,
body.target_type,
body.target_id,
body.reason,
)
return {"success": True, "id": item["id"]}
@router.get("/digital/day/{day_id}")
async def digital_day(day_id: str):
tip = platform_data.DAILY_TIPS.get(day_id)
if not tip:
raise HTTPException(404, "每日指南不存在")
return {"id": day_id, **tip}
@router.get("/notifications/unread-count")
async def unread_count(authorization: str | None = Header(None)):
user = _user(authorization)
return {"count": community_store.notification_unread_count(user["id"])}
@router.get("/notifications/preferences")
async def get_notif_prefs(authorization: str | None = Header(None)):
user = _user(authorization)
return platform_store.get_notif_prefs(user["id"])
@router.put("/notifications/preferences")
async def set_notif_prefs(body: dict, authorization: str | None = Header(None)):
user = _user(authorization)
return platform_store.set_notif_prefs(user["id"], body)
@router.get("/discussions/search")
async def search_discussions(q: str = Query(..., min_length=1)):
return community_store.search_discussions(q)
@router.get("/meetups/{meetup_id}/social-suggestions")
async def meetup_social(meetup_id: str):
return {"items": community_store.meetup_social_suggestions(meetup_id)}
@router.delete("/meetups/{meetup_id}/rsvp")
async def cancel_rsvp(meetup_id: str, authorization: str | None = Header(None)):
user = _user(authorization)
res = community_store.cancel_rsvp(meetup_id, user["id"])
if not res.get("ok"):
raise HTTPException(400, "未报名此活动")
return res

View File

@ -67,6 +67,12 @@ async def undo_swipe(authorization: str | None = Header(None)):
return res
@router.get("/matches/likes/received", response_model=list[MatchProfile])
async def match_likes_received(authorization: str | None = Header(None)):
user = _token_user(authorization)
return [MatchProfile(**p) for p in social_store.list_likes_received(user["id"])]
@router.get("/matches/likes", response_model=list[MatchProfile])
async def match_likes(authorization: str | None = Header(None)):
user = _token_user(authorization)

View File

@ -456,9 +456,89 @@ class NotificationItem(BaseModel):
body: str = ""
link: str = ""
read: bool = False
archived: bool = False
pinned: bool = False
category: str = "general"
created_at: str = ""
class CreateGigRequest(BaseModel):
title: str = Field(min_length=4, max_length=120)
description: str = Field(min_length=10, max_length=4000)
budget: str = "面议"
deadline: str = ""
tags: list[str] = Field(default_factory=list)
class ServiceItem(BaseModel):
id: str
title: str
description: str
emoji: str = "💼"
price: str = ""
category: str = ""
provider: str = ""
class ServiceLeadRequest(BaseModel):
service_id: str
name: str = Field(min_length=2, max_length=80)
email: EmailStr
message: str = Field(min_length=4, max_length=2000)
class VideoItem(BaseModel):
id: str
slug: str
title: str
excerpt: str = ""
emoji: str = "🎬"
duration: str = ""
guest: str = ""
city: str = ""
published_at: str = ""
tags: list[str] = Field(default_factory=list)
video_url: str = ""
class AiAssistantRequest(BaseModel):
message: str = Field(min_length=2, max_length=500)
class AiAssistantResponse(BaseModel):
reply: str
cities: list[dict] = Field(default_factory=list)
class NewsletterRequest(BaseModel):
email: EmailStr
source: str = "site"
class ContentSubmissionRequest(BaseModel):
name: str = Field(min_length=2, max_length=80)
title: str = Field(min_length=4, max_length=200)
content_type: str = "article"
url: str = ""
notes: str = ""
class VolunteerRequest(BaseModel):
name: str = Field(min_length=2, max_length=80)
email: EmailStr
message: str = Field(min_length=4, max_length=2000)
class ReportRequest(BaseModel):
target_type: str
target_id: str
reason: str = Field(min_length=4, max_length=1000)
class GoogleLoginRequest(BaseModel):
id_token: str
class FeedbackRequest(BaseModel):
content: str = Field(min_length=4, max_length=4000)
category: str = "general"

View File

@ -0,0 +1,50 @@
"""Rule-based nomad assistant (no LLM)."""
from __future__ import annotations
KEYWORD_CITIES = {
"便宜": ["chiangmai", "mexico", "bali"],
"budget": ["chiangmai", "mexico", "bali"],
"快": ["tokyo", "barcelona", "lisbon"],
"网速": ["tokyo", "barcelona", "lisbon"],
"internet": ["tokyo", "barcelona", "lisbon"],
"暖": ["bali", "chiangmai", "mexico"],
"warm": ["bali", "chiangmai", "mexico"],
"冷": ["lisbon", "barcelona"],
"cool": ["lisbon", "barcelona"],
"签证": ["lisbon", "mexico"],
"visa": ["lisbon", "mexico"],
"中文": ["chiangmai", "bali"],
"chinese": ["chiangmai", "bali"],
}
def assistant_reply(message: str, destinations: list[dict]) -> dict:
msg = message.lower()
picks: list[str] = []
for kw, slugs in KEYWORD_CITIES.items():
if kw in msg:
picks.extend(slugs)
if not picks:
picks = ["chiangmai", "lisbon", "bali"]
seen: set[str] = set()
items = []
for slug in picks:
if slug in seen:
continue
seen.add(slug)
dest = next((d for d in destinations if d.get("slug") == slug), None)
if dest:
items.append({
"slug": slug,
"name": dest.get("name", slug),
"emoji": dest.get("emoji", "🌍"),
"reason": f"匹配你的需求:{message[:40]}",
})
if len(items) >= 3:
break
reply = (
f"根据你的描述,我推荐看看 {', '.join(i['name'] for i in items)}。"
" 可以用「下一站决策」做更精细的筛选,或在社区问当地细节。"
)
return {"reply": reply, "cities": items}

View File

@ -97,6 +97,26 @@ def login_user(email: str, password: str) -> dict[str, Any] | None:
return {"token": token, "user": _user_profile(user)}
def login_google(email: str, name: str) -> dict[str, Any] | None:
if email not in _users:
uid = secrets.token_hex(8)
_users[email] = {
"id": uid,
"email": email,
"password_hash": _hash_password(secrets.token_hex(16)),
"name": name or email.split("@")[0],
"avatar": "🌐",
"oauth": "google",
}
_favorites[uid] = []
_plans[uid] = _empty_plan()
user = _users[email]
token = secrets.token_urlsafe(32)
_sessions[token] = user["id"]
_persist()
return {"token": token, "user": _user_profile(user)}
def login_demo() -> dict[str, Any] | None:
email = "demo@nomadro.com"
if email not in _users:

View File

@ -25,6 +25,7 @@ _gigs: list[dict] = []
_gig_apps: list[dict] = []
_notifications: dict[str, list[dict]] = {} # user_id -> items
_feedback: list[dict] = []
_views: dict[str, int] = {} # discussion_id -> view count
_seeded = False
@ -51,6 +52,7 @@ def _persist() -> None:
"gig_apps": _gig_apps,
"notifications": _notifications,
"feedback": _feedback,
"views": _views,
"seeded": _seeded,
}, ensure_ascii=False),
encoding="utf-8",
@ -59,7 +61,7 @@ def _persist() -> None:
def _restore() -> None:
global _meetups, _discussions, _replies, _discussion_likes, _reply_likes
global _rsvps, _gigs, _gig_apps, _notifications, _feedback, _seeded
global _rsvps, _gigs, _gig_apps, _notifications, _feedback, _views, _seeded
if not STORE_PATH.exists():
return
try:
@ -76,6 +78,7 @@ def _restore() -> None:
_gig_apps = data.get("gig_apps") or []
_notifications = data.get("notifications") or {}
_feedback = data.get("feedback") or []
_views = data.get("views") or {}
_seeded = bool(data.get("seeded"))
@ -151,6 +154,7 @@ def _enrich_discussion(d: dict) -> dict:
**d,
"reply_count": len(replies),
"like_count": max(base_likes, stored) if stored == 0 else base_likes + stored,
"view_count": _views.get(did, d.get("view_count", 0)),
}
@ -241,10 +245,13 @@ def list_discussions(category: str | None = None) -> list[dict]:
return pinned + rest
def get_discussion(discussion_id: str) -> dict | None:
def get_discussion(discussion_id: str, increment_view: bool = False) -> dict | None:
_seed_if_needed()
for d in _discussions:
if d["id"] == discussion_id:
if increment_view:
_views[discussion_id] = _views.get(discussion_id, 0) + 1
_persist()
enriched = _enrich_discussion(d)
replies = []
for r in _replies.get(discussion_id, []):
@ -343,14 +350,110 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
return {"ok": True, "message": "申请已提交"}
def add_notification(user_id: str, title: str, body: str, link: str = "") -> None:
def cancel_rsvp(meetup_id: str, user_id: str) -> dict:
_seed_if_needed()
rset = _rsvps.get(meetup_id, set())
if user_id not in rset:
return {"ok": False, "error": "not_rsvped"}
rset.discard(user_id)
_persist()
return {"ok": True, "rsvp_count": len(rset)}
def create_gig(user_id: str, user_name: str, payload: dict) -> dict:
_seed_if_needed()
gid = _slugify(payload["title"]) + "-" + secrets.token_hex(2)
gig = {
"id": gid,
"title": payload["title"],
"description": payload.get("description", ""),
"budget": payload.get("budget", "面议"),
"deadline": payload.get("deadline", ""),
"tags": payload.get("tags", []),
"poster": user_name,
"poster_id": user_id,
"status": "open",
}
_gigs.insert(0, gig)
_persist()
return gig
def meetup_social_suggestions(meetup_id: str, limit: int = 6) -> list[dict]:
_seed_if_needed()
m = get_meetup(meetup_id)
if not m:
return []
city = m.get("city", "")
from app.data.social_profiles import CANDIDATE_PROFILES
out = []
for p in CANDIDATE_PROFILES:
if city and city not in (p.get("location") or ""):
continue
out.append(p)
if len(out) >= limit:
break
return out[:limit] if out else CANDIDATE_PROFILES[:limit]
def notification_unread_count(user_id: str) -> int:
_reload()
return sum(1 for n in _notifications.get(user_id, []) if not n.get("read"))
def update_notification(user_id: str, notif_id: str, action: str) -> dict | None:
_reload()
items = _notifications.get(user_id, [])
for n in items:
if n["id"] != notif_id:
continue
if action == "read":
n["read"] = True
elif action == "unread":
n["read"] = False
elif action == "archive":
n["archived"] = True
elif action == "restore":
n["archived"] = False
elif action == "pin":
n["pinned"] = True
elif action == "unpin":
n["pinned"] = False
_persist()
return n
return None
def list_notifications_filtered(user_id: str, include_archived: bool = False) -> list[dict]:
_reload()
items = _notifications.get(user_id, [])
if not include_archived:
items = [n for n in items if not n.get("archived")]
pinned = [n for n in items if n.get("pinned")]
rest = [n for n in items if not n.get("pinned")]
return pinned + rest
def search_discussions(q: str) -> list[dict]:
_seed_if_needed()
s = q.lower()
return [
_enrich_discussion(d) for d in _discussions
if s in d["title"].lower() or s in d.get("excerpt", "").lower()
][:20]
def add_notification(user_id: str, title: str, body: str, link: str = "", category: str = "general") -> None:
items = _notifications.setdefault(user_id, [])
items.insert(0, {
"id": secrets.token_hex(6),
"title": title,
"body": body,
"link": link,
"category": category,
"read": False,
"archived": False,
"pinned": False,
"created_at": _now_iso(),
})
items[:] = items[:50]
@ -358,8 +461,7 @@ def add_notification(user_id: str, title: str, body: str, link: str = "") -> Non
def list_notifications(user_id: str) -> list[dict]:
_reload()
return _notifications.get(user_id, [])
return list_notifications_filtered(user_id)
def mark_notifications_read(user_id: str) -> None:

View File

@ -0,0 +1,141 @@
"""Platform persistence: leads, submissions, volunteers, reports, newsletter."""
from __future__ import annotations
import json
import secrets
from datetime import datetime, timezone
from pathlib import Path
STORE_PATH = Path(__file__).resolve().parents[1] / "data" / "platform_store.json"
_leads: list[dict] = []
_submissions: list[dict] = []
_volunteers: list[dict] = []
_reports: list[dict] = []
_newsletter: list[dict] = []
_notif_prefs: dict[str, dict] = {}
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _persist() -> None:
STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
STORE_PATH.write_text(json.dumps({
"leads": _leads,
"submissions": _submissions,
"volunteers": _volunteers,
"reports": _reports,
"newsletter": _newsletter,
"notif_prefs": _notif_prefs,
}, ensure_ascii=False), encoding="utf-8")
def _restore() -> None:
global _leads, _submissions, _volunteers, _reports, _newsletter, _notif_prefs
if not STORE_PATH.exists():
return
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
_leads = data.get("leads") or []
_submissions = data.get("submissions") or []
_volunteers = data.get("volunteers") or []
_reports = data.get("reports") or []
_newsletter = data.get("newsletter") or []
_notif_prefs = data.get("notif_prefs") or {}
_restore()
def add_service_lead(service_id: str, user_id: str | None, name: str, email: str, message: str) -> dict:
item = {
"id": secrets.token_hex(6),
"service_id": service_id,
"user_id": user_id or "",
"name": name,
"email": email,
"message": message,
"created_at": _now(),
}
_leads.append(item)
_persist()
return item
def add_content_submission(user_id: str | None, name: str, title: str, content_type: str, url: str, notes: str) -> dict:
item = {
"id": secrets.token_hex(6),
"user_id": user_id or "",
"name": name,
"title": title,
"content_type": content_type,
"url": url,
"notes": notes,
"status": "pending",
"created_at": _now(),
}
_submissions.append(item)
_persist()
return item
def add_volunteer(city_slug: str, user_id: str | None, name: str, email: str, message: str) -> dict:
item = {
"id": secrets.token_hex(6),
"city_slug": city_slug,
"user_id": user_id or "",
"name": name,
"email": email,
"message": message,
"created_at": _now(),
}
_volunteers.append(item)
_persist()
return item
def add_report(reporter_id: str | None, target_type: str, target_id: str, reason: str) -> dict:
item = {
"id": secrets.token_hex(6),
"reporter_id": reporter_id or "",
"target_type": target_type,
"target_id": target_id,
"reason": reason,
"status": "open",
"created_at": _now(),
}
_reports.append(item)
_persist()
return item
def subscribe_newsletter(email: str, source: str = "site") -> dict:
for n in _newsletter:
if n["email"] == email:
return {"ok": True, "message": "已订阅"}
_newsletter.append({"email": email, "source": source, "created_at": _now()})
_persist()
return {"ok": True, "message": "订阅成功"}
def get_notif_prefs(user_id: str) -> dict:
return _notif_prefs.get(user_id) or {
"email": True,
"push": False,
"match": True,
"meetup": True,
"community": True,
"marketing": False,
"quiet_hours": "",
}
def set_notif_prefs(user_id: str, prefs: dict) -> dict:
_notif_prefs[user_id] = {**get_notif_prefs(user_id), **prefs}
_persist()
return _notif_prefs[user_id]

View File

@ -151,6 +151,23 @@ def list_candidates(
swiped_ids.add(s.get("profileId"))
pool = list(CANDIDATE_PROFILES)
# Real members who completed join profile
for uid, prof in _profiles.items():
if uid == user_id:
continue
pool.append({
"id": f"user-{uid}",
"userId": uid,
"name": prof.get("name", "游民"),
"location": prof.get("location", "全球"),
"citySlug": prof.get("citySlug", ""),
"gender": prof.get("gender", ""),
"single": prof.get("single", ""),
"bio": prof.get("bio", ""),
"photo": prof.get("photo", "🧑‍💻"),
"tags": prof.get("tags", []),
"lookingFor": _profile_looking_for(prof),
})
if my_profile:
pool = [p for p in pool if p.get("userId") != user_id]
@ -245,6 +262,26 @@ def _ensure_conversation(user_a: str, user_b: str, intent: str = "friends", matc
return conv
def _find_profile(profile_id: str) -> dict | None:
for p in CANDIDATE_PROFILES:
if p["id"] == profile_id:
return p
if profile_id.startswith("user-"):
uid = profile_id[5:]
prof = _profiles.get(uid)
if prof:
return {
"id": profile_id,
"userId": uid,
"name": prof.get("name", "游民"),
"location": prof.get("location", "全球"),
"bio": prof.get("bio", ""),
"photo": prof.get("photo", "🧑‍💻"),
"tags": prof.get("tags", []),
}
return None
def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict:
_reload()
if action not in LIKE_ACTIONS and action != "dislike":
@ -259,7 +296,7 @@ def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dic
if s.get("userId") == user_id and s.get("profileId") == profile_id:
return {"ok": False, "error": "duplicate"}
profile = next((p for p in CANDIDATE_PROFILES if p["id"] == profile_id), None)
profile = _find_profile(profile_id)
if not profile:
return {"ok": False, "error": "not_found"}
@ -318,10 +355,29 @@ def undo_last_swipe(user_id: str) -> dict:
return {"ok": False, "error": "nothing_to_undo"}
def list_likes_received(user_id: str) -> list[dict]:
"""Profiles who liked this user's profile (simulated from static pool)."""
_reload()
my_pid = f"user-{user_id}"
likers = []
for s in _swipes:
if s.get("profileId") == my_pid and s.get("action") in LIKE_ACTIONS:
uid = s.get("userId")
p = _profiles.get(uid)
if p:
likers.append({**p, "id": f"user-{uid}", "userId": uid})
return likers
def list_likes(user_id: str) -> list[dict]:
_reload()
liked_ids = [s["profileId"] for s in _swipes if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS]
return [p for p in CANDIDATE_PROFILES if p["id"] in liked_ids]
out = []
for pid in liked_ids:
p = _find_profile(pid)
if p:
out.append(p)
return out
def list_mutual(user_id: str) -> list[dict]:

View File

@ -0,0 +1,44 @@
"""Open-Meteo weather for nomad cities."""
from __future__ import annotations
import httpx
from app.data.platform_data import WEATHER_CITIES
async def get_city_weather(slug: str) -> dict | None:
meta = WEATHER_CITIES.get(slug)
if not meta:
return None
url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={meta['lat']}&longitude={meta['lng']}"
"&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code"
"&timezone=auto"
)
try:
async with httpx.AsyncClient(timeout=8) as client:
res = await client.get(url)
res.raise_for_status()
data = res.json()
cur = data.get("current") or {}
return {
"slug": slug,
"name": meta["name"],
"temperature": cur.get("temperature_2m"),
"humidity": cur.get("relative_humidity_2m"),
"wind_speed": cur.get("wind_speed_10m"),
"weather_code": cur.get("weather_code"),
}
except (httpx.HTTPError, KeyError):
return {"slug": slug, "name": meta["name"], "temperature": None, "error": "unavailable"}
async def list_weather() -> list[dict]:
results = []
for slug in WEATHER_CITIES:
w = await get_city_weather(slug)
if w:
results.append(w)
return results

View File

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import AiAssistantClient from "@/components/AiAssistantClient";
export const metadata: Metadata = {
title: "AI 旅居助手 · nomadro",
description: "智能推荐下一站游民城市",
};
export default function AiPage() {
return (
<SiteShell showFooter>
<AiAssistantClient />
</SiteShell>
);
}

View File

@ -0,0 +1,10 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import StaticPage from "@/components/StaticPage";
import { STATIC_PAGES } from "@/lib/staticPages";
const page = STATIC_PAGES.careers;
export const metadata: Metadata = { title: `${page.title} · nomadro` };
export default function CareersPage() {
return <SiteShell showFooter><StaticPage page={page} /></SiteShell>;
}

View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import StaticPage from "@/components/StaticPage";
import { STATIC_PAGES } from "@/lib/staticPages";
const page = STATIC_PAGES.contact;
export const metadata: Metadata = {
title: `${page.title} · nomadro`,
description: page.subtitle || page.title,
};
export default function ContactPage() {
return (
<SiteShell showFooter>
<StaticPage page={page} />
</SiteShell>
);
}

View File

@ -0,0 +1,10 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import StaticPage from "@/components/StaticPage";
import { STATIC_PAGES } from "@/lib/staticPages";
const page = STATIC_PAGES.cookies;
export const metadata: Metadata = { title: `${page.title} · nomadro` };
export default function CookiesPage() {
return <SiteShell showFooter><StaticPage page={page} /></SiteShell>;
}

View File

@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell";
import Link from "next/link";
import { api } from "@/lib/api";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const day = await api.getDigitalDay(id).catch(() => null);
return { title: day ? `${day.title} · nomadro` : "每日指南" };
}
export default async function DigitalDayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const day = await api.getDigitalDay(id).catch(() => null);
if (!day) notFound();
return (
<SiteShell showFooter>
<div className="legal-page">
<nav className="detail-nav"><Link href="/digital">← 游民学院</Link></nav>
<article className="legal-content reveal">
<span className="section-tag">📅 DAY {id}</span>
<h1>{day.title}</h1>
<p>{day.body}</p>
</article>
</div>
</SiteShell>
);
}

View File

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import FeedbackClient from "@/components/FeedbackClient";
export const metadata: Metadata = {
title: "反馈 · nomadro",
description: "向 nomadro 提交问题反馈与功能建议",
};
export default function FeedbackPage() {
return (
<SiteShell showFooter>
<FeedbackClient />
</SiteShell>
);
}

View File

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import GigPostClient from "@/components/GigPostClient";
export const metadata: Metadata = {
title: "发布赏金 · nomadro",
};
export default function GigPostPage() {
return (
<SiteShell showFooter>
<GigPostClient />
</SiteShell>
);
}

View File

@ -10292,6 +10292,128 @@ img { max-width: 100%; display: block; }
color: var(--text-primary);
}
.video-embed {
position: relative;
padding-bottom: 56.25%;
height: 0;
margin-top: 24px;
border-radius: var(--radius-lg);
overflow: hidden;
}
.video-embed iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.report-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
margin-top: 24px;
}
.report-rank { list-style: none; padding: 0; }
.report-rank li {
display: grid;
grid-template-columns: 24px 1fr auto auto;
gap: 12px;
padding: 10px 0;
border-bottom: var(--border-glass);
align-items: center;
}
.member-map-grid, .member-map-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.member-map-item {
display: flex;
gap: 10px;
align-items: center;
padding: 12px;
background: var(--bg-glass);
border-radius: var(--radius-md);
}
.weather-strip { margin-top: 32px; }
.weather-cards { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
.weather-card {
padding: 12px 16px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-md);
min-width: 100px;
}
.ai-reply {
margin-top: 24px;
padding: 20px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-lg);
}
.ai-cities { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.notif-toolbar { display: flex; gap: 8px; flex-wrap: wrap; }
.notif-pref-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: var(--border-glass);
}
.nav-notify-badge {
position: absolute;
top: -4px;
right: -4px;
font-size: 0.65rem;
font-style: normal;
background: var(--accent-1);
color: #fff;
border-radius: 999px;
padding: 1px 5px;
}
.nav-notify { position: relative; }
.newsletter-strip {
margin-top: 48px;
padding: 24px;
background: var(--bg-card);
border: var(--border-glass);
border-radius: var(--radius-lg);
text-align: center;
}
.newsletter-form {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 12px;
flex-wrap: wrap;
}
.newsletter-form input {
min-width: 220px;
padding: 10px 14px;
border-radius: var(--radius-md);
border: var(--border-glass);
background: var(--bg-glass);
color: var(--text-primary);
}
@media (max-width: 768px) {
.report-grid { grid-template-columns: 1fr; }
}
.dating-likes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));

View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import StaticPage from "@/components/StaticPage";
import { STATIC_PAGES } from "@/lib/staticPages";
const page = STATIC_PAGES.guidelines;
export const metadata: Metadata = {
title: `${page.title} · nomadro`,
description: "nomadro 社区行为准则",
};
export default function GuidelinesPage() {
return (
<SiteShell showFooter>
<StaticPage page={page} />
</SiteShell>
);
}

View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import StaticPage from "@/components/StaticPage";
import { STATIC_PAGES } from "@/lib/staticPages";
const page = STATIC_PAGES.help;
export const metadata: Metadata = {
title: `${page.title} · nomadro`,
description: "nomadro 帮助中心与常见问题",
};
export default function HelpPage() {
return (
<SiteShell showFooter>
<StaticPage page={page} />
</SiteShell>
);
}

View File

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import MemberMapClient from "@/components/MemberMapClient";
export const metadata: Metadata = {
title: "游民地图 · nomadro",
description: "全球游民分布与目的地实时天气",
};
export default function MapPage() {
return (
<SiteShell showFooter>
<MemberMapClient />
</SiteShell>
);
}

View File

@ -0,0 +1,10 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import StaticPage from "@/components/StaticPage";
import { STATIC_PAGES } from "@/lib/staticPages";
const page = STATIC_PAGES.press;
export const metadata: Metadata = { title: `${page.title} · nomadro` };
export default function PressPage() {
return <SiteShell showFooter><StaticPage page={page} /></SiteShell>;
}

View File

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import ReportClient from "@/components/ReportClient";
export const metadata: Metadata = {
title: "数据报告 · nomadro",
description: "游民城市排名与会员分布",
};
export default function ReportPage() {
return (
<SiteShell showFooter>
<ReportClient />
</SiteShell>
);
}

View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import StaticPage from "@/components/StaticPage";
import { STATIC_PAGES } from "@/lib/staticPages";
const page = STATIC_PAGES.safety;
export const metadata: Metadata = {
title: `${page.title} · nomadro`,
description: "nomadro 社区安全指南",
};
export default function SafetyPage() {
return (
<SiteShell showFooter>
<StaticPage page={page} />
</SiteShell>
);
}

View File

@ -0,0 +1,18 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import ServicesClient from "@/components/ServicesClient";
import { api } from "@/lib/api";
export const metadata: Metadata = {
title: "游民服务 · nomadro",
description: "签证咨询、税务评估、落地陪跑等认证服务",
};
export default async function ServicesPage() {
const services = await api.getServices().catch(() => []);
return (
<SiteShell showFooter>
<ServicesClient services={services} />
</SiteShell>
);
}

View File

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import NotificationSettingsClient from "@/components/NotificationSettingsClient";
export const metadata: Metadata = {
title: "通知设置 · nomadro",
};
export default function NotificationSettingsPage() {
return (
<SiteShell showFooter>
<NotificationSettingsClient />
</SiteShell>
);
}

View File

@ -23,7 +23,19 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${SITE}/community/new`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/meetups/host`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/dating/likes`, lastModified: now, changeFrequency: "weekly", priority: 0.7 },
{ url: `${SITE}/digital`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
{ url: `${SITE}/ai`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
{ url: `${SITE}/videos`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/services`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/map`, lastModified: now, changeFrequency: "weekly", priority: 0.7 },
{ url: `${SITE}/report`, lastModified: now, changeFrequency: "weekly", priority: 0.65 },
{ url: `${SITE}/feedback`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/submit`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/gigs/post`, lastModified: now, changeFrequency: "weekly", priority: 0.7 },
{ url: `${SITE}/settings/notifications`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${SITE}/contact`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/help`, lastModified: now, changeFrequency: "yearly", priority: 0.4 },
{ url: `${SITE}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
{ url: `${SITE}/join`, lastModified: now, changeFrequency: "monthly", priority: 0.75 },
{ url: `${SITE}/digital/course`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },

View File

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import ContentSubmitClient from "@/components/ContentSubmitClient";
export const metadata: Metadata = {
title: "内容投稿 · nomadro",
};
export default function SubmitPage() {
return (
<SiteShell showFooter>
<ContentSubmitClient />
</SiteShell>
);
}

View File

@ -0,0 +1,10 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import StaticPage from "@/components/StaticPage";
import { STATIC_PAGES } from "@/lib/staticPages";
const page = STATIC_PAGES.support;
export const metadata: Metadata = { title: `${page.title} · nomadro` };
export default function SupportPage() {
return <SiteShell showFooter><StaticPage page={page} /></SiteShell>;
}

View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import StaticPage from "@/components/StaticPage";
import { STATIC_PAGES } from "@/lib/staticPages";
const page = STATIC_PAGES.terms;
export const metadata: Metadata = {
title: `${page.title} · nomadro`,
description: "nomadro 服务条款",
};
export default function TermsPage() {
return (
<SiteShell showFooter>
<StaticPage page={page} />
</SiteShell>
);
}

View File

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import SiteShell from "@/components/SiteShell";
import VideoDetailClient from "@/components/VideoDetailClient";
import { api } from "@/lib/api";
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const video = await api.getVideo(slug).catch(() => null);
return { title: video ? `${video.title} · nomadro` : "视频 · nomadro" };
}
export default async function VideoDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const video = await api.getVideo(slug).catch(() => null);
if (!video) notFound();
return (
<SiteShell showFooter>
<VideoDetailClient video={video} />
</SiteShell>
);
}

View File

@ -0,0 +1,18 @@
import type { Metadata } from "next";
import SiteShell from "@/components/SiteShell";
import VideosClient from "@/components/VideosClient";
import { api } from "@/lib/api";
export const metadata: Metadata = {
title: "游民访谈 · nomadro",
description: "数字游民真实旅居访谈与实录",
};
export default async function VideosPage() {
const videos = await api.getVideos().catch(() => []);
return (
<SiteShell showFooter>
<VideosClient videos={videos} />
</SiteShell>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n";
export default function AiAssistantClient() {
const { t } = useI18n();
const [message, setMessage] = useState("");
const [reply, setReply] = useState("");
const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]);
const [loading, setLoading] = useState(false);
const ask = async () => {
if (!message.trim()) return;
setLoading(true);
try {
const res = await api.askAssistant(message.trim());
setReply(res.reply);
setCities(res.cities || []);
} catch {
setReply(t.ai.fail);
} finally {
setLoading(false);
}
};
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.ai.tag}</span>
<h1>{t.ai.title}</h1>
<p>{t.ai.subtitle}</p>
</div>
<div className="community-compose reveal">
<textarea rows={4} placeholder={t.ai.placeholder} value={message} onChange={(e) => setMessage(e.target.value)} />
<button type="button" className="btn btn-primary" disabled={loading} onClick={ask}>{t.ai.ask}</button>
</div>
{reply && (
<div className="ai-reply reveal">
<p>{reply}</p>
<div className="ai-cities">
{cities.map((c) => (
<Link key={c.slug} href={`/destinations/${c.slug}`} className="btn btn-sm">{c.emoji} {c.name}</Link>
))}
<Link href="/next-stop" className="btn btn-sm">{t.ai.nextStop}</Link>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
export default function ContentSubmitClient() {
const { token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [notes, setNotes] = useState("");
const [contentType, setContentType] = useState("article");
const submit = async () => {
if (!token) {
router.push("/login?next=/submit");
return;
}
try {
await api.submitContent({ title, url, notes, content_type: contentType, name: "" }, token);
toast(t.submit.ok, "success");
setTitle("");
setUrl("");
setNotes("");
} catch {
toast(t.submit.fail, "error");
}
};
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.submit.tag}</span>
<h1>{t.submit.title}</h1>
<p>{t.submit.subtitle}</p>
</div>
<div className="community-compose reveal">
<select value={contentType} onChange={(e) => setContentType(e.target.value)}>
<option value="article">{t.submit.article}</option>
<option value="video">{t.submit.video}</option>
<option value="ebook">{t.submit.ebook}</option>
</select>
<input placeholder={t.submit.subject} value={title} onChange={(e) => setTitle(e.target.value)} />
<input placeholder={t.submit.url} value={url} onChange={(e) => setUrl(e.target.value)} />
<textarea rows={4} placeholder={t.submit.notes} value={notes} onChange={(e) => setNotes(e.target.value)} />
<button type="button" className="btn btn-primary" onClick={submit}>{t.submit.send}</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
export default function FeedbackClient() {
const { token } = useAuth();
const { toast } = useToast();
const { t } = useI18n();
const [content, setContent] = useState("");
const [category, setCategory] = useState("general");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!content.trim()) return;
setLoading(true);
try {
await api.submitFeedback(content.trim(), token || undefined, category);
toast(t.feedback.ok, "success");
setContent("");
} catch {
toast(t.feedback.fail, "error");
} finally {
setLoading(false);
}
};
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.feedback.tag}</span>
<h1>{t.feedback.title}</h1>
<p>{t.feedback.subtitle}</p>
</div>
<div className="community-compose reveal">
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="general">{t.feedback.catGeneral}</option>
<option value="bug">{t.feedback.catBug}</option>
<option value="feature">{t.feedback.catFeature}</option>
<option value="safety">{t.feedback.catSafety}</option>
</select>
<textarea rows={6} placeholder={t.feedback.placeholder} value={content} onChange={(e) => setContent(e.target.value)} />
<button type="button" className="btn btn-primary" disabled={loading} onClick={submit}>{t.feedback.submit}</button>
</div>
</div>
</div>
);
}

View File

@ -64,6 +64,11 @@ export default function Footer() {
<Link href="/gigs">{t.footer.gigs}</Link>
<Link href="/digital">{t.footer.digital}</Link>
<Link href="/pricing">{t.footer.pricing}</Link>
<Link href="/videos">{t.footer.videos}</Link>
<Link href="/services">{t.footer.services}</Link>
<Link href="/ai">{t.footer.ai}</Link>
<Link href="/map">{t.footer.mapPage}</Link>
<Link href="/report">{t.footer.report}</Link>
<Link href="/about">{t.footer.about}</Link>
</div>
<div className="footer-links">
@ -86,6 +91,9 @@ export default function Footer() {
<Link href="/join">{t.footer.join}</Link>
<Link href="/chat">{t.footer.chat}</Link>
<Link href="/notifications">{t.footer.notifications}</Link>
<Link href="/feedback">{t.footer.feedback}</Link>
<Link href="/help">{t.footer.help}</Link>
<Link href="/contact">{t.footer.contact}</Link>
<Link href="/privacy">{t.footer.privacy}</Link>
<a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
</div>

View File

@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
export default function GigPostClient() {
const { token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [budget, setBudget] = useState("");
const [deadline, setDeadline] = useState("");
const submit = async () => {
if (!token) {
router.push("/login?next=/gigs/post");
return;
}
try {
await api.createGig(token, { title, description, budget, deadline });
toast(t.gigs.postOk, "success");
router.push("/gigs");
} catch {
toast(t.gigs.postFail, "error");
}
};
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/gigs">{t.gigs.back}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.gigs.tag}</span>
<h1>{t.gigs.postTitle}</h1>
</div>
<div className="community-compose reveal">
<input placeholder={t.gigs.postName} value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea rows={5} placeholder={t.gigs.postDesc} value={description} onChange={(e) => setDescription(e.target.value)} />
<input placeholder={t.gigs.postBudget} value={budget} onChange={(e) => setBudget(e.target.value)} />
<input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} />
<button type="button" className="btn btn-primary" onClick={submit}>{t.gigs.postSubmit}</button>
</div>
</div>
</div>
);
}

View File

@ -37,6 +37,7 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
<span className="section-tag">{t.gigs.tag}</span>
<h1>{t.gigs.title}</h1>
<p>{t.gigs.subtitle}</p>
<Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link>
</div>
<div className="digital-jobs-grid reveal">
{gigs.map((g) => (

View File

@ -25,6 +25,15 @@ const PAGE_RESULTS: SearchResult[] = [
{ type: "page", title: "私信", subtitle: "与匹配成功的游民聊天", emoji: "✉️", url: "/chat" },
{ type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
{ type: "page", title: "赏金任务", subtitle: "远程小任务接单", emoji: "💼", url: "/gigs" },
{ type: "page", title: "游民访谈", subtitle: "真实旅居视频", emoji: "🎬", url: "/videos" },
{ type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" },
{ type: "page", title: "旅居助手", subtitle: "AI 推荐下一站城市", emoji: "🤖", url: "/ai" },
{ type: "page", title: "游民地图", subtitle: "会员分布与天气", emoji: "🗺️", url: "/map" },
{ type: "page", title: "数据报告", subtitle: "城市排名快照", emoji: "📊", url: "/report" },
{ type: "page", title: "意见反馈", subtitle: "Bug 与建议", emoji: "💬", url: "/feedback" },
{ type: "page", title: "内容投稿", subtitle: "文章视频电子书", emoji: "📝", url: "/submit" },
{ type: "page", title: "帮助中心", subtitle: "常见问题", emoji: "❓", url: "/help" },
{ type: "page", title: "联系我们", subtitle: "合作与媒体", emoji: "📬", url: "/contact" },
{ type: "page", title: "会员定价", subtitle: "免费版与 VIP 方案", emoji: "💎", url: "/pricing" },
{ type: "page", title: "通知中心", subtitle: "匹配与社区动态", emoji: "🔔", url: "/notifications" },
{ type: "page", title: "开通会员", subtitle: "VIP 匹配直播与课程", emoji: "✨", url: "/join" },

View File

@ -7,6 +7,7 @@ import { useToast } from "@/lib/toast";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import type { Meetup } from "@/lib/types";
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
const MODE_LABELS: Record<string, string> = {
online: "💻 线上",
@ -131,6 +132,8 @@ export default function MeetupsClient({ meetups }: Props) {
{filtered.length === 0 && (
<p className="dest-empty">{t.meetups.empty}</p>
)}
<NewsletterSubscribe source="meetups" />
</div>
</div>
);

View File

@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n";
import type { MemberMapItem } from "@/lib/types";
export default function MemberMapClient() {
const { t } = useI18n();
const [members, setMembers] = useState<MemberMapItem[]>([]);
const [weather, setWeather] = useState<{ name: string; temperature: number | null }[]>([]);
useEffect(() => {
api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => {});
api.getWeatherCities().then(setWeather).catch(() => {});
}, []);
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.mapPage.tag}</span>
<h1>{t.mapPage.title}</h1>
<p>{t.mapPage.subtitle}</p>
</div>
<div className="member-map-grid reveal">
{members.map((m) => (
<div key={m.name} className="member-map-item" style={{ gridColumn: `span 1` }}>
<span className="chat-avatar">{m.emoji}</span>
<div>
<strong>{m.name}</strong>
<p>{m.city}</p>
</div>
</div>
))}
</div>
{weather.length > 0 && (
<section className="weather-strip reveal">
<h3>{t.mapPage.weather}</h3>
<div className="weather-cards">
{weather.map((w) => (
<div key={w.name} className="weather-card">
<strong>{w.name}</strong>
<span>{w.temperature != null ? `${w.temperature}°C` : "—"}</span>
</div>
))}
</div>
</section>
)}
<p className="map-hint reveal">{t.mapPage.hint}</p>
</div>
</div>
);
}

View File

@ -6,15 +6,22 @@ import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import { loginNextFrom } from "@/lib/authNext";
import { api } from "@/lib/api";
export default function Navbar() {
const { user } = useAuth();
const { user, token } = useAuth();
const { t, locale, toggleLocale } = useI18n();
const pathname = usePathname();
const isHome = pathname === "/";
const [theme, setTheme] = useState<"dark" | "light">("dark");
const [menuOpen, setMenuOpen] = useState(false);
const [active, setActive] = useState("");
const [unread, setUnread] = useState(0);
useEffect(() => {
if (!token) { setUnread(0); return; }
api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0));
}, [token, pathname]);
const ALL_LINKS = [
{ href: "/#map", label: t.nav.map, section: "map" },
@ -133,7 +140,9 @@ export default function Navbar() {
</button>
{user ? (
<>
<Link href="/notifications" className="nav-notify" aria-label={t.nav.notifications}>🔔</Link>
<Link href="/notifications" className="nav-notify" aria-label={t.nav.notifications}>
🔔{unread > 0 && <em className="nav-notify-badge">{unread}</em>}
</Link>
<Link href="/profile" className="nav-user">
<span>{user.avatar}</span>
<span className="nav-user-name">{user.name}</span>

View File

@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
export default function NewsletterSubscribe({ source = "meetups" }: { source?: string }) {
const { toast } = useToast();
const { t } = useI18n();
const [email, setEmail] = useState("");
const submit = async () => {
if (!email.trim()) return;
try {
const res = await api.subscribeNewsletter(email.trim(), source);
toast(res.message || t.newsletter.ok, "success");
setEmail("");
} catch {
toast(t.newsletter.fail, "error");
}
};
return (
<div className="newsletter-strip reveal">
<strong>{t.newsletter.title}</strong>
<p>{t.newsletter.subtitle}</p>
<div className="newsletter-form">
<input type="email" placeholder={t.newsletter.email} value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="button" className="btn btn-primary btn-sm" onClick={submit}>{t.newsletter.submit}</button>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
export default function NotificationSettingsClient() {
const { token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
const [prefs, setPrefs] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!token) return;
api.getNotificationPrefs(token).then(setPrefs).catch(() => {});
}, [token]);
const save = async () => {
if (!token) return;
try {
await api.setNotificationPrefs(token, prefs);
toast(t.notifSettings.saved, "success");
} catch {
toast(t.notifSettings.fail, "error");
}
};
if (!token) {
return (
<div className="dating-page">
<div className="container">
<div className="dating-gate reveal">
<Link href="/login?next=/settings/notifications" className="btn btn-primary">{t.nav.login}</Link>
</div>
</div>
</div>
);
}
const toggle = (key: string) => setPrefs({ ...prefs, [key]: !prefs[key] });
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/notifications">{t.notifSettings.back}</Link></nav>
<div className="section-header reveal">
<h1>{t.notifSettings.title}</h1>
</div>
<div className="join-card reveal">
{(["match", "meetup", "community", "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)} />
</label>
))}
<button type="button" className="btn btn-primary" onClick={save}>{t.notifSettings.save}</button>
</div>
</div>
</div>
);
}

View File

@ -36,7 +36,10 @@ export default function NotificationsClient() {
<div className="container">
<div className="section-header reveal">
<h1>{t.notifications.title}</h1>
<div className="notif-toolbar">
<button type="button" className="btn btn-sm" onClick={markAll}>{t.notifications.markRead}</button>
<Link href="/settings/notifications" className="btn btn-sm">{t.notifSettings.title}</Link>
</div>
</div>
<div className="chat-list">
{items.map((n) => (

View File

@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n";
export default function ReportClient() {
const { t } = useI18n();
const [ranking, setRanking] = useState<{ slug: string; name: string; score: number; nomads: string }[]>([]);
const [members, setMembers] = useState<{ name: string; city: string; emoji: string }[]>([]);
useEffect(() => {
api.getCityRanking().then((r) => setRanking(r.items || [])).catch(() => {});
api.getMemberMap().then((r) => setMembers(r.members || [])).catch(() => {});
}, []);
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.report.tag}</span>
<h1>{t.report.title}</h1>
<p>{t.report.subtitle}</p>
</div>
<div className="report-grid">
<section className="join-card reveal">
<h3>{t.report.ranking}</h3>
<ol className="report-rank">
{ranking.map((c, i) => (
<li key={c.slug}>
<span>{i + 1}</span>
<Link href={`/destinations/${c.slug}`}>{c.name}</Link>
<em>{c.score}</em>
<small>{c.nomads}</small>
</li>
))}
</ol>
</section>
<section className="join-card reveal">
<h3>{t.report.members}</h3>
<div className="member-map-list">
{members.map((m) => (
<div key={m.name + m.city} className="member-map-item">
<span>{m.emoji}</span>
<strong>{m.name}</strong>
<span>{m.city}</span>
</div>
))}
</div>
<Link href="/map" className="btn btn-sm">{t.report.viewMap}</Link>
</section>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import type { ServiceItem } from "@/lib/types";
export default function ServicesClient({ services }: { services: ServiceItem[] }) {
const { toast } = useToast();
const { t } = useI18n();
const [form, setForm] = useState<Record<string, { name: string; email: string; message: string }>>({});
const submit = async (serviceId: string) => {
const f = form[serviceId];
if (!f?.name || !f?.email || !f?.message) return;
try {
await api.submitServiceLead(serviceId, f);
toast(t.services.leadOk, "success");
} catch {
toast(t.services.leadFail, "error");
}
};
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.services.tag}</span>
<h1>{t.services.title}</h1>
<p>{t.services.subtitle}</p>
</div>
<div className="join-grid">
{services.map((s) => (
<div key={s.id} className="join-card reveal">
<h3>{s.emoji} {s.title}</h3>
<p>{s.description}</p>
<p><strong>{s.price}</strong> · {s.provider}</p>
<input placeholder={t.services.name} value={form[s.id]?.name || ""} onChange={(e) => setForm({ ...form, [s.id]: { ...form[s.id], name: e.target.value, email: form[s.id]?.email || "", message: form[s.id]?.message || "" } })} />
<input type="email" placeholder={t.services.email} value={form[s.id]?.email || ""} onChange={(e) => setForm({ ...form, [s.id]: { ...form[s.id], email: e.target.value, name: form[s.id]?.name || "", message: form[s.id]?.message || "" } })} />
<textarea rows={3} placeholder={t.services.message} value={form[s.id]?.message || ""} onChange={(e) => setForm({ ...form, [s.id]: { ...form[s.id], message: e.target.value, name: form[s.id]?.name || "", email: form[s.id]?.email || "" } })} />
<button type="button" className="btn btn-primary btn-sm" onClick={() => submit(s.id)}>{t.services.inquire}</button>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import type { StaticPageDef } from "@/lib/staticPages";
export default function StaticPage({ page }: { page: StaticPageDef }) {
return (
<div className="legal-page">
<nav className="detail-nav">
<Link href="/">← 返回首页</Link>
</nav>
<article className="legal-content reveal">
<span className="section-tag">{page.tag}</span>
<h1>{page.title}</h1>
{page.subtitle && <p className="legal-updated">{page.subtitle}</p>}
{page.sections.map((s) => (
<section key={s.heading}>
<h2>{s.heading}</h2>
{s.body && <p>{s.body}</p>}
{s.list && (
<ul className="about-list">
{s.list.map((item) => <li key={item}>{item}</li>)}
</ul>
)}
</section>
))}
{page.cta && page.cta.length > 0 && (
<div className="about-actions">
{page.cta.map((c) => (
<Link key={c.href} href={c.href} className="btn btn-primary">{c.label}</Link>
))}
</div>
)}
</article>
</div>
);
}

View File

@ -0,0 +1,26 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { VideoItem } from "@/lib/types";
export default function VideoDetailClient({ video }: { video: VideoItem }) {
const { t } = useI18n();
return (
<div className="community-detail-page">
<div className="container">
<nav className="detail-nav"><Link href="/videos">{t.videos.back}</Link></nav>
<article className="community-detail reveal">
<span className="community-detail-cat">{video.city} · {video.guest}</span>
<h1>{video.emoji} {video.title}</h1>
<p>{video.excerpt}</p>
{video.video_url && (
<div className="video-embed">
<iframe src={video.video_url} title={video.title} allowFullScreen />
</div>
)}
</article>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { VideoItem } from "@/lib/types";
export default function VideosClient({ videos }: { videos: VideoItem[] }) {
const { t } = useI18n();
return (
<div className="community-page">
<div className="container">
<nav className="detail-nav"><Link href="/">{t.common.backHome}</Link></nav>
<div className="section-header reveal">
<span className="section-tag">{t.videos.tag}</span>
<h1>{t.videos.title}</h1>
<p>{t.videos.subtitle}</p>
</div>
<div className="community-list">
{videos.map((v) => (
<Link key={v.id} href={`/videos/${v.slug}`} className="community-card reveal">
<div className="community-card-head">
<span>{v.emoji}</span>
<span className="community-meta">{v.city} · {v.duration}</span>
</div>
<h3>{v.title}</h3>
<p className="community-excerpt">{v.excerpt}</p>
</Link>
))}
</div>
</div>
</div>
);
}

View File

@ -1,9 +1,9 @@
import type {
AuthResponse, BlogPost, BlogPostDetail, ChartData, ChatMessage, ConversationItem,
AuthResponse, BlogPost, BlogPostDetail, ChartData, ChatMessage, CityRankItem, ConversationItem,
CostResult, Destination, Discussion, DiscussionDetail, DigitalCourse, DigitalJob,
DigitalLesson, FAQ, GigItem, MatchProfile, MatchQuota, Meetup, MeetupSession, MemberProfile,
DigitalLesson, FAQ, GigItem, MatchProfile, MatchQuota, Meetup, MeetupSession, MemberMapItem, MemberProfile,
NextStopResult, NomadRoute, NotificationItem, PayCreateResult, ProfileStats, SearchResult,
Stats, SwipeResult, SyncedUserPlan, Testimonial, Tool, VipStatus, Visa,
ServiceItem, Stats, SwipeResult, SyncedUserPlan, Testimonial, Tool, VideoItem, VipStatus, Visa, WeatherCity,
} from "./types";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
@ -226,8 +226,29 @@ export const api = {
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ message }),
}),
createGig: (token: string, body: Record<string, unknown>) =>
fetchAPI<{ success: boolean }>("/gigs", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
}),
getNotifications: (token: string) =>
fetchAPI<NotificationItem[]>("/notifications", { headers: { Authorization: `Bearer ${token}` } }),
getNotificationUnread: (token: string) =>
fetchAPI<{ count: number }>("/notifications/unread-count", { headers: { Authorization: `Bearer ${token}` } }),
notificationAction: (token: string, id: string, action: string) =>
fetchAPI<{ success: boolean }>(`/notifications/${id}/${action}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
}),
getNotificationPrefs: (token: string) =>
fetchAPI<Record<string, boolean>>("/notifications/preferences", { headers: { Authorization: `Bearer ${token}` } }),
setNotificationPrefs: (token: string, prefs: Record<string, boolean>) =>
fetchAPI<Record<string, boolean>>("/notifications/preferences", {
method: "PUT",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(prefs),
}),
markNotificationsRead: (token: string) =>
fetchAPI<{ success: boolean }>("/notifications/read", {
method: "POST",
@ -247,4 +268,55 @@ export const api = {
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: JSON.stringify({ content, category }),
}),
getServices: () => fetchWithFallback<ServiceItem[]>("/services", []),
submitServiceLead: (serviceId: string, body: { name: string; email: string; message: string }) =>
fetchAPI<{ success: boolean }>("/services/leads", {
method: "POST",
body: JSON.stringify({ service_id: serviceId, ...body }),
}),
getVideos: () => fetchWithFallback<VideoItem[]>("/videos", []),
getVideo: (slug: string) => fetchAPI<VideoItem>(`/videos/${slug}`),
askAssistant: (message: string) =>
fetchAPI<{ reply: string; cities: { slug: string; name: string; emoji: string }[] }>("/ai/assistant", {
method: "POST",
body: JSON.stringify({ message }),
}),
subscribeNewsletter: (email: string, source = "site") =>
fetchAPI<{ ok: boolean; message: string }>("/newsletter/subscribe", {
method: "POST",
body: JSON.stringify({ email, source }),
}),
getMemberMap: () => fetchWithFallback<{ members: MemberMapItem[] }>("/stats/member-map", { members: [] }),
getCityRanking: () => fetchWithFallback<{ items: CityRankItem[] }>("/stats/city-ranking", { items: [] }),
getWeatherCities: () => fetchWithFallback<WeatherCity[]>("/weather/cities", []),
submitContent: (body: { title: string; url: string; notes: string; content_type: string; name: string }, token: string) =>
fetchAPI<{ success: boolean }>("/content/submissions", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
}),
submitVolunteer: (slug: string, body: { name: string; email: string; message: string }) =>
fetchAPI<{ success: boolean }>(`/cities/${slug}/volunteer-applications`, {
method: "POST",
body: JSON.stringify(body),
}),
submitReport: (body: { target_type: string; target_id: string; reason: string }, token?: string) =>
fetchAPI<{ success: boolean }>("/reports", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: JSON.stringify(body),
}),
getDigitalDay: (id: string) => fetchAPI<{ id: string; title: string; body: string }>(`/digital/day/${id}`),
googleLogin: (idToken: string) =>
fetchAPI<AuthResponse>("/auth/google", {
method: "POST",
body: JSON.stringify({ id_token: idToken }),
}),
searchDiscussions: (q: string) => fetchWithFallback<Discussion[]>(`/discussions/search?q=${encodeURIComponent(q)}`, []),
cancelMeetupRsvp: (token: string, meetupId: string) =>
fetchAPI<{ ok: boolean }>(`/meetups/${meetupId}/rsvp`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}),
};

View File

@ -283,6 +283,14 @@ export const zh = {
gigs: "赏金任务",
pricing: "会员定价",
notifications: "通知中心",
feedback: "意见反馈",
help: "帮助中心",
contact: "联系我们",
videos: "游民访谈",
services: "游民服务",
ai: "旅居助手",
mapPage: "游民地图",
report: "数据报告",
},
dating: {
tag: "💕 MATCH",
@ -480,6 +488,14 @@ export const zh = {
applyOk: "申请已提交",
applyFail: "申请失败",
loginFirst: "请先登录",
postTitle: "发布赏金任务",
postName: "任务标题",
postDesc: "任务描述与交付要求",
postBudget: "预算(如 ¥800)",
postSubmit: "发布",
postOk: "任务已发布",
postFail: "发布失败",
back: "返回赏金列表",
},
notifications: {
title: "通知",
@ -497,6 +513,94 @@ export const zh = {
year: "年",
try: "开始匹配",
},
feedback: {
tag: "💬 FEEDBACK",
title: "意见反馈",
subtitle: "Bug、功能建议或安全问题 —— 我们会认真阅读",
placeholder: "描述你遇到的问题或建议…",
submit: "提交反馈",
ok: "感谢反馈!",
fail: "提交失败",
catGeneral: "一般反馈",
catBug: "Bug 报告",
catFeature: "功能建议",
catSafety: "安全问题",
},
videos: {
tag: "🎬 VIDEOS",
title: "游民访谈",
subtitle: "真实旅居故事与目的地实录",
back: "返回视频列表",
},
services: {
tag: "🛎️ SERVICES",
title: "游民服务",
subtitle: "认证顾问与志愿者提供的落地支持",
name: "你的姓名",
email: "邮箱",
message: "需求描述",
inquire: "咨询",
leadOk: "咨询已提交",
leadFail: "提交失败",
},
ai: {
tag: "🤖 AI",
title: "旅居助手",
subtitle: "描述预算、网速、气候偏好 —— 智能推荐城市",
placeholder: "例如:预算 8000、要网速快、气候温暖…",
ask: "获取推荐",
fail: "暂时无法回答,请稍后再试",
nextStop: "打开下一站决策",
},
report: {
tag: "📊 REPORT",
title: "数据报告",
subtitle: "城市热度与游民分布快照",
ranking: "城市排名",
members: "活跃游民",
viewMap: "查看地图",
},
mapPage: {
tag: "🗺️ MAP",
title: "游民地图",
subtitle: "看看同路游民分布在哪里",
weather: "目的地天气",
hint: "完整交互地图见首页世界地图区块",
},
submit: {
tag: "📝 SUBMIT",
title: "内容投稿",
subtitle: "分享你的旅居文章、视频或电子书,审核后展示",
article: "文章",
video: "视频",
ebook: "电子书",
subject: "标题",
url: "链接(如有)",
notes: "补充说明",
send: "提交审核",
ok: "投稿已收到",
fail: "提交失败",
},
notifSettings: {
title: "通知设置",
back: "返回通知",
save: "保存设置",
saved: "设置已保存",
fail: "保存失败",
match: "匹配通知",
meetup: "活动提醒",
community: "社区动态",
email: "邮件摘要",
marketing: "活动营销",
},
newsletter: {
title: "订阅游民周报",
subtitle: "活动预告、签证变动与社区精选 —— 每周一封",
email: "你的邮箱",
submit: "订阅",
ok: "订阅成功",
fail: "订阅失败",
},
pwa: {
installHint: "可安装到主屏幕,离线也能打开计划入口",
},
@ -791,6 +895,14 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
gigs: "Bounty gigs",
pricing: "Pricing",
notifications: "Notifications",
feedback: "Feedback",
help: "Help",
contact: "Contact",
videos: "Videos",
services: "Services",
ai: "AI assistant",
mapPage: "Nomad map",
report: "Data report",
},
dating: {
tag: "💕 MATCH",
@ -988,6 +1100,14 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
applyOk: "Application sent",
applyFail: "Application failed",
loginFirst: "Sign in first",
postTitle: "Post a gig",
postName: "Task title",
postDesc: "Description and deliverables",
postBudget: "Budget (e.g. ¥800)",
postSubmit: "Publish",
postOk: "Gig published",
postFail: "Could not publish",
back: "Back to gigs",
},
notifications: {
title: "Notifications",
@ -1005,6 +1125,94 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
year: "year",
try: "Start matching",
},
feedback: {
tag: "💬 FEEDBACK",
title: "Feedback",
subtitle: "Bugs, ideas, or safety issues — we read every message",
placeholder: "Describe your issue or suggestion…",
submit: "Send feedback",
ok: "Thanks for your feedback!",
fail: "Could not send",
catGeneral: "General",
catBug: "Bug report",
catFeature: "Feature request",
catSafety: "Safety",
},
videos: {
tag: "🎬 VIDEOS",
title: "Nomad interviews",
subtitle: "Real stories from the road",
back: "Back to videos",
},
services: {
tag: "🛎️ SERVICES",
title: "Nomad services",
subtitle: "Vetted advisors and city volunteers",
name: "Your name",
email: "Email",
message: "What do you need?",
inquire: "Inquire",
leadOk: "Inquiry sent",
leadFail: "Could not send",
},
ai: {
tag: "🤖 AI",
title: "Travel assistant",
subtitle: "Budget, internet, climate — get city picks",
placeholder: "e.g. budget 8000, fast internet, warm climate…",
ask: "Get picks",
fail: "Could not answer — try again",
nextStop: "Open next-stop hub",
},
report: {
tag: "📊 REPORT",
title: "Data report",
subtitle: "City rankings and member snapshot",
ranking: "City ranking",
members: "Active nomads",
viewMap: "View map",
},
mapPage: {
tag: "🗺️ MAP",
title: "Nomad map",
subtitle: "Where community members are based",
weather: "Destination weather",
hint: "Full interactive map on the homepage",
},
submit: {
tag: "📝 SUBMIT",
title: "Submit content",
subtitle: "Share articles, videos, or ebooks for review",
article: "Article",
video: "Video",
ebook: "Ebook",
subject: "Title",
url: "Link (optional)",
notes: "Notes",
send: "Submit",
ok: "Submission received",
fail: "Could not submit",
},
notifSettings: {
title: "Notification settings",
back: "Back to inbox",
save: "Save",
saved: "Settings saved",
fail: "Could not save",
match: "Match alerts",
meetup: "Event reminders",
community: "Community",
email: "Email digest",
marketing: "Marketing",
},
newsletter: {
title: "Nomad weekly",
subtitle: "Events, visa updates & community picks — weekly email",
email: "Your email",
submit: "Subscribe",
ok: "Subscribed",
fail: "Could not subscribe",
},
pwa: {
installHint: "Install to home screen — open plan offline-ready",
},

View File

@ -0,0 +1,122 @@
export interface StaticSection {
heading: string;
body?: string;
list?: string[];
}
export interface StaticPageDef {
tag: string;
title: string;
subtitle?: string;
sections: StaticSection[];
cta?: { label: string; href: string }[];
}
export const STATIC_PAGES: Record<string, StaticPageDef> = {
contact: {
tag: "📬 CONTACT",
title: "联系我们",
subtitle: "合作、反馈与媒体咨询",
sections: [
{ heading: "邮箱", body: "hello@nomadro.com — 一般咨询与反馈" },
{ heading: "商务合作", body: "partners@nomadro.com — 品牌、活动与联合办公合作" },
{ heading: "响应时间", body: "工作日 48 小时内回复。紧急安全问题请标注「紧急」。" },
],
cta: [{ label: "提交反馈", href: "/feedback" }],
},
help: {
tag: "❓ HELP",
title: "帮助中心",
sections: [
{ heading: "如何开始", body: "注册账号 → 探索目的地 → 创建旅居计划 → 参与社区与活动。" },
{
heading: "常见问题",
list: [
"匹配功能需要先完善游民资料(/join)",
"VIP 会员解锁无限滑动、直播活动与学院课程",
"旅居计划支持云端同步与 ICS 导出",
"活动 RSVP 后可在活动页进入直播房间",
],
},
{ heading: "更多", body: "查看 FAQ 区块或社区讨论搜索已有话题。" },
],
cta: [{ label: "打开 FAQ", href: "/#faq" }, { label: "社区讨论", href: "/community" }],
},
safety: {
tag: "🛡️ SAFETY",
title: "安全指南",
sections: [
{ heading: "线下见面", body: "首次见面选择公共场所,告知朋友行程,勿轻易转账。" },
{ heading: "账号安全", body: "使用强密码,勿分享登录 token。发现异常请立即修改密码并反馈。" },
{ heading: "举报", body: "遇到骚扰、诈骗或违规内容,请使用举报功能或邮件联系我们。" },
],
cta: [{ label: "举报问题", href: "/feedback" }],
},
guidelines: {
tag: "📜 GUIDELINES",
title: "社区准则",
sections: [
{
heading: "我们鼓励",
list: ["真实经验分享", "尊重多元文化", "建设性讨论", "帮助他人落地"],
},
{
heading: "我们禁止",
list: ["仇恨言论与骚扰", "虚假签证/投资信息", "垃圾广告", "盗用他人内容"],
},
{ heading: "违规处理", body: "内容可能被隐藏,严重者封禁账号。" },
],
},
terms: {
tag: "📋 TERMS",
title: "服务条款",
subtitle: "最后更新:2026-08-30",
sections: [
{ heading: "服务说明", body: "nomadro 提供旅居信息、社区与工具,不构成法律、税务或移民建议。" },
{ heading: "用户责任", body: "你对自己发布的内容负责,并遵守当地法律法规。" },
{ heading: "免责声明", body: "目的地数据、签证信息仅供参考,请以官方渠道为准。" },
],
},
cookies: {
tag: "🍪 COOKIES",
title: "Cookie 政策",
sections: [
{ heading: "我们使用的 Cookie", body: "主题偏好、登录状态、行程规划本地缓存、Cookie 同意记录。" },
{ heading: "第三方", body: "可能使用分析工具(可在功能开关中关闭)。" },
{ heading: "管理", body: "可在浏览器设置中清除 Cookie;部分功能可能因此不可用。" },
],
cta: [{ label: "隐私政策", href: "/privacy" }],
},
press: {
tag: "📰 PRESS",
title: "媒体报道",
sections: [
{ heading: "品牌资料", body: "nomadro — 数字游民旅居决策与社区平台。口号:用一行代码环游世界。" },
{ heading: "媒体联系", body: "press@nomadro.com" },
{ heading: "数据亮点", body: "覆盖 195+ 国家目的地数据、52+ 旅居工具、活跃游民社区。" },
],
},
careers: {
tag: "💼 CAREERS",
title: "加入我们",
sections: [
{ heading: "远程优先", body: "nomadro 团队分布全球,全职与兼职远程岗位开放。" },
{
heading: "开放方向",
list: ["全栈工程师", "社区运营", "内容编辑", "城市志愿者"],
},
{ heading: "投递", body: "hello@nomadro.com,标题注明「应聘 - 岗位名」。" },
],
cta: [{ label: "赏金任务", href: "/gigs" }],
},
support: {
tag: "🤝 SUPPORT",
title: "支持 nomadro",
sections: [
{ heading: "成为 VIP", body: "解锁匹配、直播、学院课程,支持平台持续运营。" },
{ heading: "分享", body: "推荐给朋友,或在社交媒体分享你的旅居故事。" },
{ heading: "贡献内容", body: "投稿视频、电子书或博客,经审核后展示。" },
],
cta: [{ label: "开通会员", href: "/join" }, { label: "内容投稿", href: "/submit" }],
},
};

View File

@ -328,9 +328,59 @@ export interface NotificationItem {
body: string;
link: string;
read: boolean;
archived?: boolean;
pinned?: boolean;
category?: string;
created_at: string;
}
export interface ServiceItem {
id: string;
title: string;
description: string;
emoji: string;
price: string;
category: string;
provider: string;
}
export interface VideoItem {
id: string;
slug: string;
title: string;
excerpt: string;
emoji: string;
duration: string;
guest: string;
city: string;
published_at: string;
tags: string[];
video_url: string;
}
export interface MemberMapItem {
name: string;
city: string;
lat?: number;
lng?: number;
emoji: string;
}
export interface CityRankItem {
slug: string;
name: string;
score: number;
nomads: string;
}
export interface WeatherCity {
slug?: string;
name: string;
temperature: number | null;
humidity?: number;
wind_speed?: number;
}
export interface MemberProfile {
id?: string;
userId?: string;

View File

@ -53,7 +53,13 @@ MARKER="__MARKER__"
cd "$REMOTE"
git remote set-url origin "$GIT"
git fetch --depth=80 origin
git fetch --depth=80 origin dev1
git clean -fd \
-e deploy/nomadro-api.env \
-e backend/app/data/user_store.json \
-e backend/app/data/social_store.json \
-e backend/app/data/community_store.json \
-e .last_deploy_sha
git checkout -B dev1 origin/dev1
git reset --hard origin/dev1