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) # Auth / plan user store (runtime)
backend/app/data/user_store.json backend/app/data/user_store.json
backend/app/data/social_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) # Production secrets (use deploy/nomadro-api.env.example)
deploy/nomadro-api.env 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 fastapi.middleware.cors import CORSMiddleware
from app.config import settings 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( app = FastAPI(
title="nomadro API", 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(auth.router, prefix="/api/v1")
app.include_router(social.router, prefix="/api/v1") app.include_router(social.router, prefix="/api/v1")
app.include_router(community.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("/") @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) @router.get("/discussions/{discussion_id}", response_model=DiscussionDetail)
async def get_discussion(discussion_id: str): 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: if not d:
raise HTTPException(404, "讨论不存在") raise HTTPException(404, "讨论不存在")
return DiscussionDetail(**d) return DiscussionDetail(**d)

View File

@ -1,13 +1,15 @@
from fastapi import APIRouter, Header, HTTPException from fastapi import APIRouter, Header, HTTPException
import httpx
from app.schemas import ( from app.schemas import (
AuthLogin, AuthRegister, AuthResponse, Destination, AuthLogin, AuthRegister, AuthResponse, Destination, GoogleLoginRequest,
FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile, FavoriteRequest, FavoriteResponse, ProfileStats, UserProfile,
UserPlanPayload, UserPlanPayload,
) )
from app.services.auth import ( from app.services.auth import (
get_favorites, get_plan, get_user_by_token, 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 from app.services.pocketbase import pb
@ -48,6 +50,29 @@ async def demo_login():
return AuthResponse(**result) 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) @router.get("/favorites", response_model=FavoriteResponse)
async def list_favorites(authorization: str | None = Header(None)): async def list_favorites(authorization: str | None = Header(None)):
token = _extract_token(authorization) token = _extract_token(authorization)

View File

@ -3,7 +3,7 @@
from fastapi import APIRouter, Header, HTTPException, Query from fastapi import APIRouter, Header, HTTPException, Query
from app.schemas import ( from app.schemas import (
CreateDiscussionRequest, CreateMeetupRequest, CreateReplyRequest, CreateDiscussionRequest, CreateGigRequest, CreateMeetupRequest, CreateReplyRequest,
FeedbackRequest, GigApplyRequest, GigItem, NotificationItem, StatsOverview, FeedbackRequest, GigApplyRequest, GigItem, NotificationItem, StatsOverview,
) )
from app.services.auth import get_user_by_token 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()] 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") @router.post("/gigs/{gig_id}/apply")
async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | None = Header(None)): async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | None = Header(None)):
user = _user(authorization) user = _user(authorization)
@ -96,6 +103,17 @@ async def mark_read(authorization: str | None = Header(None)):
return {"success": True} 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") @router.post("/feedback")
async def submit_feedback(body: FeedbackRequest, authorization: str | None = Header(None)): async def submit_feedback(body: FeedbackRequest, authorization: str | None = Header(None)):
user = 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 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]) @router.get("/matches/likes", response_model=list[MatchProfile])
async def match_likes(authorization: str | None = Header(None)): async def match_likes(authorization: str | None = Header(None)):
user = _token_user(authorization) user = _token_user(authorization)

View File

@ -456,9 +456,89 @@ class NotificationItem(BaseModel):
body: str = "" body: str = ""
link: str = "" link: str = ""
read: bool = False read: bool = False
archived: bool = False
pinned: bool = False
category: str = "general"
created_at: str = "" 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): class FeedbackRequest(BaseModel):
content: str = Field(min_length=4, max_length=4000) content: str = Field(min_length=4, max_length=4000)
category: str = "general" 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)} 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: def login_demo() -> dict[str, Any] | None:
email = "demo@nomadro.com" email = "demo@nomadro.com"
if email not in _users: if email not in _users:

View File

