Compare commits
10 Commits
377db2284f
...
5c36032a46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c36032a46 | ||
|
|
2fa59f0cd4 | ||
|
|
863643aa33 | ||
|
|
ab9d8f6670 | ||
|
|
1c44721062 | ||
|
|
e23cf9a03c | ||
|
|
7d5ac47ee1 | ||
|
|
67fd09341b | ||
|
|
5d741df77b | ||
|
|
f9ab758926 |
@ -386,6 +386,7 @@ GIGS = [
|
||||
"deadline": "2026-09-15",
|
||||
"tags": ["设计", "远程"],
|
||||
"poster": "nomadro",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "设计",
|
||||
"location": "远程",
|
||||
@ -398,6 +399,7 @@ GIGS = [
|
||||
"deadline": "2026-09-20",
|
||||
"tags": ["活动", "深圳"],
|
||||
"poster": "深圳湾区主理人",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "活动",
|
||||
"location": "深圳",
|
||||
@ -410,6 +412,7 @@ GIGS = [
|
||||
"deadline": "2026-10-01",
|
||||
"tags": ["翻译", "内容"],
|
||||
"poster": "nomadro",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "内容",
|
||||
"location": "远程",
|
||||
@ -422,6 +425,7 @@ GIGS = [
|
||||
"deadline": "2026-10-10",
|
||||
"tags": ["前端", "Next.js"],
|
||||
"poster": "Marco",
|
||||
"poster_email": "marco@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "开发",
|
||||
"location": "远程",
|
||||
@ -434,6 +438,7 @@ GIGS = [
|
||||
"deadline": "2026-10-12",
|
||||
"tags": ["视频", "剪辑"],
|
||||
"poster": "Lina",
|
||||
"poster_email": "lina@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "内容",
|
||||
"location": "远程",
|
||||
@ -446,6 +451,7 @@ GIGS = [
|
||||
"deadline": "2026-10-08",
|
||||
"tags": ["SEO", "内容"],
|
||||
"poster": "nomadro",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "运营",
|
||||
"location": "远程",
|
||||
@ -458,6 +464,7 @@ GIGS = [
|
||||
"deadline": "2026-10-05",
|
||||
"tags": ["摄影", "大理"],
|
||||
"poster": "Lina",
|
||||
"poster_email": "lina@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "设计",
|
||||
"location": "大理",
|
||||
@ -470,6 +477,7 @@ GIGS = [
|
||||
"deadline": "2026-10-18",
|
||||
"tags": ["税务", "文档"],
|
||||
"poster": "Devon",
|
||||
"poster_email": "devon@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "内容",
|
||||
"location": "远程",
|
||||
@ -482,6 +490,7 @@ GIGS = [
|
||||
"deadline": "2026-10-08",
|
||||
"tags": ["摄影", "活动"],
|
||||
"poster": "Sofia",
|
||||
"poster_email": "sofia@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "活动",
|
||||
"location": "麦德林",
|
||||
@ -494,6 +503,7 @@ GIGS = [
|
||||
"deadline": "2026-10-20",
|
||||
"tags": ["AI", "内容"],
|
||||
"poster": "Alex",
|
||||
"poster_email": "alex@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "开发",
|
||||
"location": "远程",
|
||||
@ -506,6 +516,7 @@ GIGS = [
|
||||
"deadline": "2026-10-15",
|
||||
"tags": ["UX", "研究"],
|
||||
"poster": "Sara",
|
||||
"poster_email": "sara@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "产品",
|
||||
"location": "远程",
|
||||
@ -518,6 +529,7 @@ GIGS = [
|
||||
"deadline": "2026-10-30",
|
||||
"tags": ["社区", "兼职"],
|
||||
"poster": "nomadro",
|
||||
"poster_email": "demo@nomadro.com",
|
||||
"status": "open",
|
||||
"category": "运营",
|
||||
"location": "远程",
|
||||
|
||||
@ -95,7 +95,7 @@ JOBS = [
|
||||
"type": "全职",
|
||||
"salary": "$80k–120k",
|
||||
"tags": ["React", "TypeScript", "远程"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j2",
|
||||
@ -105,7 +105,7 @@ JOBS = [
|
||||
"type": "合同",
|
||||
"salary": "€60–90/h",
|
||||
"tags": ["K8s", "AWS", "异步"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j3",
|
||||
@ -115,7 +115,7 @@ JOBS = [
|
||||
"type": "兼职",
|
||||
"salary": "面议",
|
||||
"tags": ["社区", "写作", "游民"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j4",
|
||||
@ -125,7 +125,7 @@ JOBS = [
|
||||
"type": "全职",
|
||||
"salary": "€55k–75k",
|
||||
"tags": ["Figma", "B2B", "远程"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j5",
|
||||
@ -135,7 +135,7 @@ JOBS = [
|
||||
"type": "合同",
|
||||
"salary": "$4k–6k/月",
|
||||
"tags": ["SEO", "内容", "增长"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j6",
|
||||
@ -145,7 +145,7 @@ JOBS = [
|
||||
"type": "全职",
|
||||
"salary": "$90k–130k",
|
||||
"tags": ["Python", "FastAPI", "Postgres"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j7",
|
||||
@ -155,7 +155,7 @@ JOBS = [
|
||||
"type": "兼职",
|
||||
"salary": "¥8k–12k/月",
|
||||
"tags": ["社区", "活动", "中英"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j8",
|
||||
@ -165,7 +165,7 @@ JOBS = [
|
||||
"type": "全职",
|
||||
"salary": "$70k–100k",
|
||||
"tags": ["Flutter", "移动端"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j9",
|
||||
@ -175,7 +175,7 @@ JOBS = [
|
||||
"type": "合同",
|
||||
"salary": "$40–70/h",
|
||||
"tags": ["文档", "开发者关系"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j10",
|
||||
@ -185,7 +185,7 @@ JOBS = [
|
||||
"type": "全职",
|
||||
"salary": "$65k–95k",
|
||||
"tags": ["SQL", "看板", "增长"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j11",
|
||||
@ -195,7 +195,7 @@ JOBS = [
|
||||
"type": "兼职",
|
||||
"salary": "¥6k–10k/月",
|
||||
"tags": ["客服", "签证", "中文"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
{
|
||||
"id": "j12",
|
||||
@ -205,6 +205,6 @@ JOBS = [
|
||||
"type": "全职",
|
||||
"salary": "$85k–115k",
|
||||
"tags": ["Next.js", "Python", "独立产品"],
|
||||
"url": "https://nomadro.com",
|
||||
"url": "/gigs",
|
||||
},
|
||||
]
|
||||
|
||||
@ -105,7 +105,7 @@ VIDEOS = [
|
||||
"city": "清迈",
|
||||
"published_at": "2026-03-12",
|
||||
"tags": ["泰国", "成本", "社区"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "lisbon-d7",
|
||||
@ -118,7 +118,7 @@ VIDEOS = [
|
||||
"city": "里斯本",
|
||||
"published_at": "2026-02-08",
|
||||
"tags": ["葡萄牙", "签证"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "bali-cowork",
|
||||
@ -131,7 +131,7 @@ VIDEOS = [
|
||||
"city": "巴厘岛",
|
||||
"published_at": "2026-01-20",
|
||||
"tags": ["印尼", "办公"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "medellin-30d",
|
||||
@ -144,7 +144,7 @@ VIDEOS = [
|
||||
"city": "麦德林",
|
||||
"published_at": "2026-03-28",
|
||||
"tags": ["哥伦比亚", "试住"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "tbilisi-tax",
|
||||
@ -157,7 +157,7 @@ VIDEOS = [
|
||||
"city": "第比利斯",
|
||||
"published_at": "2026-04-05",
|
||||
"tags": ["格鲁吉亚", "税务"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "berlin-oss",
|
||||
@ -170,7 +170,7 @@ VIDEOS = [
|
||||
"city": "柏林",
|
||||
"published_at": "2026-04-22",
|
||||
"tags": ["德国", "开源"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "dali-create",
|
||||
@ -183,7 +183,7 @@ VIDEOS = [
|
||||
"city": "大理",
|
||||
"published_at": "2026-05-03",
|
||||
"tags": ["大理", "内容"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "dubai-hub",
|
||||
@ -196,7 +196,7 @@ VIDEOS = [
|
||||
"city": "迪拜",
|
||||
"published_at": "2026-05-18",
|
||||
"tags": ["迪拜", "中东"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "seoul-net",
|
||||
@ -209,7 +209,7 @@ VIDEOS = [
|
||||
"city": "首尔",
|
||||
"published_at": "2026-06-02",
|
||||
"tags": ["韩国", "网速"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "mexico-food",
|
||||
@ -222,7 +222,7 @@ VIDEOS = [
|
||||
"city": "墨西哥城",
|
||||
"published_at": "2026-06-15",
|
||||
"tags": ["墨西哥", "美食"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "first-month",
|
||||
@ -235,7 +235,7 @@ VIDEOS = [
|
||||
"city": "线上",
|
||||
"published_at": "2026-06-28",
|
||||
"tags": ["新手", "清单"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
{
|
||||
"id": "barcelona-visa",
|
||||
@ -248,7 +248,7 @@ VIDEOS = [
|
||||
"city": "巴塞罗那",
|
||||
"published_at": "2026-07-08",
|
||||
"tags": ["西班牙", "签证"],
|
||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url": "",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -13,7 +13,8 @@ from app.data import mock_data
|
||||
from app.data import community_data
|
||||
from app.services.recommendations import recommend_destinations
|
||||
from app.services.meetup_live import (
|
||||
build_lounge_chat_url, build_mirotalk_join_url, can_access_meetup, create_mirotalk_room_slug,
|
||||
build_lounge_chat_url, build_mirotalk_join_url, can_access_meetup,
|
||||
create_lounge_channel, create_mirotalk_room_slug,
|
||||
)
|
||||
from app.services.auth import get_user_by_token
|
||||
from app.services import community_store, social_store
|
||||
@ -368,17 +369,37 @@ async def meetup_session(meetup_id: str, authorization: str | None = Header(None
|
||||
mode = meetup.get("mode", "offline")
|
||||
display = (user or {}).get("name", "访客")
|
||||
|
||||
if mode not in ("online", "hybrid"):
|
||||
can_join = False
|
||||
lock = lock or "offline"
|
||||
elif can_join:
|
||||
# Live rooms need login + RSVP (organizer exempt)
|
||||
if not user:
|
||||
can_join = False
|
||||
lock = "login_required"
|
||||
else:
|
||||
organizer_id = meetup.get("organizer_id") or ""
|
||||
is_host = bool(organizer_id) and organizer_id == user["id"]
|
||||
if not is_host:
|
||||
rsvps = community_store.user_rsvp_ids(user["id"])
|
||||
mid = meetup.get("id") or meetup_id
|
||||
if mid not in rsvps and meetup_id not in rsvps:
|
||||
can_join = False
|
||||
lock = "rsvp_required"
|
||||
|
||||
session = MeetupSession(
|
||||
canJoin=can_join and mode in ("online", "hybrid"),
|
||||
canJoin=can_join,
|
||||
lockReason=lock if not can_join else "",
|
||||
displayName=display,
|
||||
mode=mode,
|
||||
mirotalkRoom=meetup.get("mirotalkRoom") or create_mirotalk_room_slug(meetup),
|
||||
loungeChannel=meetup.get("loungeChannel", ""),
|
||||
loungeChannel=meetup.get("loungeChannel") or "",
|
||||
)
|
||||
if session.canJoin:
|
||||
session.videoUrl = build_mirotalk_join_url(meetup, display)
|
||||
session.chatUrl = build_lounge_chat_url(meetup)
|
||||
if not session.loungeChannel:
|
||||
session.loungeChannel = create_lounge_channel(meetup)
|
||||
return session
|
||||
|
||||
|
||||
|
||||
@ -100,6 +100,14 @@ async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | Non
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "申请失败"))
|
||||
poster_id = res.get("poster_id") or ""
|
||||
if not poster_id:
|
||||
email = (res.get("poster_email") or "").strip().lower()
|
||||
if email:
|
||||
from app.services.auth import find_user_public_by_email
|
||||
|
||||
peer = find_user_public_by_email(email)
|
||||
if peer:
|
||||
poster_id = peer["id"]
|
||||
if poster_id and poster_id != user["id"]:
|
||||
from app.services.notify import notify_user
|
||||
|
||||
|
||||
@ -23,8 +23,21 @@ def _user(authorization: str | None) -> dict:
|
||||
async def ebook_status(authorization: str | None = Header(None)):
|
||||
user = _user(authorization)
|
||||
owned = social_store.has_ebook(user["id"])
|
||||
has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)
|
||||
return {
|
||||
"owned": owned,
|
||||
"has_files": has_files,
|
||||
"amount_fen": PAYMENT_EBOOK_AMOUNT,
|
||||
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/product")
|
||||
async def ebook_product():
|
||||
"""Public product info — no auth (buy CTA honesty)."""
|
||||
has_files = bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)
|
||||
return {
|
||||
"has_files": has_files,
|
||||
"amount_fen": PAYMENT_EBOOK_AMOUNT,
|
||||
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
|
||||
}
|
||||
@ -41,5 +54,5 @@ async def ebook_downloads(authorization: str | None = Header(None)):
|
||||
if EBOOK_EPUB_URL:
|
||||
files.append({"format": "epub", "label": "EPUB", "url": EBOOK_EPUB_URL})
|
||||
if not files:
|
||||
files.append({"format": "online", "label": "在线阅读", "url": "/book/read"})
|
||||
return {"owned": True, "files": files}
|
||||
files.append({"format": "online", "label": "Online reading", "url": "/book/read"})
|
||||
return {"owned": True, "files": files, "has_files": bool(EBOOK_PDF_URL or EBOOK_EPUB_URL)}
|
||||
|
||||
@ -505,6 +505,6 @@ else:
|
||||
if not _get_account_by_email("demo@nomadro.com"):
|
||||
register_user("demo@nomadro.com", "demo123", "演示用户")
|
||||
# Best-effort: ensure a few named demo accounts exist in PocketBase too
|
||||
for email, password, name, _avatar in _DEMO_SEED_USERS[:6]:
|
||||
for email, password, name, _avatar in _DEMO_SEED_USERS:
|
||||
if not _get_account_by_email(email):
|
||||
register_user(email, password, name)
|
||||
|
||||
@ -361,6 +361,7 @@ def _gig_from_pb(r: dict) -> dict:
|
||||
"tags": r.get("tags") or [],
|
||||
"poster": r.get("posterName", ""),
|
||||
"poster_id": r.get("posterId", ""),
|
||||
"poster_email": r.get("posterEmail", ""),
|
||||
"status": r.get("status", "open"),
|
||||
"category": r.get("category", ""),
|
||||
"location": r.get("location", ""),
|
||||
@ -416,6 +417,7 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
|
||||
"ok": True,
|
||||
"message": "申请已提交",
|
||||
"poster_id": g.get("posterId", ""),
|
||||
"poster_email": g.get("posterEmail", ""),
|
||||
"title": g.get("title", ""),
|
||||
}
|
||||
|
||||
@ -568,7 +570,7 @@ _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.jso
|
||||
_j: dict[str, Any] = {}
|
||||
|
||||
|
||||
_SEED_VERSION = 3
|
||||
_SEED_VERSION = 4
|
||||
|
||||
|
||||
def _jload() -> None:
|
||||
@ -753,6 +755,7 @@ def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) ->
|
||||
"ok": True,
|
||||
"message": "申请已提交",
|
||||
"poster_id": gig.get("poster_id", ""),
|
||||
"poster_email": gig.get("poster_email", ""),
|
||||
"title": gig.get("title", ""),
|
||||
}
|
||||
|
||||
|
||||
@ -2,11 +2,14 @@ lounge.nomadro.com {
|
||||
encode gzip
|
||||
reverse_proxy https://lounge.nomadro.cn {
|
||||
header_up Host lounge.nomadro.cn
|
||||
header_down -Content-Security-Policy
|
||||
header_down -X-Frame-Options
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
header {
|
||||
Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001"
|
||||
-X-Frame-Options
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,15 @@ mirotalk.nomadro.com {
|
||||
encode gzip
|
||||
reverse_proxy https://mirotalk.nomadro.cn {
|
||||
header_up Host mirotalk.nomadro.cn
|
||||
# Upstream CSP omits nomadweb.nomadro.com; strip so our frame-ancestors win.
|
||||
header_down -Content-Security-Policy
|
||||
header_down -X-Frame-Options
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
header {
|
||||
Content-Security-Policy "frame-ancestors 'self' https://nomadweb.nomadro.com https://nomadro.cn https://www.nomadro.cn http://localhost:3001 http://127.0.0.1:3001"
|
||||
-X-Frame-Options
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
# Optional — shows Google Sign-In on /login when set
|
||||
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||
|
||||
@ -1,62 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import AboutClient from "@/components/AboutClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "关于 nomadro",
|
||||
title: "关于 nomadro · About",
|
||||
description: "nomadro —— 用一行代码环游世界,数字游民旅居指南",
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<SiteShell>
|
||||
<div className="legal-page about-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">← 返回首页</Link>
|
||||
</nav>
|
||||
<article className="legal-content">
|
||||
<span className="section-tag">🌏 ABOUT</span>
|
||||
<h1>关于 nomadro</h1>
|
||||
<p className="legal-updated">用一行代码环游世界</p>
|
||||
|
||||
<section>
|
||||
<h2>我们是谁</h2>
|
||||
<p>
|
||||
nomadro 是一个面向数字游民的旅居决策平台。我们把目的地数据、签证信息、费用估算、时区协作与社区经验集中在一起,帮你更快做出「下一站去哪」的决定。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>能做什么</h2>
|
||||
<ul className="about-list">
|
||||
<li>🗺️ 探索全球热门游民城市与对比工具</li>
|
||||
<li>🧮 计算生活成本、机票与启动金目标</li>
|
||||
<li>📋 签证匹配、保险与上网方案参考</li>
|
||||
<li>🗓️ 规划多城行程并分享给伙伴</li>
|
||||
<li>🗣️ 落地短语、行李清单与活动日历</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>联系我们</h2>
|
||||
<p>
|
||||
合作与反馈:
|
||||
<a href="mailto:hello@nomadro.com"> hello@nomadro.com</a>
|
||||
</p>
|
||||
<p>
|
||||
开源仓库:
|
||||
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">
|
||||
gitea.dsx2020.com/eric/nomadweb
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="about-actions">
|
||||
<Link href="/#destinations" className="btn btn-primary">开始探索 🌍</Link>
|
||||
<Link href="/login" className="btn btn-ghost">加入社区</Link>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
return <AboutClient />;
|
||||
}
|
||||
|
||||
@ -1,80 +1,27 @@
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { notFound } from "next/navigation";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import BlogMarkdown from "@/components/BlogMarkdown";
|
||||
import BlogReadingProgress from "@/components/BlogReadingProgress";
|
||||
import ShareButton from "@/components/ShareButton";
|
||||
import BlogDetailClient from "@/components/BlogDetailClient";
|
||||
|
||||
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
let post;
|
||||
let allPosts;
|
||||
try {
|
||||
[post, allPosts] = await Promise.all([
|
||||
api.getBlogPost(slug),
|
||||
api.getBlog(),
|
||||
]);
|
||||
[post, allPosts] = await Promise.all([api.getBlogPost(slug), api.getBlog()]);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const related = allPosts
|
||||
.filter((p) => p.slug !== slug && p.tags.some((t) => post.tags.includes(t)))
|
||||
.filter((p) => p.slug !== slug && p.tags.some((tag) => post.tags.includes(tag)))
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
<BlogReadingProgress />
|
||||
<div className="detail-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/#blog">← 返回博客</Link>
|
||||
<ShareButton title={post.title} compact />
|
||||
</nav>
|
||||
<article className="blog-detail">
|
||||
<div className="blog-detail-header">
|
||||
<span className="blog-detail-emoji">{post.emoji}</span>
|
||||
<div className="blog-meta">
|
||||
<span>📅 {post.published_at}</span>
|
||||
<span>⏱️ {post.read_time} 分钟阅读</span>
|
||||
<span>✍️ {post.author}</span>
|
||||
</div>
|
||||
<h1>{post.title}</h1>
|
||||
<div className="blog-tags">
|
||||
{post.tags.map((t) => <span key={t} className="blog-tag">{t}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="blog-detail-content">
|
||||
<BlogMarkdown content={post.content} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="blog-related">
|
||||
<h2>📚 相关文章</h2>
|
||||
<div className="blog-related-grid">
|
||||
{related.map((r) => (
|
||||
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
|
||||
<span className="blog-related-emoji">{r.emoji}</span>
|
||||
<h3>{r.title}</h3>
|
||||
<p>{r.excerpt}</p>
|
||||
<span className="blog-read-more">阅读 →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="blog-related" style={{ marginTop: "2rem" }}>
|
||||
<h2>接着探索</h2>
|
||||
<div className="dating-empty-actions" style={{ justifyContent: "flex-start" }}>
|
||||
<Link href="/community" className="btn btn-primary btn-sm">社区讨论</Link>
|
||||
<Link href="/book" className="btn btn-sm">电子书</Link>
|
||||
<Link href="/next-stop" className="btn btn-sm">下一站</Link>
|
||||
<Link href="/submit" className="btn btn-ghost btn-sm">投稿</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<BlogDetailClient post={post} related={related} />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,18 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
type DlFile = { format: string; label: string; url: string };
|
||||
|
||||
function isRealDownload(f: DlFile) {
|
||||
const fmt = (f.format || "").toLowerCase();
|
||||
return fmt === "pdf" || fmt === "epub" || (Boolean(f.url) && !f.url.startsWith("/book/read"));
|
||||
}
|
||||
|
||||
function BookThanksInner() {
|
||||
const { token } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const b = t.bookThanks;
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const [status, setStatus] = useState<"checking" | "ok" | "pending" | "need-login">("checking");
|
||||
const [files, setFiles] = useState<{ format: string; label: string; url: string }[]>([]);
|
||||
const [files, setFiles] = useState<DlFile[]>([]);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@ -42,59 +52,63 @@ function BookThanksInner() {
|
||||
}
|
||||
if (!cancelled) {
|
||||
setStatus("pending");
|
||||
setMsg("支付确认中,请稍后刷新;或联系支持核对订单。");
|
||||
setMsg(b.pending);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setStatus("pending");
|
||||
setMsg(e instanceof Error ? e.message : "无法确认购买状态");
|
||||
setMsg(e instanceof Error ? e.message : b.statusFail);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, params]);
|
||||
}, [token, params, b.pending, b.statusFail]);
|
||||
|
||||
const downloads = useMemo(() => files.filter(isRealDownload), [files]);
|
||||
const hasOnlineOnly = status === "ok" && downloads.length === 0;
|
||||
|
||||
return (
|
||||
<div className="container" style={{ padding: "48px 24px", maxWidth: 560 }}>
|
||||
<nav className="detail-nav">
|
||||
<Link href="/book">← 电子书</Link>
|
||||
<Link href="/book">← {t.nav.ebook}</Link>
|
||||
</nav>
|
||||
<h1>下载版</h1>
|
||||
<h1>{b.title}</h1>
|
||||
{status === "need-login" && (
|
||||
<p>
|
||||
请先 <Link href="/login?next=/book/thanks">登录</Link> 查看下载。
|
||||
{b.needLogin}{" "}
|
||||
<Link href="/login?next=/book/thanks">{t.nav.login}</Link>
|
||||
</p>
|
||||
)}
|
||||
{status === "checking" && <p>正在确认订单…</p>}
|
||||
{status === "checking" && <p>{b.checking}</p>}
|
||||
{status === "pending" && (
|
||||
<div>
|
||||
<p>{msg}</p>
|
||||
<button type="button" className="btn btn-primary" onClick={() => router.refresh()}>
|
||||
刷新状态
|
||||
{b.refresh}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === "ok" && files.length === 0 && (
|
||||
{hasOnlineOnly && (
|
||||
<div>
|
||||
<p>购买已确认,下载文件暂未配置。</p>
|
||||
<p className="section-desc">可先在线阅读,或联系支持获取 PDF/EPUB。</p>
|
||||
<p>{b.confirmedNoFiles}</p>
|
||||
<p className="section-desc">{b.confirmedHint}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/book/read" className="btn btn-primary">
|
||||
继续在线阅读
|
||||
{b.continueRead}
|
||||
</Link>
|
||||
<Link href="/feedback" className="btn btn-ghost">
|
||||
联系支持
|
||||
{b.contact}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === "ok" && files.length > 0 && (
|
||||
{status === "ok" && downloads.length > 0 && (
|
||||
<div>
|
||||
<p>购买成功,可下载:</p>
|
||||
<p>{b.ready}</p>
|
||||
<ul className="ebook-download-list">
|
||||
{files.map((f) => (
|
||||
{downloads.map((f) => (
|
||||
<li key={f.format}>
|
||||
<a href={f.url} className="btn btn-primary btn-sm" target="_blank" rel="noreferrer">
|
||||
{f.label}
|
||||
@ -103,7 +117,7 @@ function BookThanksInner() {
|
||||
))}
|
||||
</ul>
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link href="/book/read">继续在线阅读 →</Link>
|
||||
<Link href="/book/read">{b.continueRead} →</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -112,9 +126,10 @@ function BookThanksInner() {
|
||||
}
|
||||
|
||||
export default function BookThanksPage() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<SiteShell>
|
||||
<Suspense fallback={<div className="container" style={{ padding: 48 }}>确认中…</div>}>
|
||||
<Suspense fallback={<div className="container" style={{ padding: 48 }}>{t.bookThanks.checking}</div>}>
|
||||
<BookThanksInner />
|
||||
</Suspense>
|
||||
</SiteShell>
|
||||
|
||||
@ -8,6 +8,62 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
const LOGS = [
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "转化/信任打磨",
|
||||
items: [
|
||||
"订阅条中英校验与说明;Join 保存资料后直达匹配;定价免费 CTA 改走完善资料",
|
||||
"VIP/电子书支付说明更诚实(支付宝微信、无文件时写清在线解锁);登录可接 Google(需配置 Client ID)",
|
||||
"移动端隐藏易裁切的 hero 浮卡;活动后下一步补齐私信入口",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "首页/关于页",
|
||||
items: [
|
||||
"首页恢复「游民一天」与「游民心声」区块,中英文化并直达七天指南/活动/社区",
|
||||
"关于页改为可切换语言,CTA 指向下一站、社区与旅居计划;静态帮助页主按钮高亮",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "学院/博客/社区/电子书",
|
||||
items: [
|
||||
"每日指南打通 7 天并中英导航;博客详情与电子书购买页中英文化",
|
||||
"社区回复支持 Ctrl+Enter、字数与滚动定位;点赞失败可提示;假下载改为在线阅读引导",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "全站闭环增强",
|
||||
items: [
|
||||
"签证向导中英文化 + 结果直达城市/计划/咨询;课程跨模块上下一课;岗位申请改走赏金页",
|
||||
"赏金申请通知发布者;视频去掉无效外链;探索导航增加游民地图入口",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "直播报名 · 私信角标",
|
||||
items: [
|
||||
"线上直播需登录并报名后进入;支持一键报名进房;显示活动时间;手机默认视频布局",
|
||||
"右上角私信图标显示未读数",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "直播 iframe 修复",
|
||||
items: [
|
||||
"修复 MiroTalk/Lounge 被上游 CSP 拦截导致直播页空白;可新窗口打开视频/聊天",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "匹配资料 · 报名日历",
|
||||
items: [
|
||||
"匹配卡片可「查看资料」;喜欢页标题改为中性「喜欢」",
|
||||
"线下报名成功自动下载日历;个人中心每场 RSVP 可加入日历",
|
||||
],
|
||||
},
|
||||
{
|
||||
date: "2026-09-04",
|
||||
tag: "导航精简",
|
||||
|
||||
@ -1,56 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import Link from "next/link";
|
||||
import DigitalDayClient from "@/components/DigitalDayClient";
|
||||
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` : "每日指南" };
|
||||
return { title: day ? `${day.title} · nomadro` : "Daily guide · 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();
|
||||
const n = Number(id);
|
||||
const prev = Number.isFinite(n) && n > 1 ? String(n - 1) : null;
|
||||
const next = Number.isFinite(n) && n < 3 ? String(n + 1) : null;
|
||||
|
||||
return (
|
||||
<SiteShell showFooter>
|
||||
<div className="digital-page">
|
||||
<div className="container" style={{ maxWidth: 720 }}>
|
||||
<nav className="detail-nav">
|
||||
<Link href="/digital">← 游民学院</Link>
|
||||
</nav>
|
||||
<article className="digital-lesson reveal">
|
||||
<span className="section-tag">📅 DAY {id}</span>
|
||||
<h1>{day.title}</h1>
|
||||
<div className="digital-lesson-body">
|
||||
{day.body.split("\n").filter(Boolean).map((p, i) => (
|
||||
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
<footer className="digital-lesson-nav">
|
||||
{prev && (
|
||||
<Link href={`/digital/day/${prev}`} className="btn">
|
||||
← 上一篇
|
||||
</Link>
|
||||
)}
|
||||
{next && (
|
||||
<Link href={`/digital/day/${next}`} className="btn btn-primary">
|
||||
下一篇 →
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/digital/course" className="btn btn-ghost">
|
||||
课程目录
|
||||
</Link>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<DigitalDayClient id={id} title={day.title} body={day.body} />
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1301,6 +1301,7 @@ img { max-width: 100%; display: block; }
|
||||
.hero-stats { justify-content: center; }
|
||||
.hero-visual { order: -1; }
|
||||
.globe-container { max-width: 280px; }
|
||||
.floating-cards .float-card { display: none; }
|
||||
|
||||
.dest-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.tools-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
@ -5316,6 +5317,17 @@ a.profile-stat-link:hover {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.wizard-result-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.wizard-empty-wrap {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wizard-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
@ -9613,6 +9625,20 @@ a.profile-stat-link:hover {
|
||||
.auth-demo-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.auth-google {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.auth-google.is-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.join-pay-note {
|
||||
margin-top: 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tools-core-banner {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@ -10601,6 +10627,21 @@ a.profile-stat-link:hover {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.dating-view-profile {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent, #e8a87c);
|
||||
text-decoration: none;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.dating-view-profile:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dating-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -10690,6 +10731,23 @@ a.profile-stat-link:hover {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.community-compose-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.community-compose-count,
|
||||
.community-compose-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.community-compose-hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gigs-page { padding: 100px 0 80px; min-height: 60vh; }
|
||||
|
||||
.join-card select {
|
||||
@ -11029,10 +11087,37 @@ a.weather-card:hover {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.live-layout-btns { margin-left: auto; display: flex; gap: 6px; }
|
||||
.live-external-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: var(--border-glass);
|
||||
}
|
||||
.live-iframe-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.live-schedule-banner,
|
||||
.live-schedule {
|
||||
padding: 8px 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
border-bottom: var(--border-glass);
|
||||
}
|
||||
.live-schedule {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.live-stage {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
min-height: calc(100vh - 52px);
|
||||
min-height: 0;
|
||||
}
|
||||
.live-stage.layout-split { grid-template-columns: 1fr 380px; }
|
||||
.live-stage.layout-video { grid-template-columns: 1fr; }
|
||||
@ -11174,12 +11259,15 @@ a.weather-card:hover {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.live-external-links {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.live-stage {
|
||||
min-height: calc(100vh - 110px);
|
||||
min-height: 50vh;
|
||||
}
|
||||
.live-stage.layout-split { grid-template-columns: 1fr; }
|
||||
.live-iframe { min-height: 280px; }
|
||||
.live-chat { border-left: none; border-top: var(--border-glass); min-height: 280px; }
|
||||
.live-iframe { min-height: 50vh; }
|
||||
.live-chat { border-left: none; border-top: var(--border-glass); }
|
||||
}
|
||||
|
||||
/* -- Feature rings / progressive IA -- */
|
||||
@ -11567,6 +11655,18 @@ a.weather-card:hover {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.profile-rsvp-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.profile-rsvp-actions .btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.compare-history {
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 1rem 1.15rem;
|
||||
|
||||
@ -9,9 +9,10 @@ import { useToast } from "@/lib/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { resolveAuthNext } from "@/lib/authNext";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import { GoogleSignInButton } from "@/components/GoogleSignInButton";
|
||||
|
||||
function LoginForm() {
|
||||
const { login, register, demoLogin } = useAuth();
|
||||
const { login, register, demoLogin, googleLogin } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@ -65,6 +66,15 @@ function LoginForm() {
|
||||
if (ok) goAfterAuth(t.login.demoToast);
|
||||
};
|
||||
|
||||
const handleGoogle = async (idToken: string) => {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
const ok = await googleLogin(idToken);
|
||||
setLoading(false);
|
||||
if (ok) goAfterAuth(meta.welcomeToast);
|
||||
else setError(t.login.googleFail);
|
||||
};
|
||||
|
||||
const headerHint = mode === "login" ? meta.hint : t.login.registerHint;
|
||||
|
||||
return (
|
||||
@ -145,6 +155,7 @@ function LoginForm() {
|
||||
</form>
|
||||
|
||||
<div className="auth-divider"><span>{t.login.or}</span></div>
|
||||
<GoogleSignInButton disabled={loading} onCredential={handleGoogle} />
|
||||
<button className="btn btn-ghost auth-demo-btn" type="button" onClick={handleDemo} disabled={loading}>
|
||||
{t.login.demo}
|
||||
</button>
|
||||
@ -154,10 +165,19 @@ function LoginForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function LoginFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<p className="plan-loading">{t.common.loading}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<SiteShell>
|
||||
<Suspense fallback={<div className="auth-page"><p className="plan-loading">加载中…</p></div>}>
|
||||
<Suspense fallback={<LoginFallback />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</SiteShell>
|
||||
|
||||
@ -3,8 +3,8 @@ import SiteShell from "@/components/SiteShell";
|
||||
import MemberMapClient from "@/components/MemberMapClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "游民地图 · nomadro",
|
||||
description: "全球游民分布与目的地实时天气",
|
||||
title: "Nomad map · 游民地图 · nomadro",
|
||||
description: "Global nomad heatmap and live city weather · 全球游民分布与目的地实时天气",
|
||||
};
|
||||
|
||||
export default function MapPage() {
|
||||
|
||||
@ -14,7 +14,12 @@ export default async function MeetupLivePage({ params }: { params: Promise<{ id:
|
||||
|
||||
return (
|
||||
<SiteShell showFooter={false}>
|
||||
<MeetupLiveClient meetupId={id} title={title} />
|
||||
<MeetupLiveClient
|
||||
meetupId={id}
|
||||
title={title}
|
||||
date={meetup?.date || ""}
|
||||
time={meetup?.time || ""}
|
||||
/>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@ -237,7 +237,7 @@ export default function ProfilePage() {
|
||||
<strong>{connect.likes}</strong>
|
||||
<span>{t.profile.statLikes}</span>
|
||||
</Link>
|
||||
<Link href="/dating" className="profile-stat-card profile-stat-link">
|
||||
<Link href="/dating/likes" className="profile-stat-card profile-stat-link">
|
||||
<span className="stat-emoji">✨</span>
|
||||
<strong>{connect.matches}</strong>
|
||||
<span>{t.profile.statMatches}</span>
|
||||
@ -410,11 +410,28 @@ export default function ProfilePage() {
|
||||
{m.city} · {m.date} {m.time}
|
||||
</p>
|
||||
</div>
|
||||
<div className="profile-rsvp-actions">
|
||||
{(m.mode === "online" || m.mode === "hybrid") && (
|
||||
<Link href={`/meetups/${m.id}/live`} className="btn btn-sm btn-primary">
|
||||
{t.profile.openLive}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => {
|
||||
const ics = buildMeetupIcs([m], m.title);
|
||||
if (!ics) {
|
||||
toast(t.meetups.exportCalFail, "info");
|
||||
return;
|
||||
}
|
||||
downloadMeetupIcs(`nomadro-${m.id}.ics`, ics);
|
||||
toast(t.meetups.exportCalDone);
|
||||
}}
|
||||
>
|
||||
{t.meetups.addToCal}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -41,6 +41,10 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
{ url: `${SITE}/digital/day/1`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/2`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/3`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/4`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/5`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/6`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/digital/day/7`, lastModified: now, changeFrequency: "monthly", priority: 0.55 },
|
||||
{ url: `${SITE}/book`, lastModified: now, changeFrequency: "weekly", priority: 0.85 },
|
||||
{ url: `${SITE}/book/read`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
|
||||
{ url: `${SITE}/support`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
||||
|
||||
68
frontend/src/components/AboutClient.tsx
Normal file
68
frontend/src/components/AboutClient.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function AboutClient() {
|
||||
const { t } = useI18n();
|
||||
const A = t.aboutPage;
|
||||
|
||||
return (
|
||||
<SiteShell>
|
||||
<div className="legal-page about-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">{t.common.backHome}</Link>
|
||||
</nav>
|
||||
<article className="legal-content">
|
||||
<span className="section-tag">{A.tag}</span>
|
||||
<h1>{A.title}</h1>
|
||||
<p className="legal-updated">{A.subtitle}</p>
|
||||
|
||||
<section>
|
||||
<h2>{A.whoTitle}</h2>
|
||||
<p>{A.whoBody}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{A.whatTitle}</h2>
|
||||
<ul className="about-list">
|
||||
<li>{A.what1}</li>
|
||||
<li>{A.what2}</li>
|
||||
<li>{A.what3}</li>
|
||||
<li>{A.what4}</li>
|
||||
<li>{A.what5}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{A.contactTitle}</h2>
|
||||
<p>
|
||||
{A.contactBody}
|
||||
<a href="mailto:hello@nomadro.com"> hello@nomadro.com</a>
|
||||
</p>
|
||||
<p>
|
||||
{A.repoBody}
|
||||
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">
|
||||
{" "}
|
||||
gitea.dsx2020.com/eric/nomadweb
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="about-actions">
|
||||
<Link href="/next-stop" className="btn btn-primary">
|
||||
{A.ctaExplore}
|
||||
</Link>
|
||||
<Link href="/community" className="btn btn-ghost">
|
||||
{A.ctaCommunity}
|
||||
</Link>
|
||||
<Link href="/plan" className="btn btn-ghost">
|
||||
{A.ctaPlan}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/BlogDetailClient.tsx
Normal file
82
frontend/src/components/BlogDetailClient.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import BlogMarkdown from "@/components/BlogMarkdown";
|
||||
import ShareButton from "@/components/ShareButton";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { BlogPost, BlogPostDetail } from "@/lib/types";
|
||||
|
||||
export default function BlogDetailClient({
|
||||
post,
|
||||
related,
|
||||
}: {
|
||||
post: BlogPostDetail;
|
||||
related: BlogPost[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const b = t.blogDetail;
|
||||
|
||||
return (
|
||||
<div className="detail-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/#blog">← {b.back}</Link>
|
||||
<ShareButton title={post.title} compact />
|
||||
</nav>
|
||||
<article className="blog-detail">
|
||||
<div className="blog-detail-header">
|
||||
<span className="blog-detail-emoji">{post.emoji}</span>
|
||||
<div className="blog-meta">
|
||||
<span>📅 {post.published_at}</span>
|
||||
<span>⏱️ {b.readTime.replace("{n}", String(post.read_time))}</span>
|
||||
<span>✍️ {post.author}</span>
|
||||
</div>
|
||||
<h1>{post.title}</h1>
|
||||
<div className="blog-tags">
|
||||
{post.tags.map((tag) => (
|
||||
<span key={tag} className="blog-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="blog-detail-content">
|
||||
<BlogMarkdown content={post.content} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="blog-related">
|
||||
<h2>📚 {b.related}</h2>
|
||||
<div className="blog-related-grid">
|
||||
{related.map((r) => (
|
||||
<Link key={r.slug} href={`/blog/${r.slug}`} className="blog-related-card">
|
||||
<span className="blog-related-emoji">{r.emoji}</span>
|
||||
<h3>{r.title}</h3>
|
||||
<p>{r.excerpt}</p>
|
||||
<span className="blog-read-more">{b.readMore}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="blog-related" style={{ marginTop: "2rem" }}>
|
||||
<h2>{b.explore}</h2>
|
||||
<div className="dating-empty-actions" style={{ justifyContent: "flex-start" }}>
|
||||
<Link href="/community" className="btn btn-primary btn-sm">
|
||||
{t.nav.community}
|
||||
</Link>
|
||||
<Link href="/book" className="btn btn-sm">
|
||||
{t.nav.ebook}
|
||||
</Link>
|
||||
<Link href="/next-stop" className="btn btn-sm">
|
||||
{t.nav.nextStop}
|
||||
</Link>
|
||||
<Link href="/submit" className="btn btn-ghost btn-sm">
|
||||
{b.submit}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,58 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import { api } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
|
||||
import type { Testimonial } from "@/lib/types";
|
||||
|
||||
export default function Community({ testimonials }: { testimonials: Testimonial[] }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const C = t.homeCommunity;
|
||||
const [active, setActive] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length <= 1 || paused) return;
|
||||
const t = setInterval(() => setActive((i) => (i + 1) % testimonials.length), 4500);
|
||||
return () => clearInterval(t);
|
||||
const id = setInterval(() => setActive((i) => (i + 1) % testimonials.length), 4500);
|
||||
return () => clearInterval(id);
|
||||
}, [testimonials.length, paused]);
|
||||
|
||||
const handleSubscribe = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
|
||||
try {
|
||||
await api.subscribe(email);
|
||||
toast("订阅成功!欢迎加入 nomadro 社区 🎉");
|
||||
form.reset();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const t = testimonials[active];
|
||||
const cur = testimonials[active];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="section community" id="community">
|
||||
<section className="section community" id="voices">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">🤝 COMMUNITY</span>
|
||||
<h2>游民心声</h2>
|
||||
<p>来自全球数字游民的真实故事</p>
|
||||
<span className="section-tag">{C.tag}</span>
|
||||
<h2>{C.title}</h2>
|
||||
<p>{C.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{t && (
|
||||
{cur && (
|
||||
<div
|
||||
className="testimonial-carousel reveal"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="testimonial-featured" key={t.id}>
|
||||
<div className="testimonial-avatar">{t.avatar}</div>
|
||||
<div className="testimonial-featured" key={cur.id}>
|
||||
<div className="testimonial-avatar">{cur.avatar}</div>
|
||||
<div className="testimonial-content">
|
||||
<div className="testimonial-stars">{"⭐".repeat(t.rating)}</div>
|
||||
<p>“{t.content}”</p>
|
||||
<div className="testimonial-stars">{"⭐".repeat(cur.rating)}</div>
|
||||
<p>“{cur.content}”</p>
|
||||
<div className="testimonial-author">
|
||||
<strong>{t.author}</strong>
|
||||
<span>{t.role}</span>
|
||||
<strong>{cur.author}</strong>
|
||||
<span>{cur.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -60,9 +50,10 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
|
||||
{testimonials.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`testimonial-dot${i === active ? " active" : ""}`}
|
||||
onClick={() => setActive(i)}
|
||||
aria-label={`评价 ${i + 1}`}
|
||||
aria-label={`${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -87,16 +78,20 @@ export default function Community({ testimonials }: { testimonials: Testimonial[
|
||||
|
||||
<div className="cta-banner reveal">
|
||||
<div className="cta-content">
|
||||
<h2>准备好开始你的旅居之旅了吗? 🌍</h2>
|
||||
<p>加入 50,000+ 数字游民社区,获取目的地指南、签证攻略与独家优惠</p>
|
||||
<h2>{C.ctaTitle}</h2>
|
||||
<p>{C.ctaDesc}</p>
|
||||
<div className="dating-empty-actions" style={{ marginTop: 12, justifyContent: "flex-start" }}>
|
||||
<Link href="/community" className="btn btn-primary btn-sm">
|
||||
{t.nav.community}
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn btn-sm">
|
||||
{t.nav.meetups}
|
||||
</Link>
|
||||
</div>
|
||||
<form className="cta-form" onSubmit={handleSubscribe}>
|
||||
<input type="email" name="email" placeholder="输入你的邮箱 📧" required />
|
||||
<button type="submit" className="btn btn-primary"><span>免费订阅</span><span>🚀</span></button>
|
||||
</form>
|
||||
</div>
|
||||
<NewsletterSubscribe source="home-voices" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -337,6 +337,14 @@ export default function DatingClient() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={`/members/${top.userId || top.id}`}
|
||||
className="dating-view-profile"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t.dating.viewProfile} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dating-actions reveal">
|
||||
|
||||
73
frontend/src/components/DigitalDayClient.tsx
Normal file
73
frontend/src/components/DigitalDayClient.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
const MAX_DAY = 7;
|
||||
|
||||
export default function DigitalDayClient({
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const n = Number(id);
|
||||
const prev = Number.isFinite(n) && n > 1 ? String(n - 1) : null;
|
||||
const next = Number.isFinite(n) && n < MAX_DAY ? String(n + 1) : null;
|
||||
|
||||
return (
|
||||
<div className="digital-page">
|
||||
<div className="container" style={{ maxWidth: 720 }}>
|
||||
<nav className="detail-nav">
|
||||
<Link href="/digital">← {t.digital.back}</Link>
|
||||
</nav>
|
||||
<article className="digital-lesson reveal">
|
||||
<span className="section-tag">📅 DAY {id}</span>
|
||||
<h1>{title}</h1>
|
||||
<div className="digital-lesson-body">
|
||||
{body
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((p, i) => (
|
||||
<p key={`${i}-${p.slice(0, 12)}`}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
<footer className="digital-lesson-nav">
|
||||
{prev && (
|
||||
<Link href={`/digital/day/${prev}`} className="btn">
|
||||
← {t.digital.prev}
|
||||
</Link>
|
||||
)}
|
||||
{next ? (
|
||||
<Link href={`/digital/day/${next}`} className="btn btn-primary">
|
||||
{t.digital.next} →
|
||||
</Link>
|
||||
) : (
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/digital/course" className="btn btn-primary">
|
||||
{t.digital.backCatalog}
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn btn-ghost">
|
||||
{t.nav.meetups}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{next && (
|
||||
<Link href="/digital/course" className="btn btn-ghost">
|
||||
{t.digital.backCourse}
|
||||
</Link>
|
||||
)}
|
||||
</footer>
|
||||
</article>
|
||||
<RingNext steps={rings.afterDigital} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -177,8 +177,13 @@ export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
|
||||
<span key={item} className="meetup-tag">{item}</span>
|
||||
))}
|
||||
</div>
|
||||
<a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
|
||||
{t.digital.apply}
|
||||
<a
|
||||
href={job.url.startsWith("http") ? job.url : job.url || "/gigs"}
|
||||
target={job.url.startsWith("http") ? "_blank" : undefined}
|
||||
rel={job.url.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
{job.url.startsWith("http") ? t.digital.apply : t.digital.jobsGoGigs}
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
|
||||
@ -24,6 +24,8 @@ export default function DigitalLessonClient({
|
||||
const [missing, setMissing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasNext, setHasNext] = useState(false);
|
||||
const [nextHref, setNextHref] = useState<string | null>(null);
|
||||
const [prevHref, setPrevHref] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -31,18 +33,40 @@ export default function DigitalLessonClient({
|
||||
(async () => {
|
||||
try {
|
||||
const course: DigitalCourse = await api.getDigitalCourse();
|
||||
const mod = course.modules[moduleIndex];
|
||||
const modules = course.modules || [];
|
||||
const mod = modules[moduleIndex];
|
||||
const meta = mod?.lessons?.[lessonIndex];
|
||||
if (!meta) {
|
||||
if (!cancelled) {
|
||||
setMissing(true);
|
||||
setLocked(false);
|
||||
setLesson(null);
|
||||
setHasNext(false);
|
||||
setNextHref(null);
|
||||
setPrevHref(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let next: string | null = null;
|
||||
if (mod.lessons[lessonIndex + 1]) {
|
||||
next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
|
||||
} else if (modules[moduleIndex + 1]?.lessons?.length) {
|
||||
next = `/digital/course/${moduleIndex + 1}/0`;
|
||||
}
|
||||
let prev: string | null = null;
|
||||
if (lessonIndex > 0) {
|
||||
prev = `/digital/course/${moduleIndex}/${lessonIndex - 1}`;
|
||||
} else if (moduleIndex > 0) {
|
||||
const prevMod = modules[moduleIndex - 1];
|
||||
const last = (prevMod?.lessons?.length || 1) - 1;
|
||||
if (prevMod?.lessons?.length) {
|
||||
prev = `/digital/course/${moduleIndex - 1}/${last}`;
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setHasNext(Boolean(mod.lessons[lessonIndex + 1]));
|
||||
setHasNext(Boolean(next));
|
||||
setNextHref(next);
|
||||
setPrevHref(prev);
|
||||
}
|
||||
try {
|
||||
const data = await api.getDigitalLesson(moduleIndex, lessonIndex, token || undefined);
|
||||
@ -118,8 +142,8 @@ export default function DigitalLessonClient({
|
||||
);
|
||||
}
|
||||
|
||||
const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null;
|
||||
const next = hasNext ? `/digital/course/${moduleIndex}/${lessonIndex + 1}` : null;
|
||||
const prev = prevHref;
|
||||
const next = hasNext ? nextHref : null;
|
||||
|
||||
return (
|
||||
<div className="digital-page">
|
||||
|
||||
@ -32,6 +32,18 @@ export default function DiscussionDetailClient({
|
||||
const [cityNames, setCityNames] = useState<string[]>([]);
|
||||
const draftKey = `nomadro-reply-draft:${discussionId}`;
|
||||
|
||||
const catLabel = (value: string) => {
|
||||
const map: Record<string, string> = {
|
||||
全部: t.community.catAll,
|
||||
签证: t.community.catVisa,
|
||||
远程工作: t.community.catRemote,
|
||||
住宿: t.community.catHousing,
|
||||
安全: t.community.catSafety,
|
||||
社区: t.community.catCommunity,
|
||||
};
|
||||
return map[value] || value;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadDraft<{ text?: string }>(draftKey);
|
||||
if (draft?.text?.trim()) {
|
||||
@ -93,9 +105,11 @@ export default function DiscussionDetailClient({
|
||||
setReply("");
|
||||
clearDraft(draftKey);
|
||||
await refresh();
|
||||
toast(t.community.replyOk, "success", {
|
||||
href: meetupsHref,
|
||||
label: inferredCity ? t.common.cityMeetups : t.dating.meetAtEvents,
|
||||
toast(t.community.replyOk, "success");
|
||||
requestAnimationFrame(() => {
|
||||
const nodes = document.querySelectorAll(".community-reply");
|
||||
const last = nodes[nodes.length - 1];
|
||||
last?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
} catch {
|
||||
toast(t.community.replyFail, "error");
|
||||
@ -113,7 +127,7 @@ export default function DiscussionDetailClient({
|
||||
const res = await api.likeDiscussion(token, discussionId);
|
||||
setLikes(res.like_count);
|
||||
} catch {
|
||||
/* ignore */
|
||||
toast(t.community.likeFail, "error");
|
||||
}
|
||||
};
|
||||
|
||||
@ -130,7 +144,7 @@ export default function DiscussionDetailClient({
|
||||
|
||||
<article className="community-detail reveal">
|
||||
<header>
|
||||
<span className="community-detail-cat">{discussion.category}</span>
|
||||
<span className="community-detail-cat">{catLabel(discussion.category)}</span>
|
||||
<h1>{discussion.title}</h1>
|
||||
<p className="community-meta">
|
||||
{discussion.author_emoji} {discussion.author} · {discussion.created_at}
|
||||
@ -176,14 +190,26 @@ export default function DiscussionDetailClient({
|
||||
<textarea
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submitReply();
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
placeholder={t.community.replyPlaceholder}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<div className="community-compose-foot">
|
||||
<span className="community-compose-count">
|
||||
{reply.length}/2000
|
||||
</span>
|
||||
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submitReply()}>
|
||||
{loading ? t.community.replySending : t.community.replySend}
|
||||
</button>
|
||||
</div>
|
||||
<p className="community-compose-hint">{t.community.replyShortcut}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="community-reply-hint reveal">
|
||||
<p>{t.community.replyHint}</p>
|
||||
|
||||
@ -44,6 +44,7 @@ export default function Footer() {
|
||||
<div className="footer-links">
|
||||
<h4>{t.footer.explore}</h4>
|
||||
<Link href="/#destinations">{t.footer.destinations}</Link>
|
||||
<Link href="/map">{t.nav.memberMap}</Link>
|
||||
<Link href="/next-stop">{t.footer.nextStop}</Link>
|
||||
{!isHome && <Link href="/plan">{t.footer.plan}</Link>}
|
||||
{!isHome && <Link href="/compare">{t.footer.compare}</Link>}
|
||||
|
||||
@ -69,8 +69,8 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
||||
return next;
|
||||
});
|
||||
toast(t.gigs.applyOk, "success", {
|
||||
href: "/digital",
|
||||
label: t.nav.digital,
|
||||
href: "/notifications",
|
||||
label: t.nav.notifications,
|
||||
});
|
||||
} catch {
|
||||
toast(t.gigs.applyFail, "error");
|
||||
|
||||
97
frontend/src/components/GoogleSignInButton.tsx
Normal file
97
frontend/src/components/GoogleSignInButton.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (cfg: {
|
||||
client_id: string;
|
||||
callback: (res: { credential: string }) => void;
|
||||
}) => void;
|
||||
renderButton: (
|
||||
el: HTMLElement,
|
||||
opts: { theme?: string; size?: string; width?: number; text?: string }
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
|
||||
export function GoogleSignInButton({
|
||||
onCredential,
|
||||
disabled,
|
||||
}: {
|
||||
onCredential: (idToken: string) => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const slotRef = useRef<HTMLDivElement>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const cbRef = useRef(onCredential);
|
||||
cbRef.current = onCredential;
|
||||
|
||||
useEffect(() => {
|
||||
if (!CLIENT_ID || !slotRef.current) return;
|
||||
let cancelled = false;
|
||||
|
||||
const mount = () => {
|
||||
if (cancelled || !slotRef.current || !window.google?.accounts?.id) return;
|
||||
slotRef.current.innerHTML = "";
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: CLIENT_ID,
|
||||
callback: (res) => {
|
||||
if (res.credential) void cbRef.current(res.credential);
|
||||
},
|
||||
});
|
||||
window.google.accounts.id.renderButton(slotRef.current, {
|
||||
theme: "outline",
|
||||
size: "large",
|
||||
width: 320,
|
||||
text: "continue_with",
|
||||
});
|
||||
setReady(true);
|
||||
};
|
||||
|
||||
if (window.google?.accounts?.id) {
|
||||
mount();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const existing = document.querySelector<HTMLScriptElement>('script[data-gis="1"]');
|
||||
if (existing) {
|
||||
existing.addEventListener("load", mount);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
existing.removeEventListener("load", mount);
|
||||
};
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://accounts.google.com/gsi/client";
|
||||
script.async = true;
|
||||
script.dataset.gis = "1";
|
||||
script.onload = mount;
|
||||
document.head.appendChild(script);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
if (!CLIENT_ID) return null;
|
||||
|
||||
return (
|
||||
<div className={`auth-google${disabled ? " is-disabled" : ""}`} aria-label={t.login.google}>
|
||||
<div ref={slotRef} />
|
||||
{!ready && <p className="auth-hint">{t.login.processing}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -46,6 +46,8 @@ const NomadTips = lazy(() => import("./NomadTips"));
|
||||
const FeedbackWidget = lazy(() => import("./FeedbackWidget"));
|
||||
const OnboardingTour = lazy(() => import("./OnboardingTour"));
|
||||
const ToolsStrip = lazy(() => import("./ToolsStrip"));
|
||||
const Lifestyle = lazy(() => import("./Lifestyle"));
|
||||
const Community = lazy(() => import("./Community"));
|
||||
const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts"));
|
||||
const PwaInstallHint = lazy(() => import("./PwaInstallHint"));
|
||||
const RecentlyViewed = lazy(() => import("./RecentlyViewed"));
|
||||
@ -166,6 +168,10 @@ export default function HomeClient(props: Props) {
|
||||
<ToolsStrip />
|
||||
</WhenVisible>
|
||||
|
||||
<WhenVisible minHeight={420}>
|
||||
<Lifestyle />
|
||||
</WhenVisible>
|
||||
|
||||
<div className="container home-ring-wrap">
|
||||
<RingNext title={t.ring.continue} steps={rings.afterDiscover} />
|
||||
</div>
|
||||
@ -176,6 +182,11 @@ export default function HomeClient(props: Props) {
|
||||
<WhenVisible minHeight={220}>
|
||||
<BlogSection posts={props.blog} limit={3} />
|
||||
</WhenVisible>
|
||||
{props.testimonials.length > 0 && (
|
||||
<WhenVisible minHeight={320}>
|
||||
<Community testimonials={props.testimonials} />
|
||||
</WhenVisible>
|
||||
)}
|
||||
<WhenVisible minHeight={200}>
|
||||
<FAQSection faqs={props.faqs} limit={5} />
|
||||
</WhenVisible>
|
||||
|
||||
@ -70,10 +70,12 @@ export default function JoinClient() {
|
||||
await api.socialJoin(token, { city, bio, lookingFor: ["friends", "explore"] });
|
||||
clearDraft(DRAFT_KEY);
|
||||
const cityHint = city.trim().split(/[,/·|]/)[0]?.trim();
|
||||
const nextHref = cityHint ? meetupCityHref(cityHint) : "/dating";
|
||||
toast(t.join.profileOk, "success", {
|
||||
href: cityHint ? meetupCityHref(cityHint) : "/dating",
|
||||
href: nextHref,
|
||||
label: cityHint ? t.common.cityMeetups : t.nav.dating,
|
||||
});
|
||||
router.push("/dating");
|
||||
} catch {
|
||||
toast(t.join.profileFail, "error");
|
||||
} finally {
|
||||
@ -105,8 +107,8 @@ export default function JoinClient() {
|
||||
return;
|
||||
}
|
||||
window.location.href = res.redirect_url;
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : t.join.payFail, "error");
|
||||
} catch {
|
||||
toast(t.join.payFail, "error");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@ -148,6 +150,7 @@ export default function JoinClient() {
|
||||
<button type="button" className="btn btn-primary" disabled={loading || checkingVip} onClick={() => void pay()}>
|
||||
{vip ? t.join.goDating : t.join.payBtn}
|
||||
</button>
|
||||
{!vip && <p className="section-desc join-pay-note">{t.join.payMethods}</p>}
|
||||
{!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,41 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
const ITEMS = [
|
||||
{ time: "🌅 07:00", icon: "🧘", title: "晨间仪式", desc: "瑜伽、冥想或海边跑步,用身心唤醒开启新的一天", tip: "推荐:清迈河边晨跑、巴厘岛海滩瑜伽" },
|
||||
{ time: "☕ 09:00", icon: "💻", title: "深度工作", desc: "在 Co-working Space 或咖啡馆专注 4 小时,完成核心任务", tip: "推荐:Punspace 清迈、Outsite 里斯本" },
|
||||
{ time: "🍜 13:00", icon: "🍽️", title: "本地探索", desc: "品尝地道美食,与当地人交流,感受文化碰撞", tip: "推荐:清迈夜市、巴塞罗那 Tapas 之旅" },
|
||||
{ time: "🌇 17:00", icon: "📞", title: "跨时区协作", desc: "与全球团队视频会议,灵活安排跨时区沟通", tip: "工具:Calendly 排时区、World Time Buddy" },
|
||||
{ time: "🌙 20:00", icon: "🎉", title: "社群社交", desc: "参加 Nomad Meetup,结识来自世界各地的同行者", tip: "平台:Nomad List Meetups、Facebook 群组" },
|
||||
];
|
||||
const ITEM_KEYS = ["morning", "work", "explore", "collab", "social"] as const;
|
||||
|
||||
export default function Lifestyle() {
|
||||
const { t } = useI18n();
|
||||
const L = t.lifestyle;
|
||||
const [active, setActive] = useState(0);
|
||||
const items = ITEM_KEYS.map((key) => ({
|
||||
key,
|
||||
time: L[`time_${key}` as keyof typeof L] as string,
|
||||
icon: L[`icon_${key}` as keyof typeof L] as string,
|
||||
title: L[`title_${key}` as keyof typeof L] as string,
|
||||
desc: L[`desc_${key}` as keyof typeof L] as string,
|
||||
tip: L[`tip_${key}` as keyof typeof L] as string,
|
||||
}));
|
||||
const cur = items[active];
|
||||
|
||||
return (
|
||||
<section className="section lifestyle" id="lifestyle">
|
||||
<div className="container">
|
||||
<div className="section-header reveal">
|
||||
<span className="section-tag">💻 NOMAD LIFE</span>
|
||||
<h2>数字游民的一天</h2>
|
||||
<p>自由不等于散漫,高效工作才能尽情探索 — 点击卡片查看详情</p>
|
||||
<span className="section-tag">{L.tag}</span>
|
||||
<h2>{L.title}</h2>
|
||||
<p>{L.subtitle}</p>
|
||||
</div>
|
||||
<div className="lifestyle-layout reveal">
|
||||
<div className="timeline">
|
||||
<div className="timeline-line">
|
||||
<svg viewBox="0 0 4 600" preserveAspectRatio="none">
|
||||
<line x1="2" y1="0" x2="2" y2="600" stroke="url(#timelineGrad)" strokeWidth="3" strokeDasharray="8 6" className="timeline-svg-line" />
|
||||
<line
|
||||
x1="2"
|
||||
y1="0"
|
||||
x2="2"
|
||||
y2="600"
|
||||
stroke="url(#timelineGrad)"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="8 6"
|
||||
className="timeline-svg-line"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="timelineGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#FF6B6B" /><stop offset="50%" stopColor="#FFE66D" /><stop offset="100%" stopColor="#4ECDC4" />
|
||||
<stop offset="0%" stopColor="#FF6B6B" />
|
||||
<stop offset="50%" stopColor="#FFE66D" />
|
||||
<stop offset="100%" stopColor="#4ECDC4" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
{ITEMS.map((item, i) => (
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={item.time}
|
||||
key={item.key}
|
||||
className={`timeline-item${active === i ? " active" : ""}`}
|
||||
onClick={() => setActive(i)}
|
||||
role="button"
|
||||
@ -54,21 +72,30 @@ export default function Lifestyle() {
|
||||
|
||||
<div className="lifestyle-detail">
|
||||
<div className="lifestyle-detail-card">
|
||||
<span className="lifestyle-detail-icon">{ITEMS[active].icon}</span>
|
||||
<span className="lifestyle-detail-time">{ITEMS[active].time}</span>
|
||||
<h3>{ITEMS[active].title}</h3>
|
||||
<p>{ITEMS[active].desc}</p>
|
||||
<span className="lifestyle-detail-icon">{cur.icon}</span>
|
||||
<span className="lifestyle-detail-time">{cur.time}</span>
|
||||
<h3>{cur.title}</h3>
|
||||
<p>{cur.desc}</p>
|
||||
<div className="lifestyle-tip">
|
||||
<span>💡</span>
|
||||
<p>{ITEMS[active].tip}</p>
|
||||
<p>{cur.tip}</p>
|
||||
</div>
|
||||
<div className="dating-empty-actions" style={{ marginTop: 16 }}>
|
||||
<Link href="/digital/day/1" className="btn btn-primary btn-sm">
|
||||
{L.ctaDay}
|
||||
</Link>
|
||||
<Link href="/meetups" className="btn btn-sm">
|
||||
{L.ctaMeetups}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="lifestyle-dots">
|
||||
{ITEMS.map((_, i) => (
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`lifestyle-dot${active === i ? " active" : ""}`}
|
||||
onClick={() => setActive(i)}
|
||||
aria-label={`第 ${i + 1} 项`}
|
||||
aria-label={`${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -5,19 +5,45 @@ import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useToast } from "@/lib/toast";
|
||||
import type { MeetupSession } from "@/lib/types";
|
||||
import RingNext from "@/components/RingNext";
|
||||
import { useRingSteps } from "@/lib/rings";
|
||||
|
||||
export default function MeetupLiveClient({ meetupId, title }: { meetupId: string; title: string }) {
|
||||
type Layout = "split" | "video" | "chat";
|
||||
|
||||
function defaultLayout(): Layout {
|
||||
if (typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches) {
|
||||
return "video";
|
||||
}
|
||||
return "split";
|
||||
}
|
||||
|
||||
export default function MeetupLiveClient({
|
||||
meetupId,
|
||||
title,
|
||||
date = "",
|
||||
time = "",
|
||||
}: {
|
||||
meetupId: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
}) {
|
||||
const { token } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const rings = useRingSteps();
|
||||
const [session, setSession] = useState<MeetupSession | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [layout, setLayout] = useState<"split" | "video" | "chat">("split");
|
||||
const [layout, setLayout] = useState<Layout>("split");
|
||||
const [rsvping, setRsvping] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(defaultLayout());
|
||||
}, []);
|
||||
|
||||
const loadSession = () => {
|
||||
setError(false);
|
||||
setSession(null);
|
||||
api
|
||||
@ -27,8 +53,29 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
|
||||
setSession(null);
|
||||
setError(true);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSession();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meetupId, token]);
|
||||
|
||||
const handleRsvpAndJoin = async () => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
setRsvping(true);
|
||||
try {
|
||||
await api.rsvpMeetup(meetupId, undefined, token);
|
||||
toast(t.meetups.rsvpOk, "success");
|
||||
loadSession();
|
||||
} catch {
|
||||
toast(t.meetups.rsvpFail, "error");
|
||||
} finally {
|
||||
setRsvping(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="live-page">
|
||||
@ -64,26 +111,45 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
|
||||
? t.live.hostRequired
|
||||
: reason === "offline"
|
||||
? t.live.offlineOnly
|
||||
: reason === "rsvp_required"
|
||||
? t.live.rsvpRequired
|
||||
: t.live.loginRequired;
|
||||
const href =
|
||||
reason === "vip_required"
|
||||
? "/join"
|
||||
: reason === "offline"
|
||||
? "/meetups"
|
||||
: `/login?next=/meetups/${meetupId}/live`;
|
||||
const label =
|
||||
reason === "vip_required" ? t.join.payBtn : reason === "offline" ? t.live.back : t.nav.login;
|
||||
|
||||
return (
|
||||
<div className="live-page">
|
||||
<div className="container">
|
||||
<div className="live-gate reveal">
|
||||
<h2>{title}</h2>
|
||||
{(date || time) && (
|
||||
<p className="live-schedule">
|
||||
🗓️ {date}
|
||||
{time ? ` ${time}` : ""}
|
||||
</p>
|
||||
)}
|
||||
<p>{copy}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href={href} className="btn btn-primary">
|
||||
{label}
|
||||
{reason === "rsvp_required" && token ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={rsvping}
|
||||
onClick={() => void handleRsvpAndJoin()}
|
||||
>
|
||||
{rsvping ? "…" : t.live.rsvpJoin}
|
||||
</button>
|
||||
) : reason === "vip_required" ? (
|
||||
<Link href="/join" className="btn btn-primary">
|
||||
{t.join.payBtn}
|
||||
</Link>
|
||||
) : reason === "offline" ? (
|
||||
<Link href="/meetups" className="btn btn-primary">
|
||||
{t.live.back}
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/login?next=/meetups/${meetupId}/live`} className="btn btn-primary">
|
||||
{t.nav.login}
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/meetups" className="btn">
|
||||
{t.live.back}
|
||||
</Link>
|
||||
@ -94,6 +160,11 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
|
||||
);
|
||||
}
|
||||
|
||||
const scheduleLabel =
|
||||
date || time
|
||||
? t.live.startsAt.replace("{when}", `${date}${time ? ` ${time}` : ""}`.trim())
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="live-page">
|
||||
<div className="live-toolbar">
|
||||
@ -112,17 +183,38 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{scheduleLabel && <div className="live-schedule-banner">{scheduleLabel}</div>}
|
||||
<div className="live-external-links">
|
||||
{session.videoUrl && (
|
||||
<a href={session.videoUrl} target="_blank" rel="noopener noreferrer" className="btn btn-sm btn-primary">
|
||||
{t.live.openVideo}
|
||||
</a>
|
||||
)}
|
||||
{session.chatUrl && (
|
||||
<a href={session.chatUrl} target="_blank" rel="noopener noreferrer" className="btn btn-sm">
|
||||
{t.live.openChat}
|
||||
</a>
|
||||
)}
|
||||
<span className="live-iframe-hint">{t.live.iframeHint}</span>
|
||||
</div>
|
||||
<div className={`live-stage layout-${layout}`}>
|
||||
{(layout === "split" || layout === "video") && session.videoUrl && (
|
||||
<iframe
|
||||
title="video"
|
||||
src={session.videoUrl}
|
||||
className="live-iframe live-video"
|
||||
allow="camera; microphone; display-capture"
|
||||
allow="camera; microphone; display-capture; autoplay; fullscreen"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
)}
|
||||
{(layout === "split" || layout === "chat") && session.chatUrl && (
|
||||
<iframe title="chat" src={session.chatUrl} className="live-iframe live-chat" />
|
||||
<iframe
|
||||
title="chat"
|
||||
src={session.chatUrl}
|
||||
className="live-iframe live-chat"
|
||||
allow="clipboard-write"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
)}
|
||||
{!session.videoUrl && !session.chatUrl && (
|
||||
<div className="notif-empty">
|
||||
|
||||
@ -129,10 +129,26 @@ export default function MeetupsClient({ meetups }: Props) {
|
||||
const res = await api.rsvpMeetup(meetup.id, undefined, token);
|
||||
setRsvpIds(new Set(rsvpIds).add(meetup.id));
|
||||
const live = meetup.mode === "online" || meetup.mode === "hybrid";
|
||||
if (live) {
|
||||
toast(res.message || t.meetups.rsvpOk, "success", {
|
||||
href: live ? meetupEventHref(meetup) : "/profile",
|
||||
label: live ? t.meetups.liveBtn : t.meetups.rsvpCalCta,
|
||||
href: meetupEventHref(meetup),
|
||||
label: t.meetups.liveBtn,
|
||||
});
|
||||
} else {
|
||||
const ics = buildMeetupIcs([meetup], meetup.title);
|
||||
if (ics) {
|
||||
downloadMeetupIcs(`nomadro-${meetup.id}.ics`, ics);
|
||||
toast(res.message || t.meetups.rsvpOk, "success", {
|
||||
href: "/profile",
|
||||
label: t.meetups.viewRsvps,
|
||||
});
|
||||
} else {
|
||||
toast(res.message || t.meetups.rsvpOk, "success", {
|
||||
href: "/profile",
|
||||
label: t.meetups.viewRsvps,
|
||||
});
|
||||
}
|
||||
}
|
||||
void loadPeers(meetup.id);
|
||||
} catch {
|
||||
toast(t.meetups.rsvpFail, "error");
|
||||
|
||||
@ -20,15 +20,21 @@ export default function Navbar() {
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(null);
|
||||
const [active, setActive] = useState("");
|
||||
const [unread, setUnread] = useState(0);
|
||||
const [chatUnread, setChatUnread] = useState(0);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setUnread(0);
|
||||
setChatUnread(0);
|
||||
return;
|
||||
}
|
||||
const refresh = () => {
|
||||
api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0));
|
||||
api
|
||||
.getConversations(token)
|
||||
.then((list) => setChatUnread(list.reduce((s, c) => s + (c.unreadCount || 0), 0)))
|
||||
.catch(() => setChatUnread(0));
|
||||
};
|
||||
refresh();
|
||||
const id = window.setInterval(refresh, 60000);
|
||||
@ -43,6 +49,7 @@ export default function Navbar() {
|
||||
const explore: NavItem[] = [
|
||||
{ href: "/#destinations", label: t.nav.destinations, section: "destinations" },
|
||||
{ href: "/#map", label: t.nav.map, section: "map" },
|
||||
{ href: "/map", label: t.nav.memberMap, section: "member-map" },
|
||||
{ href: "/next-stop", label: t.nav.nextStop, section: "next-stop" },
|
||||
...(!isHome
|
||||
? [
|
||||
@ -141,6 +148,7 @@ export default function Navbar() {
|
||||
if (l.href === "/book") return pathname.startsWith("/book");
|
||||
if (l.href === "/digital") return pathname.startsWith("/digital");
|
||||
if (l.href === "/dating") return pathname.startsWith("/dating");
|
||||
if (l.href === "/map") return pathname === "/map";
|
||||
if (l.href === "/meetups") return pathname.startsWith("/meetups");
|
||||
if (l.href === "/community") return pathname.startsWith("/community");
|
||||
if (l.href === "/gigs") return pathname.startsWith("/gigs");
|
||||
@ -235,7 +243,7 @@ export default function Navbar() {
|
||||
</button>
|
||||
{user ? (
|
||||
<Link href="/chat" className="nav-notify" aria-label={t.nav.chat}>
|
||||
✉️
|
||||
✉️{chatUnread > 0 && <em className="nav-notify-badge">{chatUnread}</em>}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
|
||||
@ -14,7 +14,7 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s
|
||||
const submit = async () => {
|
||||
const value = email.trim();
|
||||
if (!value || !value.includes("@")) {
|
||||
toast("请输入有效邮箱", "info");
|
||||
toast(t.newsletter.invalidEmail, "info");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@ -46,7 +46,7 @@ export default function NewsletterSubscribe({ source = "meetups" }: { source?: s
|
||||
{busy ? "…" : t.newsletter.submit}
|
||||
</button>
|
||||
</div>
|
||||
<p className="newsletter-note">先登记邮箱;邮件推送开通后会同步发送,不会假装已群发</p>
|
||||
<p className="newsletter-note">{t.newsletter.note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ export default function PricingClient() {
|
||||
<h3>{t.pricing.free}</h3>
|
||||
<p>{t.pricing.freeDesc}</p>
|
||||
<ul className="join-perks"><li>{t.pricing.free1}</li><li>{t.pricing.free2}</li></ul>
|
||||
<Link href="/dating" className="btn">{t.pricing.try}</Link>
|
||||
<Link href="/join" className="btn">{t.pricing.try}</Link>
|
||||
</div>
|
||||
<div className="join-card join-card-vip">
|
||||
<h3>{t.pricing.vip}</h3>
|
||||
@ -28,6 +28,7 @@ export default function PricingClient() {
|
||||
<li>{t.join.perk1}</li><li>{t.join.perk2}</li><li>{t.join.perk3}</li><li>{t.join.perk4}</li>
|
||||
</ul>
|
||||
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
|
||||
<p className="section-desc join-pay-note">{t.pricing.payNote}</p>
|
||||
</div>
|
||||
</div>
|
||||
<RingNext
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { StaticPageDef } from "@/lib/staticPages";
|
||||
|
||||
export default function StaticPage({ page }: { page: StaticPageDef }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="legal-page">
|
||||
<nav className="detail-nav">
|
||||
<Link href="/">← 返回首页</Link>
|
||||
<Link href="/">{t.common.backHome}</Link>
|
||||
</nav>
|
||||
<article className="legal-content reveal">
|
||||
<span className="section-tag">{page.tag}</span>
|
||||
@ -19,15 +21,19 @@ export default function StaticPage({ page }: { page: StaticPageDef }) {
|
||||
{s.body && <p>{s.body}</p>}
|
||||
{s.list && (
|
||||
<ul className="about-list">
|
||||
{s.list.map((item) => <li key={item}>{item}</li>)}
|
||||
{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>
|
||||
{page.cta.map((c, i) => (
|
||||
<Link key={c.href} href={c.href} className={`btn${i === 0 ? " btn-primary" : " btn-ghost"}`}>
|
||||
{c.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,51 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Visa } from "@/lib/types";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
visas: Visa[];
|
||||
}
|
||||
|
||||
const INCOME_OPTIONS = [
|
||||
{ key: "low", label: "💰 低于 ¥8,000/月", max: 8000 },
|
||||
{ key: "mid", label: "💼 ¥8,000 – ¥20,000/月", max: 20000 },
|
||||
{ key: "high", label: "🚀 ¥20,000 – ¥50,000/月", max: 50000 },
|
||||
{ key: "premium", label: "👑 高于 ¥50,000/月", max: Infinity },
|
||||
];
|
||||
const INCOME_KEYS = [
|
||||
{ key: "low", max: 8000 },
|
||||
{ key: "mid", max: 20000 },
|
||||
{ key: "high", max: 50000 },
|
||||
{ key: "premium", max: Infinity },
|
||||
] as const;
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ key: "short", label: "⏱️ 1-3 个月", months: 3 },
|
||||
{ key: "medium", label: "📅 3-12 个月", months: 12 },
|
||||
{ key: "long", label: "🏠 1 年以上", months: 24 },
|
||||
];
|
||||
const DURATION_KEYS = [
|
||||
{ key: "short", months: 3 },
|
||||
{ key: "medium", months: 12 },
|
||||
{ key: "long", months: 24 },
|
||||
] as const;
|
||||
|
||||
const REGION_OPTIONS = [
|
||||
{ key: "any", label: "🌏 不限" },
|
||||
{ key: "europe", label: "🏰 欧洲" },
|
||||
{ key: "sea", label: "🌴 东南亚" },
|
||||
{ key: "latam", label: "🌮 拉美" },
|
||||
];
|
||||
const REGION_KEYS = ["any", "europe", "sea", "latam"] as const;
|
||||
|
||||
const VISA_INCOME: Record<string, number> = {
|
||||
"1": 6000, "2": 17000, "3": 2000, "4": 47000, "5": 18000, "6": 28000,
|
||||
"1": 6000,
|
||||
"2": 17000,
|
||||
"3": 2000,
|
||||
"4": 47000,
|
||||
"5": 18000,
|
||||
"6": 28000,
|
||||
"7": 2000,
|
||||
"8": 35000,
|
||||
"9": 5000,
|
||||
"10": 30000,
|
||||
"11": 25000,
|
||||
"12": 15000,
|
||||
};
|
||||
|
||||
const VISA_REGION: Record<string, string> = {
|
||||
"1": "europe", "2": "europe", "3": "sea", "4": "sea", "5": "latam", "6": "europe",
|
||||
"1": "europe",
|
||||
"2": "europe",
|
||||
"3": "sea",
|
||||
"4": "sea",
|
||||
"5": "latam",
|
||||
"6": "europe",
|
||||
"7": "europe",
|
||||
"8": "any",
|
||||
"9": "latam",
|
||||
"10": "sea",
|
||||
"11": "europe",
|
||||
"12": "sea",
|
||||
};
|
||||
|
||||
const VISA_DEST: Record<string, string> = {
|
||||
"1": "lisbon",
|
||||
"2": "barcelona",
|
||||
"3": "bali",
|
||||
"4": "chiangmai",
|
||||
"5": "mexico",
|
||||
"7": "tbilisi",
|
||||
"8": "dubai",
|
||||
"9": "medellin",
|
||||
"10": "seoul",
|
||||
"11": "berlin",
|
||||
"12": "chiangmai",
|
||||
};
|
||||
|
||||
export default function VisaWizard({ visas }: Props) {
|
||||
const { t } = useI18n();
|
||||
const w = t.visaWizard;
|
||||
const [step, setStep] = useState(0);
|
||||
const [income, setIncome] = useState("");
|
||||
const [duration, setDuration] = useState("");
|
||||
const [region, setRegion] = useState("any");
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
const incomeOptions = INCOME_KEYS.map((o) => ({
|
||||
...o,
|
||||
label: w[`income_${o.key}` as keyof typeof w] as string,
|
||||
}));
|
||||
const durationOptions = DURATION_KEYS.map((o) => ({
|
||||
...o,
|
||||
label: w[`duration_${o.key}` as keyof typeof w] as string,
|
||||
}));
|
||||
const regionOptions = REGION_KEYS.map((key) => ({
|
||||
key,
|
||||
label: w[`region_${key}` as keyof typeof w] as string,
|
||||
}));
|
||||
const stepLabels = [w.stepIncome, w.stepDuration, w.stepRegion];
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!income || !duration) return [];
|
||||
const incomeOpt = INCOME_OPTIONS.find((o) => o.key === income)!;
|
||||
const durOpt = DURATION_OPTIONS.find((o) => o.key === duration)!;
|
||||
const incomeOpt = INCOME_KEYS.find((o) => o.key === income)!;
|
||||
const durOpt = DURATION_KEYS.find((o) => o.key === duration)!;
|
||||
|
||||
return visas
|
||||
.map((v) => {
|
||||
@ -91,16 +140,19 @@ export default function VisaWizard({ visas }: Props) {
|
||||
return (
|
||||
<div className="visa-wizard reveal">
|
||||
<div className="visa-wizard-header">
|
||||
<span className="section-tag">🧙 VISA WIZARD</span>
|
||||
<h3>签证智能匹配</h3>
|
||||
<p>回答 3 个问题,找到最适合你的远程工作签证</p>
|
||||
<span className="section-tag">{w.tag}</span>
|
||||
<h3>{w.title}</h3>
|
||||
<p>{w.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{!showResults ? (
|
||||
<div className="visa-wizard-body">
|
||||
<div className="visa-wizard-steps">
|
||||
{["月收入", "停留时长", "目的地"].map((label, i) => (
|
||||
<div key={label} className={`wizard-step-dot${i <= step ? " active" : ""}${i < step ? " done" : ""}`}>
|
||||
{stepLabels.map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`wizard-step-dot${i <= step ? " active" : ""}${i < step ? " done" : ""}`}
|
||||
>
|
||||
<span>{i < step ? "✓" : i + 1}</span>
|
||||
<small>{label}</small>
|
||||
</div>
|
||||
@ -109,8 +161,13 @@ export default function VisaWizard({ visas }: Props) {
|
||||
|
||||
{step === 0 && (
|
||||
<div className="wizard-options">
|
||||
{INCOME_OPTIONS.map((o) => (
|
||||
<button key={o.key} className={`wizard-option${income === o.key ? " selected" : ""}`} onClick={() => setIncome(o.key)}>
|
||||
{incomeOptions.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
className={`wizard-option${income === o.key ? " selected" : ""}`}
|
||||
onClick={() => setIncome(o.key)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
@ -119,8 +176,13 @@ export default function VisaWizard({ visas }: Props) {
|
||||
|
||||
{step === 1 && (
|
||||
<div className="wizard-options">
|
||||
{DURATION_OPTIONS.map((o) => (
|
||||
<button key={o.key} className={`wizard-option${duration === o.key ? " selected" : ""}`} onClick={() => setDuration(o.key)}>
|
||||
{durationOptions.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
className={`wizard-option${duration === o.key ? " selected" : ""}`}
|
||||
onClick={() => setDuration(o.key)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
@ -129,8 +191,13 @@ export default function VisaWizard({ visas }: Props) {
|
||||
|
||||
{step === 2 && (
|
||||
<div className="wizard-options">
|
||||
{REGION_OPTIONS.map((o) => (
|
||||
<button key={o.key} className={`wizard-option${region === o.key ? " selected" : ""}`} onClick={() => setRegion(o.key)}>
|
||||
{regionOptions.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
className={`wizard-option${region === o.key ? " selected" : ""}`}
|
||||
onClick={() => setRegion(o.key)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
@ -138,28 +205,55 @@ export default function VisaWizard({ visas }: Props) {
|
||||
)}
|
||||
|
||||
<div className="wizard-nav">
|
||||
{step > 0 && <button className="btn btn-ghost" onClick={() => setStep(step - 1)}>← 上一步</button>}
|
||||
{step > 0 && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setStep(step - 1)}>
|
||||
← {w.prev}
|
||||
</button>
|
||||
)}
|
||||
{step < 2 ? (
|
||||
<button className="btn btn-primary" disabled={!canNext} onClick={() => setStep(step + 1)}>下一步 →</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!canNext}
|
||||
onClick={() => setStep(step + 1)}
|
||||
>
|
||||
{w.next} →
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={handleFinish}>查看推荐 ✨</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleFinish}>
|
||||
{w.finish}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="visa-wizard-results">
|
||||
{results.length === 0 ? (
|
||||
<p className="wizard-empty">暂无完全匹配的签证,建议咨询专业移民顾问或放宽条件重试</p>
|
||||
<div className="wizard-empty-wrap">
|
||||
<p className="wizard-empty">{w.empty}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<Link href="/services" className="btn btn-primary btn-sm">
|
||||
{w.ctaServices}
|
||||
</Link>
|
||||
<Link href="/community" className="btn btn-sm">
|
||||
{w.ctaCommunity}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="wizard-result-cards">
|
||||
{results.map(({ visa, score }, i) => (
|
||||
{results.map(({ visa, score }, i) => {
|
||||
const dest = VISA_DEST[visa.id];
|
||||
return (
|
||||
<div key={visa.id} className={`wizard-result-card${i === 0 ? " winner" : ""}`}>
|
||||
{i === 0 && <span className="wizard-winner-badge">🏆 最佳匹配</span>}
|
||||
{i === 0 && <span className="wizard-winner-badge">{w.bestMatch}</span>}
|
||||
<div className="wizard-result-top">
|
||||
<span className="wizard-result-flag">{visa.flag}</span>
|
||||
<div>
|
||||
<h4>{visa.name}</h4>
|
||||
<span className="wizard-match-score">{score}% 匹配</span>
|
||||
<span className="wizard-match-score">
|
||||
{score}% {w.match}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
@ -167,11 +261,27 @@ export default function VisaWizard({ visas }: Props) {
|
||||
<li>📅 {visa.duration}</li>
|
||||
<li>⏱️ {visa.approval_time}</li>
|
||||
</ul>
|
||||
<div className="wizard-result-actions">
|
||||
{dest && (
|
||||
<Link href={`/destinations/${dest}`} className="btn btn-primary btn-sm">
|
||||
{w.ctaCity}
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/plan" className="btn btn-sm">
|
||||
{w.ctaPlan}
|
||||
</Link>
|
||||
<Link href="/services" className="btn btn-ghost btn-sm">
|
||||
{w.ctaServices}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={reset}>🔄 重新匹配</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={reset}>
|
||||
{w.reset}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -115,9 +115,14 @@ export default function WorldMap({ destinations }: Props) {
|
||||
<span className="map-info-icon">👆</span>
|
||||
<h3>{t.home.mapPick}</h3>
|
||||
<p>{t.home.mapPickHint}</p>
|
||||
<div className="dating-empty-actions">
|
||||
<a href="#destinations" className="btn btn-ghost btn-sm map-browse-link">
|
||||
{t.home.mapBrowse}
|
||||
</a>
|
||||
<Link href="/map" className="btn btn-sm map-browse-link">
|
||||
{t.home.mapMembers}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -9,7 +9,7 @@ import { useT } from "@/components/ebook/LocaleProvider";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
/** Buy download edition via FastAPI /pay/create (pay_type=ebook). */
|
||||
/** Buy edition via FastAPI /pay/create (pay_type=ebook). */
|
||||
export function BuyEbook({ compact = false }: { compact?: boolean }) {
|
||||
const t = useT();
|
||||
const { token } = useAuth();
|
||||
@ -17,18 +17,33 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [owned, setOwned] = useState(false);
|
||||
const [hasFiles, setHasFiles] = useState(false);
|
||||
const features = getFeatures();
|
||||
const enabled = features.payment?.checkout?.enabled !== false;
|
||||
const productName = features.payment?.checkout?.productName || "Download Edition";
|
||||
const buttonText = features.payment?.checkout?.buttonText || "购买下载版";
|
||||
const configuredButton = features.payment?.checkout?.buttonText;
|
||||
|
||||
useEffect(() => {
|
||||
api.ebookProduct()
|
||||
.then((p) => setHasFiles(Boolean(p.has_files)))
|
||||
.catch(() => setHasFiles(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api.ebookStatus(token).then((s) => setOwned(s.owned)).catch(() => {});
|
||||
api.ebookStatus(token).then((s) => {
|
||||
setOwned(s.owned);
|
||||
if (typeof s.has_files === "boolean") setHasFiles(s.has_files);
|
||||
}).catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const buyLabel = configuredButton
|
||||
|| (hasFiles ? t("payment.buyDownload") : t("payment.buyUnlock"));
|
||||
const buySub = hasFiles ? t("payment.buySub") : t("payment.buySubOnline");
|
||||
const ownedLabel = hasFiles ? t("payment.ownedDownload") : t("payment.ownedOnline");
|
||||
|
||||
const buy = async () => {
|
||||
setError("");
|
||||
if (!token) {
|
||||
@ -47,7 +62,6 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) {
|
||||
if (res.order_id) {
|
||||
localStorage.setItem("nomadro-ebook-order", res.order_id);
|
||||
}
|
||||
// DEV_AUTO_PAY or already-paid: confirm then go to thanks
|
||||
if (res.status === "paid" && res.order_id) {
|
||||
await api.completePayment(token, res.order_id).catch(() => null);
|
||||
router.push(`/book/thanks?order_id=${encodeURIComponent(res.order_id)}`);
|
||||
@ -66,13 +80,14 @@ export function BuyEbook({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<section className={`buy-ebook${compact ? " is-compact" : ""}`}>
|
||||
<h2 className="buy-ebook-title">{t("payment.buyGet", { name: productName })}</h2>
|
||||
<p className="buy-ebook-sub">{t("payment.buySub")}</p>
|
||||
<p className="buy-ebook-sub">{buySub}</p>
|
||||
<button type="button" className="buy-ebook-btn" disabled={busy} onClick={buy}>
|
||||
{busy ? t("payment.openingCheckout") : owned ? "已购买 · 去下载" : buttonText}
|
||||
{busy ? t("payment.openingCheckout") : owned ? ownedLabel : buyLabel}
|
||||
</button>
|
||||
{error ? <p className="buy-ebook-error">{error}</p> : null}
|
||||
<p className="buy-ebook-hint">
|
||||
也可免费 <Link href="/book/read">在线阅读</Link>
|
||||
{t("payment.orReadOnline")}{" "}
|
||||
<Link href="/book/read">{t("thanks.purchaseContinue")}</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -163,11 +163,13 @@ export const api = {
|
||||
return res.json() as Promise<{ success: boolean; url: string; object_key: string }>;
|
||||
},
|
||||
ebookStatus: (token: string) =>
|
||||
fetchAPI<{ owned: boolean; amount_fen: number; amount_yuan: string }>("/ebook/status", {
|
||||
fetchAPI<{ owned: boolean; has_files?: boolean; amount_fen: number; amount_yuan: string }>("/ebook/status", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
ebookProduct: () =>
|
||||
fetchAPI<{ has_files: boolean; amount_fen: number; amount_yuan: string }>("/ebook/product"),
|
||||
ebookDownloads: (token: string) =>
|
||||
fetchAPI<{ owned: boolean; files: { format: string; label: string; url: string }[] }>("/ebook/downloads", {
|
||||
fetchAPI<{ owned: boolean; has_files?: boolean; files: { format: string; label: string; url: string }[] }>("/ebook/downloads", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
getFavorites: (token: string) =>
|
||||
|
||||
@ -13,6 +13,7 @@ interface AuthCtx {
|
||||
login: (email: string, password: string) => Promise<boolean>;
|
||||
register: (email: string, password: string, name: string) => Promise<boolean>;
|
||||
demoLogin: () => Promise<boolean>;
|
||||
googleLogin: (idToken: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
toggleFavorite: (slug: string) => Promise<void>;
|
||||
@ -88,6 +89,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
const googleLogin = async (idToken: string) => {
|
||||
try {
|
||||
const data = await api.googleLogin(idToken);
|
||||
await persist(data.token, data.user);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
@ -122,7 +131,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user, token, favorites, planSyncStatus, login, register, demoLogin, logout, refreshUser,
|
||||
user, token, favorites, planSyncStatus, login, register, demoLogin, googleLogin, logout, refreshUser,
|
||||
toggleFavorite, isFavorite: (slug) => favorites.includes(slug),
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@ -137,6 +137,12 @@ export const messages = {
|
||||
payFailed: 'Payment failed',
|
||||
buyGet: 'Get the {name}',
|
||||
buySub: 'Read free online, or grab PDF & EPUB for offline and permanent access.',
|
||||
buySubOnline: 'Read free online — purchase unlocks permanent online access. PDF/EPUB ship when files are ready.',
|
||||
buyDownload: 'Buy download edition',
|
||||
buyUnlock: 'Unlock online edition',
|
||||
ownedDownload: 'Owned · Downloads',
|
||||
ownedOnline: 'Owned · Keep reading',
|
||||
orReadOnline: 'Or read free',
|
||||
checkoutFailed: 'Checkout failed',
|
||||
},
|
||||
thanks: {
|
||||
@ -146,7 +152,7 @@ export const messages = {
|
||||
supportContinue: 'Keep reading',
|
||||
purchaseTitle: 'Thank you!',
|
||||
purchaseBody: 'Purchase complete',
|
||||
purchaseDetail: 'Your download edition is on its way. Check your email for PDF & EPUB files. If you don\'t see it within a few minutes, check spam or contact us.',
|
||||
purchaseDetail: 'Your edition is unlocked. If PDF/EPUB links are configured, they appear here; otherwise keep reading online anytime.',
|
||||
purchaseContinue: 'Continue reading online',
|
||||
backCover: 'Back to cover',
|
||||
},
|
||||
|
||||
@ -137,6 +137,12 @@ export const messages = {
|
||||
payFailed: '支付失败',
|
||||
buyGet: '购买 {name}',
|
||||
buySub: '可免费在线阅读,或购买 PDF 与 EPUB 离线永久保存。',
|
||||
buySubOnline: '可免费在线阅读;购买后解锁永久在线访问。PDF/EPUB 文件就绪后可下载。',
|
||||
buyDownload: '购买下载版',
|
||||
buyUnlock: '解锁在线版',
|
||||
ownedDownload: '已购买 · 去下载',
|
||||
ownedOnline: '已购买 · 继续阅读',
|
||||
orReadOnline: '也可免费',
|
||||
checkoutFailed: '结账失败',
|
||||
},
|
||||
thanks: {
|
||||
@ -146,7 +152,7 @@ export const messages = {
|
||||
supportContinue: '继续阅读',
|
||||
purchaseTitle: '谢谢!',
|
||||
purchaseBody: '购买完成',
|
||||
purchaseDetail: '下载版将发送到你的邮箱(PDF 和 EPUB)。若几分钟内未收到,请检查垃圾箱或联系我们。',
|
||||
purchaseDetail: '权益已解锁。若已配置 PDF/EPUB 下载链接会显示在此;否则可随时在线阅读。',
|
||||
purchaseContinue: '继续在线阅读',
|
||||
backCover: '返回封面',
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@ export const zh = {
|
||||
nav: {
|
||||
map: "🗺️ 地图",
|
||||
mapShort: "地图",
|
||||
memberMap: "🧭 游民地图",
|
||||
destinations: "🌍 目的地",
|
||||
plan: "🗓️ 计划",
|
||||
compare: "⚖️ 对比",
|
||||
@ -109,6 +110,7 @@ export const zh = {
|
||||
mapTemp: "均温",
|
||||
mapRating: "评分",
|
||||
mapBrowse: "浏览全部目的地 →",
|
||||
mapMembers: "看看谁在那里 →",
|
||||
destTag: "🌴 TOP DESTINATIONS",
|
||||
destTitle: "热门旅居目的地",
|
||||
destSubtitle: "筛选、收藏,点击进入城市详情了解更多",
|
||||
@ -145,6 +147,65 @@ export const zh = {
|
||||
sortCostDesc: "💰 价格从高到低",
|
||||
sortSpeed: "📶 网速排序",
|
||||
},
|
||||
lifestyle: {
|
||||
tag: "💻 NOMAD LIFE",
|
||||
title: "数字游民的一天",
|
||||
subtitle: "自由不等于散漫,高效工作才能尽情探索 — 点击卡片查看详情",
|
||||
ctaDay: "七天落地指南",
|
||||
ctaMeetups: "去参加活动",
|
||||
time_morning: "🌅 07:00",
|
||||
icon_morning: "🧘",
|
||||
title_morning: "晨间仪式",
|
||||
desc_morning: "瑜伽、冥想或海边跑步,用身心唤醒开启新的一天",
|
||||
tip_morning: "推荐:清迈河边晨跑、巴厘岛海滩瑜伽",
|
||||
time_work: "☕ 09:00",
|
||||
icon_work: "💻",
|
||||
title_work: "深度工作",
|
||||
desc_work: "在 Co-working Space 或咖啡馆专注 4 小时,完成核心任务",
|
||||
tip_work: "推荐:Punspace 清迈、Outsite 里斯本",
|
||||
time_explore: "🍜 13:00",
|
||||
icon_explore: "🍽️",
|
||||
title_explore: "本地探索",
|
||||
desc_explore: "品尝地道美食,与当地人交流,感受文化碰撞",
|
||||
tip_explore: "推荐:清迈夜市、巴塞罗那 Tapas 之旅",
|
||||
time_collab: "🌇 17:00",
|
||||
icon_collab: "📞",
|
||||
title_collab: "跨时区协作",
|
||||
desc_collab: "与全球团队视频会议,灵活安排跨时区沟通",
|
||||
tip_collab: "工具:Calendly 排时区、World Time Buddy",
|
||||
time_social: "🌙 20:00",
|
||||
icon_social: "🎉",
|
||||
title_social: "社群社交",
|
||||
desc_social: "参加 Nomad Meetup,结识来自世界各地的同行者",
|
||||
tip_social: "平台:nomadro 活动、同城社群",
|
||||
},
|
||||
homeCommunity: {
|
||||
tag: "🤝 COMMUNITY",
|
||||
title: "游民心声",
|
||||
subtitle: "来自全球数字游民的真实故事",
|
||||
ctaTitle: "准备好开始你的旅居之旅了吗?",
|
||||
ctaDesc: "订阅周报,或直接走进社区与活动",
|
||||
},
|
||||
aboutPage: {
|
||||
tag: "🌏 ABOUT",
|
||||
title: "关于 nomadro",
|
||||
subtitle: "用一行代码环游世界",
|
||||
whoTitle: "我们是谁",
|
||||
whoBody:
|
||||
"nomadro 是一个面向数字游民的旅居决策平台。我们把目的地数据、签证信息、费用估算、时区协作与社区经验集中在一起,帮你更快做出「下一站去哪」的决定。",
|
||||
whatTitle: "能做什么",
|
||||
what1: "🗺️ 探索全球热门游民城市与对比工具",
|
||||
what2: "🧮 计算生活成本、机票与启动金目标",
|
||||
what3: "📋 签证匹配、保险与上网方案参考",
|
||||
what4: "🗓️ 规划多城行程并分享给伙伴",
|
||||
what5: "🗣️ 落地短语、行李清单与活动日历",
|
||||
contactTitle: "联系我们",
|
||||
contactBody: "合作与反馈:",
|
||||
repoBody: "开源仓库:",
|
||||
ctaExplore: "智能下一站",
|
||||
ctaCommunity: "加入社区",
|
||||
ctaPlan: "打开计划",
|
||||
},
|
||||
common: {
|
||||
backHome: "← 返回首页",
|
||||
browseDest: "浏览目的地",
|
||||
@ -365,6 +426,8 @@ export const zh = {
|
||||
pwdNeed: "还差 {n} 位",
|
||||
pwdOk: "可用 · 再长一点更安全",
|
||||
pwdStrong: "强度不错",
|
||||
google: "使用 Google 登录",
|
||||
googleFail: "Google 登录失败",
|
||||
},
|
||||
footer: {
|
||||
explore: "探索",
|
||||
@ -422,7 +485,7 @@ export const zh = {
|
||||
matched: "你和",
|
||||
keepSwiping: "继续滑",
|
||||
chatNow: "去聊天",
|
||||
likesTitle: "我喜欢的",
|
||||
likesTitle: "喜欢",
|
||||
likesSubtitle: "你右滑喜欢的游民会出现在这里",
|
||||
likesEmpty: "还没有喜欢任何人,去滑一张卡片吧",
|
||||
backSwipe: "返回匹配",
|
||||
@ -506,10 +569,14 @@ export const zh = {
|
||||
draftHint: "资料草稿会自动保存在本机",
|
||||
saving: "保存中…",
|
||||
vipActive: "✨ 你已是 VIP · 权益已生效",
|
||||
payMethods: "目前支持支付宝 / 微信支付(中国大陆通道)",
|
||||
},
|
||||
live: {
|
||||
loading: "加载直播间…",
|
||||
loginRequired: "登录并报名后可进入直播",
|
||||
loginRequired: "登录后可进入直播",
|
||||
rsvpRequired: "报名后即可进入直播间",
|
||||
rsvpJoin: "报名并进入",
|
||||
startsAt: "活动时间:{when} · 到点后房间会更热闹",
|
||||
vipRequired: "VIP 会员可进入此活动直播",
|
||||
back: "返回活动",
|
||||
unavailable: "无法打开直播间",
|
||||
@ -520,6 +587,39 @@ export const zh = {
|
||||
layoutVideo: "视频",
|
||||
layoutChat: "聊天",
|
||||
linksMissing: "直播链接尚未配置",
|
||||
openVideo: "新窗口打开视频",
|
||||
openChat: "新窗口打开聊天",
|
||||
iframeHint: "若画面空白,请用上方按钮在新窗口打开",
|
||||
},
|
||||
visaWizard: {
|
||||
tag: "🧙 VISA WIZARD",
|
||||
title: "签证智能匹配",
|
||||
subtitle: "回答 3 个问题,找到最适合你的远程工作签证",
|
||||
stepIncome: "月收入",
|
||||
stepDuration: "停留时长",
|
||||
stepRegion: "目的地",
|
||||
income_low: "💰 低于 ¥8,000/月",
|
||||
income_mid: "💼 ¥8,000 – ¥20,000/月",
|
||||
income_high: "🚀 ¥20,000 – ¥50,000/月",
|
||||
income_premium: "👑 高于 ¥50,000/月",
|
||||
duration_short: "⏱️ 1-3 个月",
|
||||
duration_medium: "📅 3-12 个月",
|
||||
duration_long: "🏠 1 年以上",
|
||||
region_any: "🌏 不限",
|
||||
region_europe: "🏰 欧洲",
|
||||
region_sea: "🌴 东南亚",
|
||||
region_latam: "🌮 拉美",
|
||||
prev: "上一步",
|
||||
next: "下一步",
|
||||
finish: "查看推荐 ✨",
|
||||
empty: "暂无完全匹配的签证,建议咨询顾问或放宽条件重试",
|
||||
bestMatch: "🏆 最佳匹配",
|
||||
match: "匹配",
|
||||
reset: "🔄 重新匹配",
|
||||
ctaCity: "查看城市",
|
||||
ctaPlan: "写入计划",
|
||||
ctaServices: "签证咨询",
|
||||
ctaCommunity: "去社区问问",
|
||||
},
|
||||
digital: {
|
||||
tag: "🎓 ACADEMY",
|
||||
@ -557,7 +657,7 @@ export const zh = {
|
||||
missingLesson: "课时不存在",
|
||||
backCatalog: "返回课程目录",
|
||||
dayGuide: "每日指南",
|
||||
dayGuideDesc: "从税务居民到落地验网,三天起步清单",
|
||||
dayGuideDesc: "从税务居民到固定作息,七天落地清单",
|
||||
back: "学院首页",
|
||||
backCourse: "课程目录",
|
||||
locked: "此课时需要 VIP",
|
||||
@ -686,6 +786,7 @@ export const zh = {
|
||||
meetPeers: "同城可能认识的人",
|
||||
goDating: "去匹配",
|
||||
rsvpCalCta: "导出日历",
|
||||
viewRsvps: "查看报名",
|
||||
viewProfile: "查看资料",
|
||||
},
|
||||
community: {
|
||||
@ -718,6 +819,8 @@ export const zh = {
|
||||
replyNeed: "请先写一点回复内容",
|
||||
replyOk: "回复已发布",
|
||||
replyFail: "回复失败",
|
||||
likeFail: "点赞失败,请稍后重试",
|
||||
replyShortcut: "Ctrl/⌘ + Enter 发送",
|
||||
noRepliesYet: "还没有回复,来做第一个吧",
|
||||
catAll: "全部",
|
||||
catVisa: "签证",
|
||||
@ -730,6 +833,27 @@ export const zh = {
|
||||
draftHint: "内容会自动保存在本机",
|
||||
replyDraftRestored: "已恢复未发送的回复草稿",
|
||||
},
|
||||
bookThanks: {
|
||||
title: "下载版",
|
||||
needLogin: "请先登录后查看下载。",
|
||||
checking: "正在确认订单…",
|
||||
pending: "支付确认中,请稍后刷新;或联系支持核对订单。",
|
||||
statusFail: "无法确认购买状态",
|
||||
refresh: "刷新状态",
|
||||
confirmedNoFiles: "购买已确认。PDF/EPUB 下载暂未开放。",
|
||||
confirmedHint: "可先在线阅读完整版,或联系支持获取文件。",
|
||||
continueRead: "继续在线阅读",
|
||||
contact: "联系支持",
|
||||
ready: "购买成功,可下载:",
|
||||
},
|
||||
blogDetail: {
|
||||
back: "返回博客",
|
||||
readTime: "{n} 分钟阅读",
|
||||
related: "相关文章",
|
||||
readMore: "阅读 →",
|
||||
explore: "接着探索",
|
||||
submit: "投稿",
|
||||
},
|
||||
gigs: {
|
||||
tag: "💼 GIGS",
|
||||
title: "赏金任务",
|
||||
@ -779,7 +903,8 @@ export const zh = {
|
||||
free2: "参与社区讨论与活动 RSVP",
|
||||
vip: "VIP 会员",
|
||||
year: "年",
|
||||
try: "开始匹配",
|
||||
try: "完善资料开始",
|
||||
payNote: "VIP 支付目前走支付宝 / 微信",
|
||||
},
|
||||
feedback: {
|
||||
tag: "💬 FEEDBACK",
|
||||
@ -1151,6 +1276,8 @@ export const zh = {
|
||||
submit: "订阅",
|
||||
ok: "订阅成功",
|
||||
fail: "订阅失败",
|
||||
invalidEmail: "请输入有效邮箱",
|
||||
note: "先登记邮箱;邮件推送开通后会同步发送,不会假装已群发",
|
||||
},
|
||||
pwa: {
|
||||
installHint: "可安装到主屏幕,离线也能打开计划入口",
|
||||
@ -1278,6 +1405,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
nav: {
|
||||
map: "🗺️ Map",
|
||||
mapShort: "Map",
|
||||
memberMap: "🧭 Who's there",
|
||||
destinations: "🌍 Destinations",
|
||||
plan: "🗓️ Plan",
|
||||
compare: "⚖️ Compare",
|
||||
@ -1383,6 +1511,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
mapTemp: "Avg temp",
|
||||
mapRating: "Rating",
|
||||
mapBrowse: "Browse all cities →",
|
||||
mapMembers: "See who's there →",
|
||||
destTag: "🌴 TOP DESTINATIONS",
|
||||
destTitle: "Top nomad cities",
|
||||
destSubtitle: "Filter, favorite, tap a city for full details",
|
||||
@ -1419,6 +1548,65 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
sortCostDesc: "💰 Cost high → low",
|
||||
sortSpeed: "📶 Wi‑Fi speed",
|
||||
},
|
||||
lifestyle: {
|
||||
tag: "💻 NOMAD LIFE",
|
||||
title: "A day in nomad life",
|
||||
subtitle: "Freedom still needs rhythm — tap a card for detail",
|
||||
ctaDay: "7-day landing guide",
|
||||
ctaMeetups: "Find meetups",
|
||||
time_morning: "🌅 07:00",
|
||||
icon_morning: "🧘",
|
||||
title_morning: "Morning ritual",
|
||||
desc_morning: "Yoga, meditation, or a run by the water to wake body and mind",
|
||||
tip_morning: "Try: riverside run in Chiang Mai, beach yoga in Bali",
|
||||
time_work: "☕ 09:00",
|
||||
icon_work: "💻",
|
||||
title_work: "Deep work",
|
||||
desc_work: "Four focused hours in a coworking space or café for core tasks",
|
||||
tip_work: "Try: Punspace Chiang Mai, Outsite Lisbon",
|
||||
time_explore: "🍜 13:00",
|
||||
icon_explore: "🍽️",
|
||||
title_explore: "Local explore",
|
||||
desc_explore: "Eat local food, talk to people, feel the culture shift",
|
||||
tip_explore: "Try: Chiang Mai night market, Barcelona tapas walk",
|
||||
time_collab: "🌇 17:00",
|
||||
icon_collab: "📞",
|
||||
title_collab: "Cross-timezone sync",
|
||||
desc_collab: "Video with a global team — schedule around the clock",
|
||||
tip_collab: "Tools: Calendly time zones, World Time Buddy",
|
||||
time_social: "🌙 20:00",
|
||||
icon_social: "🎉",
|
||||
title_social: "Community night",
|
||||
desc_social: "Join a nomad meetup and meet people from everywhere",
|
||||
tip_social: "Platforms: nomadro events, local groups",
|
||||
},
|
||||
homeCommunity: {
|
||||
tag: "🤝 COMMUNITY",
|
||||
title: "Nomad voices",
|
||||
subtitle: "Real stories from digital nomads worldwide",
|
||||
ctaTitle: "Ready for your next move?",
|
||||
ctaDesc: "Subscribe to the weekly brief, or jump into community and events",
|
||||
},
|
||||
aboutPage: {
|
||||
tag: "🌏 ABOUT",
|
||||
title: "About nomadro",
|
||||
subtitle: "Travel the world with one line of code",
|
||||
whoTitle: "Who we are",
|
||||
whoBody:
|
||||
"nomadro is a move-decision platform for digital nomads. We bring destination data, visas, cost estimates, timezone collaboration, and community experience into one place so you can decide where to go next faster.",
|
||||
whatTitle: "What you can do",
|
||||
what1: "🗺️ Explore top nomad cities and comparison tools",
|
||||
what2: "🧮 Estimate living costs, flights, and startup runway",
|
||||
what3: "📋 Visa matching, insurance, and connectivity references",
|
||||
what4: "🗓️ Plan multi-city trips and share with partners",
|
||||
what5: "🗣️ Landing phrases, packing lists, and event calendars",
|
||||
contactTitle: "Contact",
|
||||
contactBody: "Partnerships & feedback:",
|
||||
repoBody: "Open source:",
|
||||
ctaExplore: "Smart next stop",
|
||||
ctaCommunity: "Join community",
|
||||
ctaPlan: "Open plan",
|
||||
},
|
||||
common: {
|
||||
backHome: "← Back home",
|
||||
browseDest: "Destinations",
|
||||
@ -1639,6 +1827,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
pwdNeed: "{n} more characters needed",
|
||||
pwdOk: "OK · a bit longer is safer",
|
||||
pwdStrong: "Looking strong",
|
||||
google: "Continue with Google",
|
||||
googleFail: "Google sign-in failed",
|
||||
},
|
||||
footer: {
|
||||
explore: "Explore",
|
||||
@ -1696,7 +1886,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
matched: "You matched with",
|
||||
keepSwiping: "Keep swiping",
|
||||
chatNow: "Open chat",
|
||||
likesTitle: "People you liked",
|
||||
likesTitle: "Likes",
|
||||
likesSubtitle: "Profiles you swiped right on",
|
||||
likesEmpty: "No likes yet — go swipe some cards",
|
||||
backSwipe: "Back to match",
|
||||
@ -1780,10 +1970,14 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
draftHint: "Profile draft autosaves on this device",
|
||||
saving: "Saving…",
|
||||
vipActive: "✨ You're VIP — perks are active",
|
||||
payMethods: "Checkout uses Alipay / WeChat Pay (China rails) for now",
|
||||
},
|
||||
live: {
|
||||
loading: "Loading live room…",
|
||||
loginRequired: "Sign in and RSVP to join the live room",
|
||||
loginRequired: "Sign in to join the live room",
|
||||
rsvpRequired: "RSVP to enter the live room",
|
||||
rsvpJoin: "RSVP and join",
|
||||
startsAt: "Starts {when} · the room fills closer to start",
|
||||
vipRequired: "VIP required for this live event",
|
||||
back: "Back to events",
|
||||
unavailable: "Can't open live room",
|
||||
@ -1794,6 +1988,39 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
layoutVideo: "Video",
|
||||
layoutChat: "Chat",
|
||||
linksMissing: "Live links are not configured yet",
|
||||
openVideo: "Open video in new tab",
|
||||
openChat: "Open chat in new tab",
|
||||
iframeHint: "If the embed is blank, open the rooms in a new tab above",
|
||||
},
|
||||
visaWizard: {
|
||||
tag: "🧙 VISA WIZARD",
|
||||
title: "Visa matcher",
|
||||
subtitle: "Answer 3 questions to find remote-work visas that fit",
|
||||
stepIncome: "Income",
|
||||
stepDuration: "Stay length",
|
||||
stepRegion: "Region",
|
||||
income_low: "💰 Under ¥8,000/mo",
|
||||
income_mid: "💼 ¥8,000 – ¥20,000/mo",
|
||||
income_high: "🚀 ¥20,000 – ¥50,000/mo",
|
||||
income_premium: "👑 Over ¥50,000/mo",
|
||||
duration_short: "⏱️ 1–3 months",
|
||||
duration_medium: "📅 3–12 months",
|
||||
duration_long: "🏠 1 year+",
|
||||
region_any: "🌏 Anywhere",
|
||||
region_europe: "🏰 Europe",
|
||||
region_sea: "🌴 SE Asia",
|
||||
region_latam: "🌮 LatAm",
|
||||
prev: "Back",
|
||||
next: "Next",
|
||||
finish: "See matches ✨",
|
||||
empty: "No strong matches — try looser filters or talk to an advisor",
|
||||
bestMatch: "🏆 Best match",
|
||||
match: "match",
|
||||
reset: "🔄 Match again",
|
||||
ctaCity: "View city",
|
||||
ctaPlan: "Add to plan",
|
||||
ctaServices: "Visa help",
|
||||
ctaCommunity: "Ask community",
|
||||
},
|
||||
digital: {
|
||||
tag: "🎓 ACADEMY",
|
||||
@ -1831,7 +2058,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
missingLesson: "Lesson not found",
|
||||
backCatalog: "Back to catalog",
|
||||
dayGuide: "Daily guides",
|
||||
dayGuideDesc: "Tax residency to landing Wi‑Fi — a 3-day starter checklist",
|
||||
dayGuideDesc: "Tax residency to daily rhythm — a 7-day landing checklist",
|
||||
back: "Academy home",
|
||||
backCourse: "Course catalog",
|
||||
locked: "VIP required",
|
||||
@ -1960,6 +2187,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
meetPeers: "People you may meet nearby",
|
||||
goDating: "Go match",
|
||||
rsvpCalCta: "Export calendar",
|
||||
viewRsvps: "View RSVPs",
|
||||
viewProfile: "View profile",
|
||||
},
|
||||
community: {
|
||||
@ -1992,6 +2220,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
replyNeed: "Write a reply first",
|
||||
replyOk: "Reply posted",
|
||||
replyFail: "Reply failed",
|
||||
likeFail: "Couldn't like — try again",
|
||||
replyShortcut: "Ctrl/⌘ + Enter to send",
|
||||
noRepliesYet: "No replies yet — be the first",
|
||||
catAll: "All",
|
||||
catVisa: "Visa",
|
||||
@ -2004,6 +2234,27 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
draftHint: "Draft autosaves on this device",
|
||||
replyDraftRestored: "Unsent reply draft restored",
|
||||
},
|
||||
bookThanks: {
|
||||
title: "Download edition",
|
||||
needLogin: "Sign in to view your downloads.",
|
||||
checking: "Confirming your order…",
|
||||
pending: "Payment is still confirming — refresh shortly or contact support.",
|
||||
statusFail: "Could not confirm purchase status",
|
||||
refresh: "Refresh status",
|
||||
confirmedNoFiles: "Purchase confirmed. PDF/EPUB files are not configured yet.",
|
||||
confirmedHint: "You can keep reading online, or contact support for files.",
|
||||
continueRead: "Continue reading online",
|
||||
contact: "Contact support",
|
||||
ready: "Purchase complete — download:",
|
||||
},
|
||||
blogDetail: {
|
||||
back: "Back to blog",
|
||||
readTime: "{n} min read",
|
||||
related: "Related posts",
|
||||
readMore: "Read →",
|
||||
explore: "Keep exploring",
|
||||
submit: "Submit",
|
||||
},
|
||||
gigs: {
|
||||
tag: "💼 GIGS",
|
||||
title: "Bounty gigs",
|
||||
@ -2053,7 +2304,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
free2: "Discussions & event RSVPs",
|
||||
vip: "VIP",
|
||||
year: "year",
|
||||
try: "Start matching",
|
||||
try: "Complete profile",
|
||||
payNote: "VIP checkout currently uses Alipay / WeChat Pay",
|
||||
},
|
||||
feedback: {
|
||||
tag: "💬 FEEDBACK",
|
||||
@ -2425,6 +2677,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
||||
submit: "Subscribe",
|
||||
ok: "Subscribed",
|
||||
fail: "Could not subscribe",
|
||||
invalidEmail: "Enter a valid email",
|
||||
note: "We'll save your email now; sends start once mailing is enabled — no fake blasts",
|
||||
},
|
||||
pwa: {
|
||||
installHint: "Install to home screen — open plan offline-ready",
|
||||
|
||||
@ -100,6 +100,8 @@ export function useRingSteps() {
|
||||
|
||||
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
|
||||
|
||||
{ href: "/chat", emoji: "✉️", label: t.ring.chat, desc: t.ring.chatDesc },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
136
scripts/deploy_dating_fix.py
Normal file
136
scripts/deploy_dating_fix.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Upload dating hotfix deps and rebuild web."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
HOST = "107.173.30.245"
|
||||
USER = "root"
|
||||
PASS = "Xiao4669805"
|
||||
REMOTE = "/opt/nomadweb"
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
FILES = [
|
||||
"frontend/src/lib/communityDraft.ts",
|
||||
"frontend/src/lib/meetupLinks.ts",
|
||||
"frontend/src/lib/localDraft.ts",
|
||||
"frontend/src/lib/savedGigs.ts",
|
||||
"frontend/src/lib/savedJobs.ts",
|
||||
"frontend/src/lib/meetupIcs.ts",
|
||||
"frontend/src/lib/aiHistory.ts",
|
||||
"frontend/src/lib/compareHistory.ts",
|
||||
"frontend/src/lib/nextStopPrefs.ts",
|
||||
"frontend/src/lib/recentDestinations.ts",
|
||||
"frontend/src/lib/watchLater.ts",
|
||||
"frontend/src/lib/toolCopy.ts",
|
||||
"frontend/src/lib/coworking.ts",
|
||||
"frontend/src/lib/authNext.ts",
|
||||
"frontend/src/lib/rings.ts",
|
||||
"frontend/src/lib/toast.tsx",
|
||||
"frontend/src/lib/api.ts",
|
||||
"frontend/src/lib/types.ts",
|
||||
"frontend/src/lib/i18n/dictionaries.ts",
|
||||
"frontend/src/components/ToolCityExits.tsx",
|
||||
"frontend/src/components/RecentlyViewed.tsx",
|
||||
"frontend/src/components/NomadDigest.tsx",
|
||||
"frontend/src/components/DatingClient.tsx",
|
||||
"frontend/src/components/DatingLikesClient.tsx",
|
||||
"frontend/src/components/ChatClient.tsx",
|
||||
"frontend/src/components/CommunityNewClient.tsx",
|
||||
"frontend/src/components/DiscussionDetailClient.tsx",
|
||||
"frontend/src/components/GigsClient.tsx",
|
||||
"frontend/src/components/MeetupsClient.tsx",
|
||||
"frontend/src/components/MeetupsHostClient.tsx",
|
||||
"frontend/src/components/CommunityHubClient.tsx",
|
||||
"frontend/src/components/MemberProfileClient.tsx",
|
||||
"frontend/src/components/NotificationsClient.tsx",
|
||||
"frontend/src/components/PricingClient.tsx",
|
||||
"frontend/src/components/GlobalSearch.tsx",
|
||||
"frontend/src/components/SiteShell.tsx",
|
||||
"frontend/src/components/Navbar.tsx",
|
||||
"frontend/src/components/Footer.tsx",
|
||||
"frontend/src/app/globals.css",
|
||||
"frontend/src/app/changelog/page.tsx",
|
||||
]
|
||||
|
||||
REBUILD = r"""
|
||||
set -euo pipefail
|
||||
exec 9>/var/lock/nomadro-deploy.lock
|
||||
if ! flock -n 9; then echo LOCK_BUSY; exit 75; fi
|
||||
export NEXT_TELEMETRY_DISABLED=1
|
||||
export NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
|
||||
export NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
|
||||
export NODE_ENV=production
|
||||
cd /opt/nomadweb/frontend
|
||||
echo BUILD_START
|
||||
npm run build
|
||||
mkdir -p .next/standalone/.next
|
||||
rm -rf .next/standalone/public .next/standalone/.next/static
|
||||
cp -a public .next/standalone/public
|
||||
cp -a .next/static .next/standalone/.next/static
|
||||
rm -rf .next/standalone/content
|
||||
mkdir -p .next/standalone/content
|
||||
cp -a content .next/standalone/content
|
||||
systemctl restart nomadro-web
|
||||
for i in $(seq 1 30); do
|
||||
web=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3055/ || true)
|
||||
echo "try=$i web=$web"
|
||||
[ "$web" = "200" ] && exit 0
|
||||
sleep 1
|
||||
done
|
||||
exit 1
|
||||
"""
|
||||
|
||||
|
||||
def ensure_dir(sftp: paramiko.SFTPClient, path: str) -> None:
|
||||
parts = path.strip("/").split("/")
|
||||
cur = ""
|
||||
for p in parts:
|
||||
cur += "/" + p
|
||||
try:
|
||||
sftp.stat(cur)
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
sftp.mkdir(cur)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(HOST, username=USER, password=PASS, timeout=30)
|
||||
sftp = client.open_sftp()
|
||||
for i, rel in enumerate(FILES, 1):
|
||||
local = ROOT / rel
|
||||
if not local.exists():
|
||||
print(f"[{i}/{len(FILES)}] SKIP {rel}", flush=True)
|
||||
continue
|
||||
remote = f"{REMOTE}/{rel}"
|
||||
print(f"[{i}/{len(FILES)}] UPLOAD {rel}", flush=True)
|
||||
ensure_dir(sftp, str(Path(remote).parent).replace("\\", "/"))
|
||||
sftp.put(str(local), remote)
|
||||
sftp.close()
|
||||
|
||||
print("BUILD...", flush=True)
|
||||
_, stdout, stderr = client.exec_command(REBUILD, timeout=900)
|
||||
while True:
|
||||
line = stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
print(line.rstrip(), flush=True)
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
if err.strip():
|
||||
print("LOG:", err[-4000:], flush=True)
|
||||
code = stdout.channel.recv_exit_status()
|
||||
client.close()
|
||||
print("exit", code, flush=True)
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in New Issue
Block a user