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:
parent
ff7b721acb
commit
48408d3079
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||
|
||||
116
backend/app/data/platform_data.py
Normal file
116
backend/app/data/platform_data.py
Normal 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": "东京"},
|
||||
}
|
||||
@ -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("/")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
185
backend/app/routers/platform.py
Normal file
185
backend/app/routers/platform.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
50
backend/app/services/ai_service.py
Normal file
50
backend/app/services/ai_service.py
Normal 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}
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
141
backend/app/services/platform_store.py
Normal file
141
backend/app/services/platform_store.py
Normal 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]
|
||||
@ -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]:
|
||||
|
||||
44
backend/app/services/weather_service.py
Normal file
44
backend/app/services/weather_service.py
Normal 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']}"
|
||||
"¤t=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
|
||||
16
frontend/src/app/ai/page.tsx
Normal file
16
frontend/src/app/ai/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/app/careers/page.tsx
Normal file
10
frontend/src/app/careers/page.tsx
Normal 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>;
|
||||
}
|
||||
19
frontend/src/app/contact/page.tsx
Normal file
19
frontend/src/app/contact/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/app/cookies/page.tsx
Normal file
10
frontend/src/app/cookies/page.tsx
Normal 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>;
|
||||
}
|
||||
29
frontend/src/app/digital/day/[id]/page.tsx
Normal file
29
frontend/src/app/digital/day/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/app/feedback/page.tsx
Normal file
16
frontend/src/app/feedback/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/app/gigs/post/page.tsx
Normal file
15
frontend/src/app/gigs/post/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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));
|
||||
|
||||
19
frontend/src/app/guidelines/page.tsx
Normal file
19
frontend/src/app/guidelines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/app/help/page.tsx
Normal file
19
frontend/src/app/help/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/app/map/page.tsx
Normal file
16
frontend/src/app/map/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/app/press/page.tsx
Normal file
10
frontend/src/app/press/page.tsx
Normal 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>;
|
||||
}
|
||||
16
frontend/src/app/report/page.tsx
Normal file
16
frontend/src/app/report/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/app/safety/page.tsx
Normal file
19
frontend/src/app/safety/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/app/services/page.tsx
Normal file
18
frontend/src/app/services/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/app/settings/notifications/page.tsx
Normal file
15
frontend/src/app/settings/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
15
frontend/src/app/submit/page.tsx
Normal file
15
frontend/src/app/submit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/app/support/page.tsx
Normal file
10
frontend/src/app/support/page.tsx
Normal 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>;
|
||||
}
|
||||
19
frontend/src/app/terms/page.tsx
Normal file
19
frontend/src/app/terms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/app/videos/[slug]/page.tsx
Normal file
22
frontend/src/app/videos/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/app/videos/page.tsx
Normal file
18
frontend/src/app/videos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/AiAssistantClient.tsx
Normal file
56
frontend/src/components/AiAssistantClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/ContentSubmitClient.tsx
Normal file
60
frontend/src/components/ContentSubmitClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/FeedbackClient.tsx
Normal file
54
frontend/src/components/FeedbackClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
53
frontend/src/components/GigPostClient.tsx
Normal file
53
frontend/src/components/GigPostClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) => (
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
56
frontend/src/components/MemberMapClient.tsx
Normal file
56
frontend/src/components/MemberMapClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
34
frontend/src/components/NewsletterSubscribe.tsx
Normal file
34
frontend/src/components/NewsletterSubscribe.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/NotificationSettingsClient.tsx
Normal file
66
frontend/src/components/NotificationSettingsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -36,7 +36,10 @@ export default function NotificationsClient() {
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<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 className="chat-list">
|
||||
{items.map((n) => (
|
||||
|
||||
58
frontend/src/components/ReportClient.tsx
Normal file
58
frontend/src/components/ReportClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/ServicesClient.tsx
Normal file
51
frontend/src/components/ServicesClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/StaticPage.tsx
Normal file
37
frontend/src/components/StaticPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/VideoDetailClient.tsx
Normal file
26
frontend/src/components/VideoDetailClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/VideosClient.tsx
Normal file
33
frontend/src/components/VideosClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}` },
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
122
frontend/src/lib/staticPages.ts
Normal file
122
frontend/src/lib/staticPages.ts
Normal 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" }],
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user