@ -25,6 +25,7 @@ _gigs: list[dict] = []
_gig_apps: list[dict] = [] _gig_apps: list[dict] = []
_notifications: dict[str, list[dict]] = {} # user_id -> items _notifications: dict[str, list[dict]] = {} # user_id -> items
_feedback: list[dict] = [] _feedback: list[dict] = []
_views: dict[str, int] = {} # discussion_id -> view count
_seeded = False _seeded = False
@ -51,6 +52,7 @@ def _persist() -> None:
"gig_apps": _gig_apps, "gig_apps": _gig_apps,
"notifications": _notifications, "notifications": _notifications,
"feedback": _feedback, "feedback": _feedback,
"views": _views,
"seeded": _seeded, "seeded": _seeded,
}, ensure_ascii=False), }, ensure_ascii=False),
encoding="utf-8", encoding="utf-8",
@ -59,7 +61,7 @@ def _persist() -> None:
def _restore() -> None: def _restore() -> None:
global _meetups, _discussions, _replies, _discussion_likes, _reply_likes 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(): if not STORE_PATH.exists():
return return
try: try:
@ -76,6 +78,7 @@ def _restore() -> None:
_gig_apps = data.get("gig_apps") or [] _gig_apps = data.get("gig_apps") or []
_notifications = data.get("notifications") or {} _notifications = data.get("notifications") or {}
_feedback = data.get("feedback") or [] _feedback = data.get("feedback") or []
_views = data.get("views") or {}
_seeded = bool(data.get("seeded")) _seeded = bool(data.get("seeded"))
@ -151,6 +154,7 @@ def _enrich_discussion(d: dict) -> dict:
**d, **d,
"reply_count": len(replies), "reply_count": len(replies),
"like_count": max(base_likes, stored) if stored == 0 else base_likes + stored, "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 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() _seed_if_needed()
for d in _discussions: for d in _discussions:
if d["id"] == discussion_id: if d["id"] == discussion_id:
if increment_view:
_views[discussion_id] = _views.get(discussion_id, 0) + 1
_persist()
enriched = _enrich_discussion(d) enriched = _enrich_discussion(d)
replies = [] replies = []
for r in _replies.get(discussion_id, []): 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": "申请已提交"} 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 = _notifications.setdefault(user_id, [])
items.insert(0, { items.insert(0, {
"id": secrets.token_hex(6), "id": secrets.token_hex(6),
"title": title, "title": title,
"body": body, "body": body,
"link": link, "link": link,
"category": category,
"read": False, "read": False,
"archived": False,
"pinned": False,
"created_at": _now_iso(), "created_at": _now_iso(),
}) })
items[:] = items[:50] 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]: def list_notifications(user_id: str) -> list[dict]:
_reload() return list_notifications_filtered(user_id)
return _notifications.get(user_id, [])
def mark_notifications_read(user_id: str) -> None: 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")) swiped_ids.add(s.get("profileId"))
pool = list(CANDIDATE_PROFILES) 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: if my_profile:
pool = [p for p in pool if p.get("userId") != user_id] 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 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: def record_swipe(user_id: str, profile_id: str, action: str, intent: str) -> dict:
_reload() _reload()
if action not in LIKE_ACTIONS and action != "dislike": 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: if s.get("userId") == user_id and s.get("profileId") == profile_id:
return {"ok": False, "error": "duplicate"} 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: if not profile:
return {"ok": False, "error": "not_found"} 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"} 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]: def list_likes(user_id: str) -> list[dict]:
_reload() _reload()
liked_ids = [s["profileId"] for s in _swipes if s.get("userId") == user_id and s.get("action") in LIKE_ACTIONS] 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]: 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); 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 { .dating-likes-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); 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}/community/new`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/meetups/host`, 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}/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/course`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 }, { url: `${SITE}/digital/jobs`, lastModified: now, changeFrequency: "weekly", priority: 0.75 },
{ url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 }, { 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="/gigs">{t.footer.gigs}</Link>
<Link href="/digital">{t.footer.digital}</Link> <Link href="/digital">{t.footer.digital}</Link>
<Link href="/pricing">{t.footer.pricing}</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> <Link href="/about">{t.footer.about}</Link>
</div> </div>
<div className="footer-links"> <div className="footer-links">
@ -86,6 +91,9 @@ export default function Footer() {
<Link href="/join">{t.footer.join}</Link> <Link href="/join">{t.footer.join}</Link>
<Link href="/chat">{t.footer.chat}</Link> <Link href="/chat">{t.footer.chat}</Link>
<Link href="/notifications">{t.footer.notifications}</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> <Link href="/privacy">{t.footer.privacy}</Link>
<a href="mailto:hello@nomadro.com">hello@nomadro.com</a> <a href="mailto:hello@nomadro.com">hello@nomadro.com</a>
</div> </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> <span className="section-tag">{t.gigs.tag}</span>
<h1>{t.gigs.title}</h1> <h1>{t.gigs.title}</h1>
<p>{t.gigs.subtitle}</p> <p>{t.gigs.subtitle}</p>
<Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link>
</div> </div>
<div className="digital-jobs-grid reveal"> <div className="digital-jobs-grid reveal">
{gigs.map((g) => ( {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: "/chat" },
{ type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" }, { type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
{ type: "page", title: "赏金任务", subtitle: "远程小任务接单", emoji: "💼", url: "/gigs" }, { 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: "免费版与 VIP 方案", emoji: "💎", url: "/pricing" },
{ type: "page", title: "通知中心", subtitle: "匹配与社区动态", emoji: "🔔", url: "/notifications" }, { type: "page", title: "通知中心", subtitle: "匹配与社区动态", emoji: "🔔", url: "/notifications" },
{ type: "page", title: "开通会员", subtitle: "VIP 匹配直播与课程", emoji: "✨", url: "/join" }, { 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 { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { Meetup } from "@/lib/types"; import type { Meetup } from "@/lib/types";
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
const MODE_LABELS: Record<string, string> = { const MODE_LABELS: Record<string, string> = {
online: "💻 线上", online: "💻 线上",
@ -131,6 +132,8 @@ export default function MeetupsClient({ meetups }: Props) {
{filtered.length === 0 && ( {filtered.length === 0 && (
<p className="dest-empty">{t.meetups.empty}</p> <p className="dest-empty">{t.meetups.empty}</p>
)} )}
<NewsletterSubscribe source="meetups" />
</div> </div>
</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 { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { loginNextFrom } from "@/lib/authNext"; import { loginNextFrom } from "@/lib/authNext";
import { api } from "@/lib/api";
export default function Navbar() { export default function Navbar() {
const { user } = useAuth(); const { user, token } = useAuth();
const { t, locale, toggleLocale } = useI18n(); const { t, locale, toggleLocale } = useI18n();
const pathname = usePathname(); const pathname = usePathname();
const isHome = pathname === "/"; const isHome = pathname === "/";
const [theme, setTheme] = useState<"dark" | "light">("dark"); const [theme, setTheme] = useState<"dark" | "light">("dark");
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [active, setActive] = useState(""); 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 = [ const ALL_LINKS = [
{ href: "/#map", label: t.nav.map, section: "map" }, { href: "/#map", label: t.nav.map, section: "map" },
@ -133,7 +140,9 @@ export default function Navbar() {
</button> </button>
{user ? ( {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"> <Link href="/profile" className="nav-user">
<span>{user.avatar}</span> <span>{user.avatar}</span>
<span className="nav-user-name">{user.name}</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="container">
<div className="section-header reveal"> <div className="section-header reveal">
<h1>{t.notifications.title}</h1> <h1>{t.notifications.title}</h1>
<button type="button" className="btn btn-sm" onClick={markAll}>{t.notifications.markRead}</button> <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>
<div className="chat-list"> <div className="chat-list">
{items.map((n) => ( {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 { import type {
AuthResponse, BlogPost, BlogPostDetail, ChartData, ChatMessage, ConversationItem, AuthResponse, BlogPost, BlogPostDetail, ChartData, ChatMessage, CityRankItem, ConversationItem,
CostResult, Destination, Discussion, DiscussionDetail, DigitalCourse, DigitalJob, 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, 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"; } from "./types";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; 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}` }, headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ message }), 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) => getNotifications: (token: string) =>
fetchAPI<NotificationItem[]>("/notifications", { headers: { Authorization: `Bearer ${token}` } }), 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) => markNotificationsRead: (token: string) =>
fetchAPI<{ success: boolean }>("/notifications/read", { fetchAPI<{ success: boolean }>("/notifications/read", {
method: "POST", method: "POST",
@ -247,4 +268,55 @@ export const api = {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
body: JSON.stringify({ content, category }), 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: "赏金任务", gigs: "赏金任务",
pricing: "会员定价", pricing: "会员定价",
notifications: "通知中心", notifications: "通知中心",
feedback: "意见反馈",
help: "帮助中心",
contact: "联系我们",
videos: "游民访谈",
services: "游民服务",
ai: "旅居助手",
mapPage: "游民地图",
report: "数据报告",
}, },
dating: { dating: {
tag: "💕 MATCH", tag: "💕 MATCH",
@ -480,6 +488,14 @@ export const zh = {
applyOk: "申请已提交", applyOk: "申请已提交",
applyFail: "申请失败", applyFail: "申请失败",
loginFirst: "请先登录", loginFirst: "请先登录",
postTitle: "发布赏金任务",
postName: "任务标题",
postDesc: "任务描述与交付要求",
postBudget: "预算(如 ¥800)",
postSubmit: "发布",
postOk: "任务已发布",
postFail: "发布失败",
back: "返回赏金列表",
}, },
notifications: { notifications: {
title: "通知", title: "通知",
@ -497,6 +513,94 @@ export const zh = {
year: "年", year: "年",
try: "开始匹配", 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: { pwa: {
installHint: "可安装到主屏幕,离线也能打开计划入口", installHint: "可安装到主屏幕,离线也能打开计划入口",
}, },
@ -791,6 +895,14 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
gigs: "Bounty gigs", gigs: "Bounty gigs",
pricing: "Pricing", pricing: "Pricing",
notifications: "Notifications", notifications: "Notifications",
feedback: "Feedback",
help: "Help",
contact: "Contact",
videos: "Videos",
services: "Services",
ai: "AI assistant",
mapPage: "Nomad map",
report: "Data report",
}, },
dating: { dating: {
tag: "💕 MATCH", tag: "💕 MATCH",
@ -988,6 +1100,14 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
applyOk: "Application sent", applyOk: "Application sent",
applyFail: "Application failed", applyFail: "Application failed",
loginFirst: "Sign in first", 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: { notifications: {
title: "Notifications", title: "Notifications",
@ -1005,6 +1125,94 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
year: "year", year: "year",
try: "Start matching", 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: { pwa: {
installHint: "Install to home screen — open plan offline-ready", 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; body: string;
link: string; link: string;
read: boolean; read: boolean;
archived?: boolean;
pinned?: boolean;
category?: string;
created_at: 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 { export interface MemberProfile {
id?: string; id?: string;
userId?: string; userId?: string;

View File

@ -53,7 +53,13 @@ MARKER="__MARKER__"
cd "$REMOTE" cd "$REMOTE"
git remote set-url origin "$GIT" 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 checkout -B dev1 origin/dev1
git reset --hard origin/dev1 git reset --hard origin/dev1