From bdf051619793d8094e203ad6182ab190a873b4e3 Mon Sep 17 00:00:00 2001
From: eric
Date: Fri, 4 Sep 2026 06:18:25 -0500
Subject: [PATCH] Fix dating match gate and expand seed data for usable demos.
Co-authored-by: Cursor
---
backend/app/data/city_details_data.py | 106 ++
backend/app/data/community_data.py | 433 +++++-
backend/app/data/digital_content.py | 90 ++
backend/app/data/mock_data.py | 168 +++
backend/app/data/platform_data.py | 196 +++
backend/app/data/social_profiles.py | 52 +-
backend/app/routers/social.py | 39 +-
backend/app/services/auth.py | 98 ++
backend/app/services/community_store.py | 17 +-
backend/app/services/social_store.py | 8 +
frontend/src/app/changelog/page.tsx | 199 +++
frontend/src/app/globals.css | 552 +++++++-
frontend/src/app/login/page.tsx | 16 +-
frontend/src/app/profile/page.tsx | 248 +++-
frontend/src/app/tools/page.tsx | 31 +-
frontend/src/components/AiAssistantClient.tsx | 112 +-
frontend/src/components/ArrivalChecklist.tsx | 9 +
frontend/src/components/Calculator.tsx | 37 +
frontend/src/components/ChatClient.tsx | 24 +-
frontend/src/components/ChatThreadClient.tsx | 62 +-
frontend/src/components/CityNotes.tsx | 29 +-
.../src/components/CommunityHubClient.tsx | 24 +-
.../src/components/CommunityNewClient.tsx | 80 +-
frontend/src/components/CompareClient.tsx | 116 +-
.../src/components/ContentSubmitClient.tsx | 55 +-
frontend/src/components/CookieConsent.tsx | 25 +-
frontend/src/components/CostCompare.tsx | 27 +
frontend/src/components/DatingClient.tsx | 204 ++-
frontend/src/components/DatingLikesClient.tsx | 19 +-
.../components/DestinationDetailClient.tsx | 157 ++-
.../src/components/DestinationMatcher.tsx | 254 ++--
.../src/components/DigitalCourseClient.tsx | 13 +-
frontend/src/components/DigitalHomeClient.tsx | 13 +-
frontend/src/components/DigitalJobsClient.tsx | 170 ++-
.../src/components/DigitalLessonClient.tsx | 23 +-
.../src/components/DiscussionDetailClient.tsx | 80 +-
frontend/src/components/FAQSection.tsx | 20 +-
frontend/src/components/FavoriteButton.tsx | 20 +-
frontend/src/components/FeedbackClient.tsx | 58 +-
frontend/src/components/FeedbackWidget.tsx | 98 +-
frontend/src/components/FirstMonthCost.tsx | 30 +
frontend/src/components/FlightCost.tsx | 2 +
frontend/src/components/Footer.tsx | 6 +-
frontend/src/components/GigPostClient.tsx | 46 +-
frontend/src/components/GigsClient.tsx | 147 +-
frontend/src/components/GlobalSearch.tsx | 185 ++-
frontend/src/components/HomeClient.tsx | 53 +-
frontend/src/components/HousingGuide.tsx | 4 +-
frontend/src/components/JoinClient.tsx | 42 +-
frontend/src/components/JoinPaidClient.tsx | 10 +-
frontend/src/components/KeyboardShortcuts.tsx | 39 +-
frontend/src/components/MeetupLiveClient.tsx | 18 +-
frontend/src/components/MeetupsClient.tsx | 121 +-
frontend/src/components/MeetupsHostClient.tsx | 108 +-
frontend/src/components/MemberMapClient.tsx | 32 +-
.../src/components/MemberProfileClient.tsx | 36 +-
frontend/src/components/MovePlanClient.tsx | 100 +-
frontend/src/components/NextStopClient.tsx | 126 +-
frontend/src/components/NomadDigest.tsx | 156 +++
frontend/src/components/NomadTips.tsx | 41 +-
.../components/NotificationSettingsClient.tsx | 24 +-
.../src/components/NotificationsClient.tsx | 31 +-
frontend/src/components/OnboardingTour.tsx | 80 +-
frontend/src/components/PricingClient.tsx | 8 +
frontend/src/components/RecentlyViewed.tsx | 94 ++
frontend/src/components/ReportClient.tsx | 96 +-
frontend/src/components/RunwayCalculator.tsx | 56 +-
frontend/src/components/SavingsGoal.tsx | 2 +
frontend/src/components/ServicesClient.tsx | 59 +-
frontend/src/components/ShareButton.tsx | 27 +-
frontend/src/components/SiteShell.tsx | 2 +-
frontend/src/components/SpinGlobe.tsx | 6 +-
frontend/src/components/ToolCityExits.tsx | 37 +
frontend/src/components/ToolRunnerClient.tsx | 26 +-
frontend/src/components/VideoDetailClient.tsx | 51 +-
frontend/src/components/VideosClient.tsx | 113 +-
frontend/src/components/VisaStayCountdown.tsx | 2 +
frontend/src/components/WeekendIdeas.tsx | 2 +
frontend/src/components/WifiWorkScore.tsx | 4 +-
frontend/src/components/WorldMap.tsx | 11 +-
frontend/src/lib/aiHistory.ts | 44 +
frontend/src/lib/api.ts | 5 +
frontend/src/lib/authNext.ts | 28 +-
frontend/src/lib/communityDraft.ts | 38 +
frontend/src/lib/compareHistory.ts | 42 +
frontend/src/lib/coworking.ts | 30 +
frontend/src/lib/i18n/dictionaries.ts | 1218 ++++++++++++++++-
frontend/src/lib/localDraft.ts | 27 +
frontend/src/lib/meetupIcs.ts | 76 +
frontend/src/lib/meetupLinks.ts | 30 +
frontend/src/lib/nextStopPrefs.ts | 40 +
frontend/src/lib/recentDestinations.ts | 45 +
frontend/src/lib/rings.ts | 160 ++-
frontend/src/lib/savedGigs.ts | 54 +
frontend/src/lib/savedJobs.ts | 35 +
frontend/src/lib/toast.tsx | 21 +-
frontend/src/lib/toolCopy.ts | 98 ++
frontend/src/lib/watchLater.ts | 48 +
scripts/deploy_quick.py | 4 +
99 files changed, 7149 insertions(+), 909 deletions(-)
create mode 100644 frontend/src/components/NomadDigest.tsx
create mode 100644 frontend/src/components/RecentlyViewed.tsx
create mode 100644 frontend/src/components/ToolCityExits.tsx
create mode 100644 frontend/src/lib/aiHistory.ts
create mode 100644 frontend/src/lib/communityDraft.ts
create mode 100644 frontend/src/lib/compareHistory.ts
create mode 100644 frontend/src/lib/localDraft.ts
create mode 100644 frontend/src/lib/meetupIcs.ts
create mode 100644 frontend/src/lib/meetupLinks.ts
create mode 100644 frontend/src/lib/nextStopPrefs.ts
create mode 100644 frontend/src/lib/recentDestinations.ts
create mode 100644 frontend/src/lib/savedGigs.ts
create mode 100644 frontend/src/lib/savedJobs.ts
create mode 100644 frontend/src/lib/toolCopy.ts
create mode 100644 frontend/src/lib/watchLater.ts
diff --git a/backend/app/data/city_details_data.py b/backend/app/data/city_details_data.py
index 6ddf15b..113cbaa 100644
--- a/backend/app/data/city_details_data.py
+++ b/backend/app/data/city_details_data.py
@@ -187,6 +187,71 @@ EXTRA_ROUTES: list[dict[str, Any]] = [
"status": "published",
"emoji": "🚄",
},
+ {
+ "slug": "tax-base-loop",
+ "title": "税务友好试住线",
+ "titleEn": "Tax Base Loop",
+ "citySlugs": ["tbilisi", "dubai"],
+ "durationDays": 75,
+ "budget": 22000,
+ "description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。",
+ "descriptionEn": "Tbilisi base trial plus Dubai hub month.",
+ "stops": ["第比利斯 45 天", "迪拜 30 天"],
+ "status": "published",
+ "emoji": "🧾",
+ },
+ {
+ "slug": "latam-spring-trail",
+ "title": "拉美春城双城",
+ "titleEn": "LATAM Spring Trail",
+ "citySlugs": ["medellin", "mexico"],
+ "durationDays": 60,
+ "budget": 15000,
+ "description": "麦德林 → 墨西哥城,北美时区远程友好。",
+ "descriptionEn": "Medellín to Mexico City for NA timezones.",
+ "stops": ["麦德林 30 天", "墨西哥城 30 天"],
+ "status": "published",
+ "emoji": "🌺",
+ },
+ {
+ "slug": "create-slow-china",
+ "title": "国内慢创作线",
+ "titleEn": "China Slow Create",
+ "citySlugs": ["dali"],
+ "durationDays": 45,
+ "budget": 9000,
+ "description": "大理深度创作,适合内容创作者与独立开发者。",
+ "descriptionEn": "Dali deep create stay.",
+ "stops": ["大理 45 天"],
+ "status": "published",
+ "emoji": "📷",
+ },
+ {
+ "slug": "euro-oss-sprint",
+ "title": "欧洲开源冲刺线",
+ "titleEn": "Euro OSS Sprint",
+ "citySlugs": ["berlin", "lisbon"],
+ "durationDays": 50,
+ "budget": 32000,
+ "description": "柏林技术社群 + 里斯本阳光回调。",
+ "descriptionEn": "Berlin tech then Lisbon sun.",
+ "stops": ["柏林 25 天", "里斯本 25 天"],
+ "status": "published",
+ "emoji": "🖥️",
+ },
+ {
+ "slug": "east-asia-net",
+ "title": "东亚网速双城",
+ "titleEn": "East Asia Net Duo",
+ "citySlugs": ["seoul", "tokyo"],
+ "durationDays": 35,
+ "budget": 30000,
+ "description": "首尔与东京高强度协作月,适合产品迭代。",
+ "descriptionEn": "Seoul and Tokyo sprint months.",
+ "stops": ["首尔 18 天", "东京 17 天"],
+ "status": "published",
+ "emoji": "📶",
+ },
]
@@ -234,4 +299,45 @@ CONTENT_ITEMS: list[dict[str, Any]] = [
"status": "published",
"authorName": "nomadro",
},
+ {
+ "slug": "medellin-trial",
+ "type": "guide",
+ "title": "麦德林试住手册",
+ "titleEn": "Medellín Trial Guide",
+ "subtitle": "安全区、西语与北美时区",
+ "description": "30 天试住清单与预算模板。",
+ "targetUrl": "/destinations/medellin",
+ "ctaLabel": "看城市",
+ "citySlugs": ["medellin"],
+ "sortOrder": 4,
+ "status": "published",
+ "authorName": "Sofia",
+ },
+ {
+ "slug": "tbilisi-tax-note",
+ "type": "guide",
+ "title": "第比利斯税务笔记",
+ "titleEn": "Tbilisi Tax Notes",
+ "subtitle": "远程签证与开户提醒",
+ "description": "非正式法律意见,帮助你先做试住决策。",
+ "targetUrl": "/destinations/tbilisi",
+ "ctaLabel": "打开笔记",
+ "citySlugs": ["tbilisi"],
+ "sortOrder": 5,
+ "status": "published",
+ "authorName": "Devon",
+ },
+ {
+ "slug": "first-month-video",
+ "type": "video",
+ "title": "旅居第一月稳定输出",
+ "subtitle": "SIM、短租、社群五步法",
+ "description": "新手最容易踩坑的第一周怎么过。",
+ "targetUrl": "/videos/first-month",
+ "ctaLabel": "看视频",
+ "citySlugs": ["chiangmai", "lisbon", "bali"],
+ "sortOrder": 6,
+ "status": "published",
+ "authorName": "nomadro",
+ },
]
diff --git a/backend/app/data/community_data.py b/backend/app/data/community_data.py
index 37604e3..e6f96b1 100644
--- a/backend/app/data/community_data.py
+++ b/backend/app/data/community_data.py
@@ -26,7 +26,7 @@ MEETUPS = [
"id": "chiangmai-cowork",
"title": "清迈联合办公下午茶",
"city": "清迈",
- "destination_slug": "chiang-mai",
+ "destination_slug": "chiangmai",
"emoji": "☕",
"date": "2026-09-18",
"time": "15:00",
@@ -96,6 +96,133 @@ MEETUPS = [
"tags": ["税务", "日本", "干货"],
"is_upcoming": True,
},
+ {
+ "id": "berlin-opensource",
+ "title": "柏林开源与 DevOps 夜谈",
+ "city": "柏林",
+ "destination_slug": "berlin",
+ "emoji": "🖥️",
+ "date": "2026-09-28",
+ "time": "19:00",
+ "venue": "Kreuzberg Co-working",
+ "description": "分享自托管、CI 与远程团队协作实践,会后可继续社交。",
+ "mode": "hybrid",
+ "access_level": "public",
+ "mirotalkRoom": "nomadro-berlin-oss",
+ "rsvp_count": 22,
+ "max_attendees": 40,
+ "organizer": "Berlin Remote",
+ "tags": ["开源", "技术", "欧洲"],
+ "is_upcoming": True,
+ },
+ {
+ "id": "dali-create-walk",
+ "title": "大理洱海创作散步",
+ "city": "大理",
+ "destination_slug": "dali",
+ "emoji": "📷",
+ "date": "2026-10-02",
+ "time": "16:00",
+ "venue": "才村码头集合",
+ "description": "边走边拍,分享内容选题与变现路径,适合创作者与慢旅居者。",
+ "mode": "offline",
+ "access_level": "public",
+ "rsvp_count": 8,
+ "max_attendees": 18,
+ "organizer": "大理创作者局",
+ "tags": ["内容", "户外", "国内"],
+ "is_upcoming": True,
+ },
+ {
+ "id": "medellin-spanish",
+ "title": "麦德林西语咖啡局",
+ "city": "麦德林",
+ "destination_slug": "medellin",
+ "emoji": "☕",
+ "date": "2026-10-08",
+ "time": "10:30",
+ "venue": "Laureles 咖啡馆",
+ "description": "初级西语练习 + 安全区与租房情报交换,新手友好。",
+ "mode": "offline",
+ "access_level": "public",
+ "rsvp_count": 16,
+ "max_attendees": 20,
+ "organizer": "Medellín Nomads",
+ "tags": ["语言", "拉美", "社交"],
+ "is_upcoming": True,
+ },
+ {
+ "id": "seoul-night-office",
+ "title": "首尔深夜联合办公",
+ "city": "首尔",
+ "destination_slug": "seoul",
+ "emoji": "🌃",
+ "date": "2026-10-15",
+ "time": "21:00",
+ "venue": "江南共享办公",
+ "description": "适合东亚时区冲刺交付的游民,自带电脑即可。",
+ "mode": "offline",
+ "access_level": "public",
+ "rsvp_count": 12,
+ "max_attendees": 25,
+ "organizer": "Seoul Workation",
+ "tags": ["办公", "韩国", "效率"],
+ "is_upcoming": True,
+ },
+ {
+ "id": "dubai-hub-brunch",
+ "title": "迪拜枢纽早午餐",
+ "city": "迪拜",
+ "destination_slug": "dubai",
+ "emoji": "🥂",
+ "date": "2026-10-18",
+ "time": "11:00",
+ "venue": "Downtown 咖啡厅",
+ "description": "中转游民见面:签证、航班与下一站城市选择。",
+ "mode": "offline",
+ "access_level": "public",
+ "rsvp_count": 10,
+ "max_attendees": 16,
+ "organizer": "Dubai Hub Crew",
+ "tags": ["中东", "社交", "跳板"],
+ "is_upcoming": True,
+ },
+ {
+ "id": "tbilisi-tax-cafe",
+ "title": "第比利斯税务咖啡聊",
+ "city": "第比利斯",
+ "destination_slug": "tbilisi",
+ "emoji": "🍷",
+ "date": "2026-10-22",
+ "time": "15:00",
+ "venue": "Vake 区咖啡馆",
+ "description": "交流远程签证与本地公司实务(非正式法律意见),会后可徒步旧城。",
+ "mode": "offline",
+ "access_level": "members",
+ "rsvp_count": 19,
+ "max_attendees": 22,
+ "organizer": "Tbilisi Base",
+ "tags": ["税务", "欧洲", "干货"],
+ "is_upcoming": True,
+ },
+ {
+ "id": "mexico-food-walk",
+ "title": "墨西哥城街头美食漫步",
+ "city": "墨西哥城",
+ "destination_slug": "mexico",
+ "emoji": "🌮",
+ "date": "2026-10-26",
+ "time": "17:30",
+ "venue": "Roma Norte 集合",
+ "description": "边吃边聊北美时区接单与安全出行经验。",
+ "mode": "offline",
+ "access_level": "public",
+ "rsvp_count": 13,
+ "max_attendees": 18,
+ "organizer": "CDMX Nomads",
+ "tags": ["美食", "拉美", "社交"],
+ "is_upcoming": True,
+ },
]
DISCUSSIONS = [
@@ -164,6 +291,90 @@ DISCUSSIONS = [
"created_at": "2026-08-28",
"tags": ["活动", "投票"],
},
+ {
+ "id": "medellin-safety",
+ "title": "麦德林哪些街区适合第一次长住?",
+ "excerpt": "Laureles / Poblado / Envigado 的网速、噪音和夜间出行体验求分享。",
+ "author": "Sofia",
+ "author_emoji": "🌺",
+ "category": "住宿",
+ "reply_count": 17,
+ "like_count": 41,
+ "created_at": "2026-08-30",
+ "tags": ["麦德林", "安全"],
+ },
+ {
+ "id": "tbilisi-bank",
+ "title": "第比利斯开户与收美元的实操经验",
+ "excerpt": "需要本地号码吗?Wise 能不能直接用?求最新流程。",
+ "author": "Devon",
+ "author_emoji": "🍷",
+ "category": "财务",
+ "reply_count": 14,
+ "like_count": 36,
+ "created_at": "2026-09-01",
+ "tags": ["格鲁吉亚", "银行"],
+ },
+ {
+ "id": "berlin-winter",
+ "title": "柏林冬天怎么保持产出和社交?",
+ "excerpt": "日照短、天气冷,有没有室内运动 + meetup 推荐。",
+ "author": "Ken",
+ "author_emoji": "🎨",
+ "category": "生活方式",
+ "reply_count": 9,
+ "like_count": 22,
+ "created_at": "2026-09-02",
+ "tags": ["柏林", "健康"],
+ },
+ {
+ "id": "dali-wifi",
+ "title": "大理合租找房:网速坑位合集",
+ "excerpt": "哪些区域光纤稳定?有没有房东靠谱名单。",
+ "author": "Lina",
+ "author_emoji": "📷",
+ "category": "住宿",
+ "reply_count": 21,
+ "like_count": 48,
+ "created_at": "2026-09-03",
+ "tags": ["大理", "网速"],
+ },
+ {
+ "id": "dubai-summer",
+ "title": "迪拜夏天还能正常远程办公吗?",
+ "excerpt": "空调联合办公、作息调整和预算增加幅度求真实数据。",
+ "author": "Omar",
+ "author_emoji": "🏙️",
+ "category": "生活方式",
+ "reply_count": 8,
+ "like_count": 19,
+ "created_at": "2026-09-04",
+ "tags": ["迪拜", "气候"],
+ },
+ {
+ "id": "seoul-visa",
+ "title": "韩国 Workation 签证材料清单核对",
+ "excerpt": "收入证明、保险、行程……有人最近过签吗?",
+ "author": "Yuki",
+ "author_emoji": "🇰🇷",
+ "category": "签证",
+ "reply_count": 12,
+ "like_count": 33,
+ "created_at": "2026-09-05",
+ "tags": ["韩国", "签证"],
+ },
+ {
+ "id": "cowork-month-pass",
+ "title": "各城市联合办公月票性价比对比",
+ "excerpt": "清迈 / 里斯本 / 柏林 / 麦德林,欢迎贴价目与座位体验。",
+ "author": "Alex",
+ "author_emoji": "🚀",
+ "category": "远程工作",
+ "reply_count": 26,
+ "like_count": 61,
+ "created_at": "2026-09-06",
+ "tags": ["办公", "成本"],
+ },
]
GIGS = [
@@ -176,6 +387,8 @@ GIGS = [
"tags": ["设计", "远程"],
"poster": "nomadro",
"status": "open",
+ "category": "设计",
+ "location": "远程",
},
{
"id": "meetup-host",
@@ -186,6 +399,8 @@ GIGS = [
"tags": ["活动", "深圳"],
"poster": "深圳湾区主理人",
"status": "open",
+ "category": "活动",
+ "location": "深圳",
},
{
"id": "content-translate",
@@ -196,6 +411,116 @@ GIGS = [
"tags": ["翻译", "内容"],
"poster": "nomadro",
"status": "open",
+ "category": "内容",
+ "location": "远程",
+ },
+ {
+ "id": "landing-page",
+ "title": "城市落地页前端实现",
+ "description": "Next.js + Tailwind,按 Figma 还原清迈/里斯本两套落地页。",
+ "budget": "¥3500",
+ "deadline": "2026-10-10",
+ "tags": ["前端", "Next.js"],
+ "poster": "Marco",
+ "status": "open",
+ "category": "开发",
+ "location": "远程",
+ },
+ {
+ "id": "video-edit",
+ "title": "游民访谈短视频剪辑",
+ "description": "4 条 60–90 秒竖版视频,含字幕与封面。",
+ "budget": "¥1500",
+ "deadline": "2026-10-12",
+ "tags": ["视频", "剪辑"],
+ "poster": "Lina",
+ "status": "open",
+ "category": "内容",
+ "location": "远程",
+ },
+ {
+ "id": "seo-audit",
+ "title": "目的地内容 SEO 审计",
+ "description": "审计 10 篇城市指南关键词与内链结构,输出改进清单。",
+ "budget": "¥900",
+ "deadline": "2026-10-08",
+ "tags": ["SEO", "内容"],
+ "poster": "nomadro",
+ "status": "open",
+ "category": "运营",
+ "location": "远程",
+ },
+ {
+ "id": "photo-pack",
+ "title": "大理旅居照片素材包",
+ "description": "需 30 张可商用风格照片(咖啡馆、洱海、合租场景)。",
+ "budget": "¥600",
+ "deadline": "2026-10-05",
+ "tags": ["摄影", "大理"],
+ "poster": "Lina",
+ "status": "open",
+ "category": "设计",
+ "location": "大理",
+ },
+ {
+ "id": "tax-checklist",
+ "title": "格鲁吉亚税务清单校对",
+ "description": "把英文政策摘要整理成中文 checklist(非法律意见)。",
+ "budget": "¥700",
+ "deadline": "2026-10-18",
+ "tags": ["税务", "文档"],
+ "poster": "Devon",
+ "status": "open",
+ "category": "内容",
+ "location": "远程",
+ },
+ {
+ "id": "meetup-photo",
+ "title": "麦德林活动跟拍",
+ "description": "一场线下咖啡局跟拍 + 当天出 15 张精修。",
+ "budget": "$80",
+ "deadline": "2026-10-08",
+ "tags": ["摄影", "活动"],
+ "poster": "Sofia",
+ "status": "open",
+ "category": "活动",
+ "location": "麦德林",
+ },
+ {
+ "id": "chatbot-faq",
+ "title": "签证 FAQ 聊天机器人语料",
+ "description": "整理 50 条问答并标注意图,供客服机器人训练。",
+ "budget": "¥1100",
+ "deadline": "2026-10-20",
+ "tags": ["AI", "内容"],
+ "poster": "Alex",
+ "status": "open",
+ "category": "开发",
+ "location": "远程",
+ },
+ {
+ "id": "ux-test",
+ "title": "下一站推荐工具可用性测试",
+ "description": "招募 5 名游民完成任务并录制反馈(远程)。",
+ "budget": "¥80/人",
+ "deadline": "2026-10-15",
+ "tags": ["UX", "研究"],
+ "poster": "Sara",
+ "status": "open",
+ "category": "产品",
+ "location": "远程",
+ },
+ {
+ "id": "community-mod",
+ "title": "周末社区值班版主",
+ "description": "处理讨论区举报与欢迎新人,需中英双语。",
+ "budget": "¥300/周末",
+ "deadline": "2026-10-30",
+ "tags": ["社区", "兼职"],
+ "poster": "nomadro",
+ "status": "open",
+ "category": "运营",
+ "location": "远程",
},
]
@@ -217,6 +542,14 @@ DISCUSSION_REPLIES = {
"created_at": "2026-08-21",
"like_count": 8,
},
+ {
+ "id": "r3",
+ "author": "Yuki",
+ "author_emoji": "🇮🇩",
+ "content": "巴厘岛短期用 B211A 足够,长期再看第二家园成本是否划算。",
+ "created_at": "2026-08-22",
+ "like_count": 6,
+ },
],
"async-remote": [
{
@@ -227,6 +560,64 @@ DISCUSSION_REPLIES = {
"created_at": "2026-08-23",
"like_count": 9,
},
+ {
+ "id": "r2",
+ "author": "Alex",
+ "author_emoji": "🚀",
+ "content": "模板三要素:昨天完成 / 今天计划 / 卡点,别写散文。",
+ "created_at": "2026-08-23",
+ "like_count": 11,
+ },
+ ],
+ "coliving-tips": [
+ {
+ "id": "r1",
+ "author": "小林",
+ "author_emoji": "🧳",
+ "content": "一定要问清押金退还条件和网速实测截图,别只看宣传。",
+ "created_at": "2026-08-26",
+ "like_count": 14,
+ },
+ ],
+ "medellin-safety": [
+ {
+ "id": "r1",
+ "author": "Alex",
+ "author_emoji": "🌮",
+ "content": "第一次建议 Laureles,生活成本与便利平衡;夜间少拿手机逛街。",
+ "created_at": "2026-08-31",
+ "like_count": 10,
+ },
+ ],
+ "tbilisi-bank": [
+ {
+ "id": "r1",
+ "author": "Omar",
+ "author_emoji": "🏙️",
+ "content": "先办本地 SIM 再去银行,材料准备护照+地址证明更顺利。",
+ "created_at": "2026-09-01",
+ "like_count": 7,
+ },
+ ],
+ "dali-wifi": [
+ {
+ "id": "r1",
+ "author": "阿静",
+ "author_emoji": "🏡",
+ "content": "才村/喜洲合租差别大,入住前用手机热点对照测速最靠谱。",
+ "created_at": "2026-09-03",
+ "like_count": 9,
+ },
+ ],
+ "cowork-month-pass": [
+ {
+ "id": "r1",
+ "author": "Marco",
+ "author_emoji": "💻",
+ "content": "里斯本月票大约 €150–250;清迈很多空间 ¥600–1000 就够用。",
+ "created_at": "2026-09-06",
+ "like_count": 15,
+ },
],
}
@@ -271,4 +662,44 @@ NOMAD_ROUTES = [
"stops": ["tokyo", "chiangmai"],
"emoji": "🚄",
},
+ {
+ "id": "tax-base-loop",
+ "slug": "tax-base-loop",
+ "title": "税务友好试住线",
+ "duration_days": 75,
+ "budget": 22000,
+ "description": "第比利斯试住 + 迪拜枢纽月,适合评估税务与中转策略。",
+ "stops": ["tbilisi", "dubai"],
+ "emoji": "🧾",
+ },
+ {
+ "id": "latam-spring",
+ "slug": "latam-spring-trail",
+ "title": "拉美春城双城",
+ "duration_days": 60,
+ "budget": 15000,
+ "description": "麦德林 → 墨西哥城,北美时区远程友好。",
+ "stops": ["medellin", "mexico"],
+ "emoji": "🌺",
+ },
+ {
+ "id": "create-slow",
+ "slug": "create-slow-china",
+ "title": "国内慢创作线",
+ "duration_days": 45,
+ "budget": 9000,
+ "description": "大理深度创作 + 短途补给,适合内容创作者。",
+ "stops": ["dali"],
+ "emoji": "📷",
+ },
+ {
+ "id": "euro-oss",
+ "slug": "euro-oss-sprint",
+ "title": "欧洲开源冲刺线",
+ "duration_days": 50,
+ "budget": 32000,
+ "description": "柏林技术社群 + 里斯本阳光回调。",
+ "stops": ["berlin", "lisbon"],
+ "emoji": "🖥️",
+ },
]
diff --git a/backend/app/data/digital_content.py b/backend/app/data/digital_content.py
index 45930bf..7b37c8e 100644
--- a/backend/app/data/digital_content.py
+++ b/backend/app/data/digital_content.py
@@ -117,4 +117,94 @@ JOBS = [
"tags": ["社区", "写作", "游民"],
"url": "https://nomadro.com",
},
+ {
+ "id": "j4",
+ "title": "Product Designer",
+ "company": "Atlantic Remote",
+ "location": "欧盟时区 ±3h",
+ "type": "全职",
+ "salary": "€55k–75k",
+ "tags": ["Figma", "B2B", "远程"],
+ "url": "https://nomadro.com",
+ },
+ {
+ "id": "j5",
+ "title": "Growth Marketer",
+ "company": "Hub Ventures",
+ "location": "中东/欧洲友好",
+ "type": "合同",
+ "salary": "$4k–6k/月",
+ "tags": ["SEO", "内容", "增长"],
+ "url": "https://nomadro.com",
+ },
+ {
+ "id": "j6",
+ "title": "Backend Engineer (Python)",
+ "company": "Latam Cloud",
+ "location": "北美时区",
+ "type": "全职",
+ "salary": "$90k–130k",
+ "tags": ["Python", "FastAPI", "Postgres"],
+ "url": "https://nomadro.com",
+ },
+ {
+ "id": "j7",
+ "title": "Community Manager",
+ "company": "nomadro",
+ "location": "全球远程",
+ "type": "兼职",
+ "salary": "¥8k–12k/月",
+ "tags": ["社区", "活动", "中英"],
+ "url": "https://nomadro.com",
+ },
+ {
+ "id": "j8",
+ "title": "Mobile Engineer (iOS/Android)",
+ "company": "Seoul Soft",
+ "location": "东亚时区",
+ "type": "全职",
+ "salary": "$70k–100k",
+ "tags": ["Flutter", "移动端"],
+ "url": "https://nomadro.com",
+ },
+ {
+ "id": "j9",
+ "title": "Technical Writer",
+ "company": "Docs Anywhere",
+ "location": "异步优先",
+ "type": "合同",
+ "salary": "$40–70/h",
+ "tags": ["文档", "开发者关系"],
+ "url": "https://nomadro.com",
+ },
+ {
+ "id": "j10",
+ "title": "Data Analyst",
+ "company": "Wander Metrics",
+ "location": "全球远程",
+ "type": "全职",
+ "salary": "$65k–95k",
+ "tags": ["SQL", "看板", "增长"],
+ "url": "https://nomadro.com",
+ },
+ {
+ "id": "j11",
+ "title": "Customer Success(签证顾问支持)",
+ "company": "nomadro",
+ "location": "中国时区友好",
+ "type": "兼职",
+ "salary": "¥6k–10k/月",
+ "tags": ["客服", "签证", "中文"],
+ "url": "https://nomadro.com",
+ },
+ {
+ "id": "j12",
+ "title": "Full-stack Engineer",
+ "company": "Indie Nomad Tools",
+ "location": "全球远程",
+ "type": "全职",
+ "salary": "$85k–115k",
+ "tags": ["Next.js", "Python", "独立产品"],
+ "url": "https://nomadro.com",
+ },
]
diff --git a/backend/app/data/mock_data.py b/backend/app/data/mock_data.py
index 1f66f07..9884b4a 100644
--- a/backend/app/data/mock_data.py
+++ b/backend/app/data/mock_data.py
@@ -43,6 +43,48 @@ DESTINATIONS = [
"nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"],
"map_x": 820, "map_y": 210,
},
+ {
+ "id": "7", "slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨",
+ "tag": "欧洲 · 创意之都", "description": "开放的创业与开源氛围,租金相对西欧友好,夜生活与艺术场景极具吸引力。",
+ "region": "europe", "cost": 9800, "speed": 140, "temperature": 12, "rating": 8.9, "hue": 250,
+ "nomads_count": "7,200+", "highlights": ["🎨 艺术与街头文化", "💻 创业生态活跃", "🚲 骑行友好", "🍺 社交场景丰富"],
+ "map_x": 500, "map_y": 160,
+ },
+ {
+ "id": "8", "slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️",
+ "tag": "国内 · 慢生活", "description": "苍山洱海之间的内容创作者聚集地,生活节奏慢、社交松弛,适合深度创作与试住。",
+ "region": "asia", "cost": 4200, "speed": 80, "temperature": 18, "rating": 8.7, "hue": 190,
+ "nomads_count": "3,600+", "highlights": ["📷 内容创作友好", "🏞️ 自然风光", "🍵 咖啡馆密集", "🏡 合租成本低"],
+ "map_x": 730, "map_y": 220,
+ },
+ {
+ "id": "9", "slug": "seoul", "name": "首尔", "country": "韩国", "emoji": "🏙️",
+ "tag": "亚洲 · 潮流都市", "description": "高速网络与深夜城市活力并存,适合对接东亚市场与产品迭代。",
+ "region": "asia", "cost": 11000, "speed": 220, "temperature": 14, "rating": 8.5, "hue": 330,
+ "nomads_count": "3,200+", "highlights": ["📶 全球顶尖网速", "🍜 美食与便利店文化", "🛍️ 潮流消费", "🚇 地铁覆盖极强"],
+ "map_x": 800, "map_y": 200,
+ },
+ {
+ "id": "10", "slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺",
+ "tag": "拉美 · 春城", "description": "四季如春、生活成本友好,英语游民社区成熟,适合北美时区远程工作。",
+ "region": "latam", "cost": 5200, "speed": 90, "temperature": 23, "rating": 9.0, "hue": 140,
+ "nomads_count": "6,400+", "highlights": ["🌤️ 四季如春", "💰 高性价比", "🕐 北美时区", "🤝 社区活动密集"],
+ "map_x": 260, "map_y": 300,
+ },
+ {
+ "id": "11", "slug": "dubai", "name": "迪拜", "country": "阿联酋", "emoji": "🏙️",
+ "tag": "中东 · 枢纽都市", "description": "免税与签证便利吸引远程高收入人群,国际航班枢纽,适合中东/欧亚跳板。",
+ "region": "asia", "cost": 14000, "speed": 180, "temperature": 32, "rating": 8.4, "hue": 40,
+ "nomads_count": "4,800+", "highlights": ["🧾 税务友好", "✈️ 航线枢纽", "🏗️ 现代基础设施", "🌐 多元国际社区"],
+ "map_x": 600, "map_y": 240,
+ },
+ {
+ "id": "12", "slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷",
+ "tag": "欧亚 · 税务友好", "description": "1% 小企业税与远程友好签证,旧城氛围浓厚,东欧游民新基地。",
+ "region": "europe", "cost": 4800, "speed": 70, "temperature": 16, "rating": 8.8, "hue": 15,
+ "nomads_count": "5,100+", "highlights": ["🧾 1% 小企业税", "🏰 旧城与温泉", "🍷 葡萄酒文化", "💰 低生活成本"],
+ "map_x": 560, "map_y": 190,
+ },
]
VISAS = [
@@ -58,6 +100,18 @@ VISAS = [
"duration": "1-4年", "income_req": "$2,500/月", "approval_time": "2-4 周", "extra": "🌮 北美时区友好", "difficulty": 30, "difficulty_label": "简单"},
{"id": "6", "country": "爱沙尼亚", "flag": "🇪🇪", "name": "爱沙尼亚 DNV", "badge": "🚀 先锋", "badge_type": "pioneer",
"duration": "1年", "income_req": "€3,504/月", "approval_time": "2-4 周", "extra": "💻 全球首个数字游民签证", "difficulty": 40, "difficulty_label": "中等"},
+ {"id": "7", "country": "格鲁吉亚", "flag": "🇬🇪", "name": "格鲁吉亚 Remotely from Georgia", "badge": "⭐ 推荐", "badge_type": "easy",
+ "duration": "1年", "income_req": "$2,000/月", "approval_time": "约 10 天", "extra": "🧾 可搭配小企业 1% 税", "difficulty": 28, "difficulty_label": "简单"},
+ {"id": "8", "country": "阿联酋", "flag": "🇦🇪", "name": "迪拜 Virtual Working Programme", "badge": "🔥 热门", "badge_type": "hot",
+ "duration": "1年", "income_req": "$3,500/月", "approval_time": "2-4 周", "extra": "✈️ 中东航线枢纽", "difficulty": 42, "difficulty_label": "中等"},
+ {"id": "9", "country": "哥伦比亚", "flag": "🇨🇴", "name": "哥伦比亚 V Visa (Digital Nomad)", "badge": "💰 低成本", "badge_type": "budget",
+ "duration": "最长 2 年", "income_req": "约 3× 最低工资", "approval_time": "2-6 周", "extra": "🌺 适合麦德林春城", "difficulty": 32, "difficulty_label": "简单"},
+ {"id": "10", "country": "韩国", "flag": "🇰🇷", "name": "韩国 Workation Visa (C-3-8)", "badge": "🆕 新政策", "badge_type": "new",
+ "duration": "最长 2 年", "income_req": "约 $65,000/年", "approval_time": "1-2 个月", "extra": "🏙️ 对接首尔与济州", "difficulty": 55, "difficulty_label": "中等"},
+ {"id": "11", "country": "德国", "flag": "🇩🇪", "name": "德国 Freelance / Self-employment", "badge": "🚀 先锋", "badge_type": "pioneer",
+ "duration": "1-3年", "income_req": "需商业计划与客户证明", "approval_time": "2-4 个月", "extra": "🎨 适合柏林自由职业", "difficulty": 65, "difficulty_label": "较难"},
+ {"id": "12", "country": "泰国", "flag": "🇹🇭", "name": "泰国 DTV 签证", "badge": "⭐ 推荐", "badge_type": "easy",
+ "duration": "最长 5 年(多次入境)", "income_req": "约 50 万泰铢存款证明", "approval_time": "2-6 周", "extra": "🤝 远程工作友好", "difficulty": 38, "difficulty_label": "中等"},
]
FAQS = [
@@ -79,6 +133,11 @@ TESTIMONIALS = [
{"id": "1", "avatar": "👩💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5},
{"id": "2", "avatar": "👨🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5},
{"id": "3", "avatar": "🧑💼", "content": "带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。", "author": "张家", "role": "产品经理 · 巴厘岛 🇮🇩", "rating": 5},
+ {"id": "4", "avatar": "👩🎨", "content": "麦德林四季如春,英语社区很活跃。我用北美时区接单,下午还能去公园运动,效率反而更高。", "author": "Sofia", "role": "自由职业设计 · 麦德林 🇨🇴", "rating": 5},
+ {"id": "5", "avatar": "👨💻", "content": "第比利斯生活成本低,还能开本地公司走 1% 税。旧城咖啡馆写代码,周末去卡兹别吉徒步,节奏很舒服。", "author": "Devon", "role": "DevOps · 第比利斯 🇬🇪", "rating": 5},
+ {"id": "6", "avatar": "📷", "content": "大理适合慢创作。合租便宜、风景治愈,但要主动找同频伙伴,别只待在民宿里。", "author": "Lina", "role": "内容创作者 · 大理 🇨🇳", "rating": 4},
+ {"id": "7", "avatar": "🧑🚀", "content": "柏林创业氛围强,开源 meetup 特别多。冬天冷、德语门槛真实存在,但找到圈子后很值得。", "author": "Ken", "role": "数据分析 · 柏林 🇩🇪", "rating": 4},
+ {"id": "8", "avatar": "🏙️", "content": "迪拜当跳板很香:航班方便、签证路径清晰。夏天太热,建议把深度工作时间放在室内联合办公。", "author": "Omar", "role": "增长顾问 · 迪拜 🇦🇪", "rating": 4},
]
TOOLS = [
@@ -94,6 +153,13 @@ BLOG_POSTS = [
{"id": "1", "slug": "chiangmai-guide-2026", "title": "清迈数字游民完全指南 2026", "excerpt": "从签证到住宿,从咖啡馆到 Co-working,一篇搞定清迈旅居。", "emoji": "🏔️", "author": "nomadro", "published_at": "2026-01-15", "read_time": 8, "tags": ["清迈", "指南", "东南亚"]},
{"id": "2", "slug": "portugal-d7-visa", "title": "葡萄牙 D7 签证申请全攻略", "excerpt": "手把手教你申请欧洲最受欢迎的远程工作签证。", "emoji": "🇵🇹", "author": "nomadro", "published_at": "2026-02-03", "read_time": 12, "tags": ["签证", "葡萄牙", "欧洲"]},
{"id": "3", "slug": "nomad-tax-basics", "title": "数字游民税务入门:你需要知道的 5 件事", "excerpt": "183 天规则、双重征税、税务居民身份——一文理清。", "emoji": "🧾", "author": "nomadro", "published_at": "2026-02-20", "read_time": 10, "tags": ["税务", "法律", "指南"]},
+ {"id": "4", "slug": "medellin-spring-city", "title": "麦德林春城试住 30 天笔记", "excerpt": "时区、安全区、联合办公与西语社区初体验。", "emoji": "🌺", "author": "Sofia", "published_at": "2026-03-05", "read_time": 9, "tags": ["麦德林", "拉美", "试住"]},
+ {"id": "5", "slug": "tbilisi-tax-base", "title": "第比利斯当税务基地靠谱吗?", "excerpt": "远程签证、小企业税与旧城生活成本实测。", "emoji": "🍷", "author": "Devon", "published_at": "2026-03-18", "read_time": 11, "tags": ["格鲁吉亚", "税务", "欧洲"]},
+ {"id": "6", "slug": "dali-slow-create", "title": "大理慢创作:合租、网速与社群", "excerpt": "国内游民城市怎么住得久又不散漫。", "emoji": "🏔️", "author": "Lina", "published_at": "2026-04-02", "read_time": 7, "tags": ["大理", "内容", "国内"]},
+ {"id": "7", "slug": "berlin-startup-nomad", "title": "柏林自由职业与开源社群指南", "excerpt": "签证路径、共享办公与每周活动清单。", "emoji": "🎨", "author": "Ken", "published_at": "2026-04-20", "read_time": 10, "tags": ["柏林", "创业", "开源"]},
+ {"id": "8", "slug": "dubai-hub-month", "title": "迪拜一个月跳板旅居怎么排期", "excerpt": "签证、炎热季节、航班中转与办公点选择。", "emoji": "🏙️", "author": "Omar", "published_at": "2026-05-01", "read_time": 8, "tags": ["迪拜", "中东", "枢纽"]},
+ {"id": "9", "slug": "seoul-workation", "title": "首尔 Workation:网速、便利与成本", "excerpt": "C-3-8 签证与江南区办公踩点。", "emoji": "🏙️", "author": "Yuki", "published_at": "2026-05-16", "read_time": 9, "tags": ["首尔", "韩国", "网速"]},
+ {"id": "10", "slug": "first-month-checklist", "title": "旅居第一月清单:从落地到稳定输出", "excerpt": "SIM、短租、联合办公、社群与预算五步走。", "emoji": "✅", "author": "nomadro", "published_at": "2026-06-01", "read_time": 6, "tags": ["清单", "新手", "落地"]},
]
BLOG_CONTENT: dict[str, str] = {
@@ -217,6 +283,102 @@ A: 满足居住要求后可以申请永居或入籍。""",
3. 🏦 使用 Wise 等工具便于跨境汇款记录
4. 👨💼 收入超过一定金额建议聘请税务顾问
5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""",
+
+ "medellin-spring-city": """## 为什么麦德林?
+
+四季如春、生活成本可控,且贴近北美时区,适合接美加客户。Laureles / El Poblado 是游民常见落脚区。
+
+## 30 天试住建议
+
+1. 前 7 天住短租,实测网速与噪音
+2. 办本地 SIM,备份热点
+3. 参加 2 场线下 meetup,验证社区匹配度
+4. 确定联合办公月票再签长租
+
+## 预算参考(月)
+
+- 住宿:¥2,000–3,500
+- 餐饮:¥1,200–1,800
+- 交通:¥300–500
+- 办公:¥400–800""",
+
+ "tbilisi-tax-base": """## 远程签证 + 小企业税
+
+格鲁吉亚对远程工作者友好,许多人会评估本地公司与 1% 税路径(需自行核实最新政策)。
+
+## 生活感受
+
+旧城与 Vake 区咖啡馆多,适合深度工作;冬季偏冷,夏天舒服。
+
+## 注意
+
+- 银行开户与本地手机号流程可能变动
+- 税务方案务必咨询持证顾问
+- 把「试住 30 天」和「税务落地」分开决策""",
+
+ "dali-slow-create": """## 适合谁
+
+内容创作者、独立开发者、想放慢节奏但仍保持产出的人。
+
+## 实操建议
+
+- 合租优先选稳定 WiFi 与独立工作桌
+- 用固定作息对抗「度假感」
+- 主动加入本地创作者局,避免社交孤岛
+
+## 成本
+
+月生活费常可控制在 ¥3,500–5,000,视合租与出行频率而定。""",
+
+ "berlin-startup-nomad": """## 柏林为什么吸引游民
+
+开源、设计、创业活动密度高,共享办公选择多。
+
+## 落地要点
+
+- 提前规划居留/自由职业路径
+- 冬天日照短,准备补光与室内运动
+- 德语不是必须,但能明显提升生活便利
+
+## 每周节奏
+
+2 天联合办公 + 1 场 meetup + 周末短途,比纯咖啡馆更稳。""",
+
+ "dubai-hub-month": """## 适合当跳板
+
+国际航班密集,签证路径相对清晰,适合中东/欧亚中转月。
+
+## 炎热季节
+
+夏季高温,深度工作尽量安排在空调联合办公;户外活动放早晚。
+
+## 预算
+
+整体偏高,建议按「枢纽月」而非「低成本旅居」来规划。""",
+
+ "seoul-workation": """## 网速与便利
+
+首尔网速与城市便利度极强,适合需要高频协作的产品/工程角色。
+
+## 成本与节奏
+
+住宿与餐饮成本接近一线城市;用便利店与共享办公控制变量。
+
+## 签证
+
+关注 Workation / 相关短期远程路径的最新材料要求。""",
+
+ "first-month-checklist": """## 落地五步
+
+1. **连接**:落地办 SIM,测公寓与办公点网速
+2. **住处**:先短租 7–14 天,再决定长租
+3. **办公**:固定 1 个主点 + 2 个备份咖啡馆
+4. **社群**:报名 1 场 meetup,加 1 个本地群
+5. **预算**:用第一周真实账单校准月预算
+
+## 输出稳定
+
+每天保护 4 小时深度工作,旅行探索放在下午后段。""",
}
TICKER_MESSAGES = [
@@ -226,4 +388,10 @@ TICKER_MESSAGES = [
"🏝️ 巴厘岛 Co-working 今日 89% 满座",
"📶 清迈平均网速 95Mbps",
"🌅 东京远程工作者满意度 9.1",
+ "🌺 Sofia 在麦德林办完本地 SIM",
+ "🍷 Devon 把下一站设为第比利斯",
+ "🎨 Ken 报名柏林开源 Meetup",
+ "🏙️ Omar 从迪拜中转飞往里斯本",
+ "📷 Lina 在大理发布本周创作日志",
+ "🇰🇷 Yuki 实测首尔联合办公 220Mbps",
]
diff --git a/backend/app/data/platform_data.py b/backend/app/data/platform_data.py
index fec8adf..320c6be 100644
--- a/backend/app/data/platform_data.py
+++ b/backend/app/data/platform_data.py
@@ -37,6 +37,60 @@ SERVICES = [
"category": "职业",
"provider": "HR 志愿者",
},
+ {
+ "id": "coliving-match",
+ "title": "合租匹配咨询",
+ "description": "按城市、预算与作息匹配合租对象,附沟通话术模板。",
+ "emoji": "🏡",
+ "price": "¥149 起",
+ "category": "住宿",
+ "provider": "社区主理人",
+ },
+ {
+ "id": "insurance-pick",
+ "title": "国际保险选型",
+ "description": "按年龄、目的地与既往史对比 SafetyWing / Genki / 本地险。",
+ "emoji": "🏥",
+ "price": "¥99 起",
+ "category": "安全",
+ "provider": "保险顾问",
+ },
+ {
+ "id": "content-pack",
+ "title": "城市内容素材包",
+ "description": "可商用照片 + 短视频 B-roll,覆盖咖啡馆与联合办公场景。",
+ "emoji": "📷",
+ "price": "¥259 起",
+ "category": "内容",
+ "provider": "创作者网络",
+ },
+ {
+ "id": "plan-coach",
+ "title": "90 天旅居计划辅导",
+ "description": "一起排城市顺序、预算与签证节点,输出可执行计划表。",
+ "emoji": "🗓️",
+ "price": "¥499 起",
+ "category": "规划",
+ "provider": "nomadro 教练",
+ },
+ {
+ "id": "bank-setup",
+ "title": "跨境账户开户辅导",
+ "description": "Wise / Revolut / 本地银行材料清单与常见拒因规避。",
+ "emoji": "💳",
+ "price": "¥179 起",
+ "category": "财务",
+ "provider": "财务志愿者",
+ },
+ {
+ "id": "meetup-host-coach",
+ "title": "线下活动主办陪跑",
+ "description": "从选题、场地到 RSVP 转化,帮你办成第一场同城局。",
+ "emoji": "🎉",
+ "price": "¥399 起",
+ "category": "社区",
+ "provider": "活动主理人",
+ },
]
VIDEOS = [
@@ -79,6 +133,123 @@ VIDEOS = [
"tags": ["印尼", "办公"],
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
},
+ {
+ "id": "medellin-30d",
+ "slug": "medellin-30d",
+ "title": "麦德林 30 天试住 vlog",
+ "excerpt": "安全区、西语学习与北美时区接单节奏。",
+ "emoji": "🌺",
+ "duration": "16:20",
+ "guest": "Sofia",
+ "city": "麦德林",
+ "published_at": "2026-03-28",
+ "tags": ["哥伦比亚", "试住"],
+ "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ },
+ {
+ "id": "tbilisi-tax",
+ "slug": "tbilisi-tax",
+ "title": "第比利斯税务基地访谈",
+ "excerpt": "远程签证、开户与旧城生活成本。",
+ "emoji": "🍷",
+ "duration": "21:05",
+ "guest": "Devon",
+ "city": "第比利斯",
+ "published_at": "2026-04-05",
+ "tags": ["格鲁吉亚", "税务"],
+ "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ },
+ {
+ "id": "berlin-oss",
+ "slug": "berlin-oss",
+ "title": "柏林开源 Meetup 一日",
+ "excerpt": "创业办公空间与技术社交怎么玩。",
+ "emoji": "🎨",
+ "duration": "14:48",
+ "guest": "Ken",
+ "city": "柏林",
+ "published_at": "2026-04-22",
+ "tags": ["德国", "开源"],
+ "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ },
+ {
+ "id": "dali-create",
+ "slug": "dali-create",
+ "title": "大理慢创作一周",
+ "excerpt": "合租、网速与内容产出作息。",
+ "emoji": "📷",
+ "duration": "12:55",
+ "guest": "Lina",
+ "city": "大理",
+ "published_at": "2026-05-03",
+ "tags": ["大理", "内容"],
+ "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ },
+ {
+ "id": "dubai-hub",
+ "slug": "dubai-hub",
+ "title": "迪拜枢纽月怎么排",
+ "excerpt": "签证、炎热季节与航班中转策略。",
+ "emoji": "🏙️",
+ "duration": "13:40",
+ "guest": "Omar",
+ "city": "迪拜",
+ "published_at": "2026-05-18",
+ "tags": ["迪拜", "中东"],
+ "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ },
+ {
+ "id": "seoul-net",
+ "slug": "seoul-net",
+ "title": "首尔网速与深夜办公",
+ "excerpt": "江南联合办公实测与 Workation 签证提示。",
+ "emoji": "🇰🇷",
+ "duration": "11:22",
+ "guest": "Yuki",
+ "city": "首尔",
+ "published_at": "2026-06-02",
+ "tags": ["韩国", "网速"],
+ "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ },
+ {
+ "id": "mexico-food",
+ "slug": "mexico-food",
+ "title": "墨西哥城美食与办公平衡",
+ "excerpt": "Roma Norte 咖啡馆踩点与时区协作。",
+ "emoji": "🌮",
+ "duration": "15:10",
+ "guest": "Alex",
+ "city": "墨西哥城",
+ "published_at": "2026-06-15",
+ "tags": ["墨西哥", "美食"],
+ "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ },
+ {
+ "id": "first-month",
+ "slug": "first-month",
+ "title": "旅居第一月:从慌乱到稳定",
+ "excerpt": "SIM、短租、社群与预算五步法。",
+ "emoji": "✅",
+ "duration": "10:05",
+ "guest": "nomadro",
+ "city": "线上",
+ "published_at": "2026-06-28",
+ "tags": ["新手", "清单"],
+ "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ },
+ {
+ "id": "barcelona-visa",
+ "slug": "barcelona-visa",
+ "title": "巴塞罗那 Nomad Visa 体验",
+ "excerpt": "材料、审批周期与地中海生活成本。",
+ "emoji": "🏖️",
+ "duration": "19:30",
+ "guest": "Sara",
+ "city": "巴塞罗那",
+ "published_at": "2026-07-08",
+ "tags": ["西班牙", "签证"],
+ "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ },
]
MEMBER_MAP = [
@@ -90,6 +261,14 @@ MEMBER_MAP = [
{"name": "Ken", "city": "东京", "lat": 35.68, "lng": 139.69, "emoji": "🗼"},
{"name": "Lina", "city": "大理", "lat": 25.60, "lng": 100.27, "emoji": "🏔️"},
{"name": "Omar", "city": "迪拜", "lat": 25.20, "lng": 55.27, "emoji": "🏙️"},
+ {"name": "Devon", "city": "柏林", "lat": 52.52, "lng": 13.40, "emoji": "🖥️"},
+ {"name": "Sara", "city": "麦德林", "lat": 6.25, "lng": -75.56, "emoji": "🌺"},
+ {"name": "Nina", "city": "第比利斯", "lat": 41.72, "lng": 44.79, "emoji": "🍷"},
+ {"name": "Jin", "city": "首尔", "lat": 37.57, "lng": 126.98, "emoji": "🇰🇷"},
+ {"name": "Priya", "city": "里斯本", "lat": 38.74, "lng": -9.15, "emoji": "💻"},
+ {"name": "Tom", "city": "清迈", "lat": 18.80, "lng": 98.97, "emoji": "☕"},
+ {"name": "Mia", "city": "巴厘岛", "lat": -8.65, "lng": 115.14, "emoji": "🧘"},
+ {"name": "Leo", "city": "墨西哥城", "lat": 19.42, "lng": -99.16, "emoji": "🚀"},
]
CITY_RANKING = [
@@ -98,12 +277,23 @@ CITY_RANKING = [
{"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"},
{"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"},
{"slug": "barcelona", "name": "巴塞罗那", "score": 84, "nomads": "7k+"},
+ {"slug": "medellin", "name": "麦德林", "score": 88, "nomads": "6k+"},
+ {"slug": "tbilisi", "name": "第比利斯", "score": 86, "nomads": "5k+"},
+ {"slug": "berlin", "name": "柏林", "score": 83, "nomads": "7k+"},
+ {"slug": "dali", "name": "大理", "score": 82, "nomads": "4k+"},
+ {"slug": "seoul", "name": "首尔", "score": 81, "nomads": "3k+"},
+ {"slug": "tokyo", "name": "东京", "score": 80, "nomads": "4k+"},
+ {"slug": "dubai", "name": "迪拜", "score": 79, "nomads": "5k+"},
]
DAILY_TIPS = {
"1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"},
"2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"},
"3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"},
+ "4": {"title": "第四天:加入同城社群", "body": "报名一场 meetup,比独自逛咖啡馆更快建立支持网络。"},
+ "5": {"title": "第五天:备份通讯方案", "body": "本地 SIM + eSIM 热点双备份,开会前先压测上行带宽。"},
+ "6": {"title": "第六天:校准月预算", "body": "用第一周真实账单更新计划表,别只信网上平均值。"},
+ "7": {"title": "第七天:固定作息", "body": "保护每日 4 小时深度工作,探索放在下午后段。"},
}
WEATHER_CITIES = {
@@ -113,4 +303,10 @@ WEATHER_CITIES = {
"mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"},
"barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"},
"tokyo": {"lat": 35.68, "lng": 139.69, "name": "东京"},
+ "berlin": {"lat": 52.52, "lng": 13.40, "name": "柏林"},
+ "dali": {"lat": 25.60, "lng": 100.27, "name": "大理"},
+ "seoul": {"lat": 37.57, "lng": 126.98, "name": "首尔"},
+ "medellin": {"lat": 6.25, "lng": -75.56, "name": "麦德林"},
+ "dubai": {"lat": 25.20, "lng": 55.27, "name": "迪拜"},
+ "tbilisi": {"lat": 41.72, "lng": 44.79, "name": "第比利斯"},
}
diff --git a/backend/app/data/social_profiles.py b/backend/app/data/social_profiles.py
index 73a5dcc..f20eef9 100644
--- a/backend/app/data/social_profiles.py
+++ b/backend/app/data/social_profiles.py
@@ -4,7 +4,7 @@ MATCH_INTENTS = ("friends", "dating", "partner", "roommate", "cofounder", "explo
CANDIDATE_PROFILES = [
{
- "id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiang-mai",
+ "id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiangmai",
"gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。",
"photo": "🧳", "tags": ["远程", "咖啡", "徒步"],
"lookingFor": ["friends", "dating", "explore"],
@@ -22,7 +22,7 @@ CANDIDATE_PROFILES = [
"lookingFor": ["roommate", "friends"],
},
{
- "id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico-city",
+ "id": "p4", "userId": "u4", "name": "Alex", "location": "墨西哥城", "citySlug": "mexico",
"gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。",
"photo": "🚀", "tags": ["独立开发", "拉美"],
"lookingFor": ["cofounder", "friends", "dating"],
@@ -51,4 +51,52 @@ CANDIDATE_PROFILES = [
"photo": "🖥️", "tags": ["DevOps", "开源"],
"lookingFor": ["cofounder", "friends"],
},
+ {
+ "id": "p9", "userId": "u9", "name": "Sofia", "location": "麦德林", "citySlug": "medellin",
+ "gender": "女", "single": "是", "bio": "自由设计师,北美时区接单,爱西语咖啡局。",
+ "photo": "🌺", "tags": ["设计", "西语", "拉美"],
+ "lookingFor": ["friends", "dating", "explore"],
+ },
+ {
+ "id": "p10", "userId": "u10", "name": "Omar", "location": "迪拜", "citySlug": "dubai",
+ "gender": "男", "single": "是", "bio": "增长顾问,常把迪拜当欧亚跳板。",
+ "photo": "🏙️", "tags": ["增长", "中东", "航班"],
+ "lookingFor": ["friends", "partner", "explore"],
+ },
+ {
+ "id": "p11", "userId": "u11", "name": "Nina", "location": "第比利斯", "citySlug": "tbilisi",
+ "gender": "女", "single": "是", "bio": "独立顾问,研究税务友好基地与旧城生活。",
+ "photo": "🍷", "tags": ["税务", "顾问", "欧洲"],
+ "lookingFor": ["friends", "cofounder"],
+ },
+ {
+ "id": "p12", "userId": "u12", "name": "Jin", "location": "首尔", "citySlug": "seoul",
+ "gender": "男", "single": "是", "bio": "移动端工程师,沉迷深夜联合办公。",
+ "photo": "🇰🇷", "tags": ["移动端", "效率", "网速"],
+ "lookingFor": ["friends", "roommate", "explore"],
+ },
+ {
+ "id": "p13", "userId": "u13", "name": "Priya", "location": "里斯本", "citySlug": "lisbon",
+ "gender": "女", "single": "是", "bio": "产品设计师,喜欢海边徒步与博物馆。",
+ "photo": "🌊", "tags": ["产品设计", "徒步"],
+ "lookingFor": ["dating", "friends"],
+ },
+ {
+ "id": "p14", "userId": "u14", "name": "Tom", "location": "清迈", "citySlug": "chiangmai",
+ "gender": "男", "single": "是", "bio": "后端工程师,咖啡店常客,周末骑摩托。",
+ "photo": "☕", "tags": ["后端", "咖啡", "摩托"],
+ "lookingFor": ["friends", "cofounder", "explore"],
+ },
+ {
+ "id": "p15", "userId": "u15", "name": "Mia", "location": "巴厘岛", "citySlug": "bali",
+ "gender": "女", "single": "是", "bio": "瑜伽老师兼内容创作者,寻找同频合租。",
+ "photo": "🧘", "tags": ["瑜伽", "内容", "合租"],
+ "lookingFor": ["roommate", "friends", "partner"],
+ },
+ {
+ "id": "p16", "userId": "u16", "name": "Leo", "location": "墨西哥城", "citySlug": "mexico",
+ "gender": "男", "single": "是", "bio": "创业者,做拉美远程协作工具。",
+ "photo": "🌮", "tags": ["创业", "拉美", "SaaS"],
+ "lookingFor": ["cofounder", "friends", "dating"],
+ },
]
diff --git a/backend/app/routers/social.py b/backend/app/routers/social.py
index ea01048..f9b31c3 100644
--- a/backend/app/routers/social.py
+++ b/backend/app/routers/social.py
@@ -25,14 +25,36 @@ def _token_user(authorization: str | None) -> dict:
async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)):
user = _token_user(authorization)
profile = social_store.join_member(user["id"], user["name"], body.model_dump())
- return {"success": True, "profile": profile}
+ return {"success": True, "profile": _profile_payload(profile, user["id"])}
@router.get("/join/status")
async def join_status(authorization: str | None = Header(None)):
user = _token_user(authorization)
- profile = social_store._profiles.get(user["id"])
- return {"complete": bool(profile), "profile": profile}
+ profile = social_store.get_public_profile(user["id"])
+ return {"complete": bool(profile), "profile": _profile_payload(profile, user["id"]) if profile else None}
+
+
+def _profile_payload(profile: dict | None, user_id: str) -> dict:
+ if not profile:
+ return {}
+ return {
+ "id": profile.get("id") or f"user-{user_id}",
+ "userId": profile.get("userId") or user_id,
+ "name": profile.get("name") or "游民",
+ "location": profile.get("location") or "",
+ "citySlug": profile.get("citySlug") or "",
+ "gender": profile.get("gender") or "",
+ "single": profile.get("single") or "",
+ "bio": profile.get("bio") or "",
+ "photo": profile.get("photo") or "🧑💻",
+ "tags": profile.get("tags") or [],
+ "lookingFor": profile.get("lookingFor") or profile.get("looking_for") or [],
+ }
+
+
+def _has_match_profile(user_id: str) -> bool:
+ return social_store.get_public_profile(user_id) is not None
@router.get("/matches/candidates", response_model=list[MatchProfile])
@@ -45,8 +67,13 @@ async def match_candidates(
exclude_swiped: bool = Query(True),
):
user = _token_user(authorization)
- if user["id"] not in social_store._profiles:
- raise HTTPException(400, "请先完成加入资料")
+ if not _has_match_profile(user["id"]):
+ # Soft-join so dating is usable after login without a stuck gate
+ social_store.join_member(
+ user["id"],
+ user.get("name") or "游民",
+ {"city": "全球", "lookingFor": [intent, "explore"], "bio": "刚加入 nomadro 匹配"},
+ )
return [MatchProfile(**p) for p in social_store.list_candidates(
user["id"], intent=intent, city=city, gender=gender, single=single, exclude_swiped=exclude_swiped
)]
@@ -136,5 +163,5 @@ async def post_message(conv_id: str, body: SendMessageRequest, authorization: st
async def vip_check(authorization: str | None = Header(None)):
user = _token_user(authorization)
vip = social_store.is_vip(user["id"])
- exp = (social_store._memberships.get(user["id"]) or {}).get("expires_at", 0)
+ exp = social_store.membership_expires_at(user["id"])
return VipStatus(vip=vip, expires_at=exp)
diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py
index 8530249..4cc0ea6 100644
--- a/backend/app/services/auth.py
+++ b/backend/app/services/auth.py
@@ -400,13 +400,111 @@ def find_user_public_by_email(email: str) -> dict | None:
return _user_profile(user) if user else None
+# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
+_DEMO_SEED_USERS = [
+ ("demo@nomadro.com", "demo123", "演示用户", "🧑💻"),
+ ("xiaolin@nomadro.com", "demo123", "小林", "🧳"),
+ ("marco@nomadro.com", "demo123", "Marco", "💻"),
+ ("yuki@nomadro.com", "demo123", "Yuki", "🎨"),
+ ("alex@nomadro.com", "demo123", "Alex", "🚀"),
+ ("sara@nomadro.com", "demo123", "Sara", "☕"),
+ ("ken@nomadro.com", "demo123", "Ken", "📊"),
+ ("lina@nomadro.com", "demo123", "Lina", "📷"),
+ ("devon@nomadro.com", "demo123", "Devon", "🖥️"),
+ ("sofia@nomadro.com", "demo123", "Sofia", "🌺"),
+ ("omar@nomadro.com", "demo123", "Omar", "🏙️"),
+ ("nina@nomadro.com", "demo123", "Nina", "🍷"),
+]
+
+_DEMO_FAVORITES = {
+ "演示用户": ["chiangmai", "lisbon", "bali"],
+ "小林": ["chiangmai", "dali", "bali"],
+ "Marco": ["lisbon", "barcelona", "berlin"],
+ "Yuki": ["bali", "seoul", "tokyo"],
+ "Alex": ["mexico", "medellin", "lisbon"],
+ "Sara": ["barcelona", "lisbon", "medellin"],
+ "Ken": ["tokyo", "seoul", "berlin"],
+ "Lina": ["dali", "chiangmai", "bali"],
+ "Devon": ["berlin", "tbilisi", "lisbon"],
+ "Sofia": ["medellin", "mexico", "barcelona"],
+ "Omar": ["dubai", "lisbon", "tbilisi"],
+ "Nina": ["tbilisi", "berlin", "dubai"],
+}
+
+_DEMO_PLANS = {
+ "演示用户": {
+ "items": [
+ {"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 2, "note": "先稳住节奏"},
+ {"slug": "bali", "name": "巴厘岛", "country": "印尼", "emoji": "🏝️", "cost": 4500, "months": 1, "note": ""},
+ {"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 2, "note": "评估 D7"},
+ ],
+ "meta": {"title": "演示旅居计划", "startMonth": "2026-10", "monthlyBudget": 8000, "checklist": {"visa": True, "sim": True}},
+ "updated_at": 1,
+ },
+ "小林": {
+ "items": [
+ {"slug": "chiangmai", "name": "清迈", "country": "泰国", "emoji": "🏔️", "cost": 3800, "months": 3, "note": ""},
+ {"slug": "dali", "name": "大理", "country": "中国", "emoji": "🏔️", "cost": 4200, "months": 1, "note": "慢创作"},
+ ],
+ "meta": {"title": "东南亚慢旅", "startMonth": "2026-09", "monthlyBudget": 4500, "checklist": {}},
+ "updated_at": 1,
+ },
+ "Marco": {
+ "items": [
+ {"slug": "lisbon", "name": "里斯本", "country": "葡萄牙", "emoji": "🌊", "cost": 9000, "months": 4, "note": ""},
+ {"slug": "barcelona", "name": "巴塞罗那", "country": "西班牙", "emoji": "🏖️", "cost": 10500, "months": 1, "note": ""},
+ ],
+ "meta": {"title": "欧洲基地", "startMonth": "2026-08", "monthlyBudget": 12000, "checklist": {"visa": True}},
+ "updated_at": 1,
+ },
+ "Sofia": {
+ "items": [
+ {"slug": "medellin", "name": "麦德林", "country": "哥伦比亚", "emoji": "🌺", "cost": 5200, "months": 2, "note": ""},
+ {"slug": "mexico", "name": "墨西哥城", "country": "墨西哥", "emoji": "🌃", "cost": 6500, "months": 1, "note": ""},
+ ],
+ "meta": {"title": "拉美春城", "startMonth": "2026-10", "monthlyBudget": 5500, "checklist": {}},
+ "updated_at": 1,
+ },
+ "Devon": {
+ "items": [
+ {"slug": "tbilisi", "name": "第比利斯", "country": "格鲁吉亚", "emoji": "🍷", "cost": 4800, "months": 3, "note": ""},
+ {"slug": "berlin", "name": "柏林", "country": "德国", "emoji": "🎨", "cost": 9800, "months": 1, "note": ""},
+ ],
+ "meta": {"title": "税务友好试住", "startMonth": "2026-11", "monthlyBudget": 6000, "checklist": {}},
+ "updated_at": 1,
+ },
+}
+
+
+def _seed_demo_users() -> None:
+ for email, password, name, avatar in _DEMO_SEED_USERS:
+ if email not in _users:
+ created = register_user(email, password, name)
+ if not created:
+ continue
+ uid = _users[email]["id"]
+ _users[email]["avatar"] = avatar
+ favs = _DEMO_FAVORITES.get(name)
+ if favs and not (_favorites.get(uid) or []):
+ _favorites[uid] = list(favs)
+ plan = _DEMO_PLANS.get(name)
+ if plan and not ((_plans.get(uid) or {}).get("items") or []):
+ _plans[uid] = plan
+ _persist_json()
+
+
# Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
if not use_pb():
if not _restore_json():
login_demo()
elif "demo@nomadro.com" not in _users:
login_demo()
+ _seed_demo_users()
else:
_migrate_json_to_pb()
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]:
+ if not _get_account_by_email(email):
+ register_user(email, password, name)
diff --git a/backend/app/services/community_store.py b/backend/app/services/community_store.py
index e18204f..755a4cc 100644
--- a/backend/app/services/community_store.py
+++ b/backend/app/services/community_store.py
@@ -577,18 +577,21 @@ def _jload() -> None:
_j.update(json.loads(_JSON_PATH.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError):
pass
- if not _j.get("seeded"):
+ needs_seed = not _j.get("seeded") or not (
+ (_j.get("meetups") or []) and (_j.get("discussions") or []) and (_j.get("gigs") or [])
+ )
+ if needs_seed:
_j.update({
"meetups": deepcopy(community_data.MEETUPS),
"discussions": deepcopy(community_data.DISCUSSIONS),
"replies": deepcopy(community_data.DISCUSSION_REPLIES),
"gigs": deepcopy(getattr(community_data, "GIGS", [])),
- "discussion_likes": {},
- "rsvps": {},
- "gig_apps": [],
- "notifications": {},
- "feedback": [],
- "views": {},
+ "discussion_likes": _j.get("discussion_likes") or {},
+ "rsvps": _j.get("rsvps") or {},
+ "gig_apps": _j.get("gig_apps") or [],
+ "notifications": _j.get("notifications") or {},
+ "feedback": _j.get("feedback") or [],
+ "views": _j.get("views") or {},
"seeded": True,
})
_jsave()
diff --git a/backend/app/services/social_store.py b/backend/app/services/social_store.py
index 8938aac..c7efc4b 100644
--- a/backend/app/services/social_store.py
+++ b/backend/app/services/social_store.py
@@ -73,6 +73,14 @@ def is_vip(user_id: str) -> bool:
return bool(m and (m.get("expires_at") or 0) > int(time.time()))
+def membership_expires_at(user_id: str) -> int:
+ if use_pb():
+ row = safe_first("memberships", filter=f"userId={q(user_id)}")
+ return int((row or {}).get("expiresAt") or 0)
+ _jload()
+ return int(((_j.get("memberships") or {}).get(user_id) or {}).get("expires_at") or 0)
+
+
def ensure_membership(user_id: str, days: int = 365) -> None:
exp = int(time.time()) + days * 86400
if use_pb():
diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx
index cf9f6de..255636a 100644
--- a/frontend/src/app/changelog/page.tsx
+++ b/frontend/src/app/changelog/page.tsx
@@ -8,6 +8,205 @@ export const metadata: Metadata = {
};
const LOGS = [
+ {
+ date: "2026-09-04",
+ tag: "匹配页修复",
+ items: [
+ "修复 /dating 因后端错误检查不存在的 _profiles 而一直卡在「一键加入匹配」",
+ "登录后自动创建匹配资料并进入滑卡;VIP 状态读取同步修正",
+ ],
+ },
+ {
+ date: "2026-09-04",
+ tag: "业务数据充实",
+ items: [
+ "目的地增至 12 城,签证/博客/活动/讨论/零活/岗位/视频/服务/会员地图全面扩容(均 10+)",
+ "种子 12 个演示账号(密码 demo123)含收藏与旅居计划;修复社区空库不重灌的问题",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "城市工具决策出口",
+ items: [
+ "命运转盘、网速、住宿、机票、周末灵感、落地清单、城市笔记结果区可写入计划并深链活动",
+ "落地清单 Meetup 任务直达同城活动;最近浏览补同城活动与计划入口;匹配器关闭清除 #matcher",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "工具转化 · 登录 CTA · 快捷键",
+ items: [
+ "启动金目标 / 签证停留倒计时可写入计划并深链城市;下一站加入计划区分已在行程",
+ "登录成功 toast 可直达回流页;悬浮反馈支持 Esc;站内 M 键打开首页智能匹配(/#matcher)",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "登录回流 · 匹配器体验",
+ items: [
+ "登录页识别数字游民/视频/岗位/AI/下一站/地图等回流目标,不再误标成个人中心",
+ "跑道计算器套用城市后可写入计划;首页智能匹配预加载 + 加载壳,支持 Esc 关闭",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "计算器 · 视频 · 分享回流",
+ items: [
+ "费用/对比/首月成本工具结果可写入计划并深链城市详情与同城活动",
+ "视频详情按城市接活动环;岗位收藏 toast、分享复制 toast、私信空态补下一跳",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "报告 · 工具 · 发布回流",
+ items: [
+ "数据报告:快照 toast 去下一站;排名/会员城市深链计划与同城活动",
+ "工具运行页结果旁增加计划/下一站/活动出口;afterTools 补活动环",
+ "发布赏金/发起活动/报名/加入匹配成功 toast 接回数字游民、社区或匹配;课程末课接赏金与活动",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "对比 · 收藏 · 反馈回流",
+ items: [
+ "对比写入计划 / 收藏城市 / 个人中心收藏入计划:toast 可打开计划或我的收藏",
+ "反馈、投稿、悬浮反馈成功后可去社区;视频稍后再看空态与收藏 toast 回流",
+ "通知页登录门补活动与社区出口;旅居助手写入计划 toast 可打开计划中心",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "地图 · 匹配 · 会员资料",
+ items: [
+ "智能匹配 / 下一站 / 世界地图写入计划后 toast 可打开计划中心;地图面板增加同城活动",
+ "会员资料与游民地图城市深链同城活动;加入资料 / 服务咨询成功 toast 接回匹配或社区",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "Toast CTA · Connect/Grow 出口",
+ items: [
+ "Toast 支持行动链接(写入计划/报名/发帖后可直达下一站);城市页加入计划可打开计划中心",
+ "社区帖文识别城市名深链同城活动;匹配/私信空态与登录门增加活动与社区出口",
+ "Grow 环:赏金→计划/活动,数字游民学院用 afterDigital 接回探索",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "Explore ↔ Connect 漏斗",
+ items: [
+ "城市页/对比/计划/下一站深链到同城活动;线上活动直达直播间",
+ "对比胜出城市一键活动 + 计划;NextStop / Live / 计划 RingNext 按情境分流",
+ "报名后展示同城社交建议;首页紧凑「接着探索」摘要(无足迹时隐藏)",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "工具箱 · AI 草稿",
+ items: [
+ "工具箱目录与分类标签支持中英切换;全局搜索工具结果随语言匹配",
+ "页脚品牌标语与下一站预算单位接入 i18n;旅居助手提问草稿本机保存",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "智能匹配 · 首页 chrome",
+ items: [
+ "首页智能匹配向导步骤/选项/结果与写入计划 toast 全面中英文化",
+ "快捷键帮助、游民小贴士、Cookie 条、新手引导、FAQ 空态接入 i18n",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "全局搜索 · 反馈 · 草稿",
+ items: [
+ "全局搜索:触发器/占位/结果类型与路径快捷入口全面中英文化",
+ "悬浮反馈、分享/收藏按钮、地图加计划 toast、登录校验接入 i18n",
+ "私信与服务咨询支持本机草稿;反馈 FAB 与反馈页共用草稿键",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "个人中心 · 计划导出",
+ items: [
+ "个人中心:成就徽章、统计、收藏/行程空态、快捷入口与头像提示全面中英文化",
+ "旅居计划:Markdown/纯文本导出与签证字段标签随语言切换",
+ "讨论回复支持本机草稿;匹配加入表单默认城市改为空白占位",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "城市深度 · 草稿 · 计划",
+ items: [
+ "城市详情深度区块(指南/画像/成本/评价等)全面中英文化;快照模板随语言切换",
+ "发起活动与意见反馈表单支持本机草稿自动保存",
+ "下一站匹配理由、对比快照、计划空态/排序按钮、私信线程 RingNext 补齐 i18n",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "下一站 · 社区 · 城市",
+ items: [
+ "下一站决策:预算/网速/气候/标签偏好本机记住;标签与气候中英展示",
+ "社区分类筛选与发帖分类中英标签;讨论回复提示与空态接入 i18n",
+ "城市详情:加入计划/分享/超预算提示与操作按钮全面中英文化",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "匹配 · 岗位 · 会员",
+ items: [
+ "远程岗位支持搜索/标签筛选与本机收藏",
+ "匹配意图、配额、空态与私信列表全面中英文化",
+ "会员资料表单草稿自动保存",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "赏金 · 服务 · 报告",
+ items: [
+ "赏金可收藏、申请说明本机草稿;发布赏金/内容投稿自动保存草稿",
+ "服务咨询与数据报告空态文案中英齐全;报告支持复制快照",
+ "赏金/投稿/服务校验提示全面接入 i18n",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "摘要 · 稍后再看 · 草稿",
+ items: [
+ "视频「稍后再看」本机收藏;列表与详情可一键存取",
+ "个人中心「今日游民摘要」汇总计划就绪、报名、视频与最近 AI 提问",
+ "社区发帖草稿自动保存;通知/反馈/地图/视频空态文案中英齐全",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "日历 · 对比 · 搜索",
+ items: [
+ "活动报名可导出 ICS(整表/单场);个人中心 RSVP 一键导入日历",
+ "城市对比:最近对比记录可恢复、复制对比快照",
+ "全局搜索空态展示最近浏览城市;通知设置补齐 RingNext 与文案",
+ ],
+ },
+ {
+ date: "2026-09-03",
+ tag: "继续探索与个人中心",
+ items: [
+ "最近浏览城市:详情页自动记录,首页与个人中心可续看、一键对比",
+ "个人中心展示已报名活动(含直播入口)与环导航",
+ "旅居助手:本地提问历史、中英文案与对比/写入计划闭环",
+ "城市详情「复制快照」;VIP 支付成功页补齐下一步",
+ ],
+ },
+ {
+ date: "2026-09-01",
+ tag: "环导航与文案",
+ items: [
+ "工具详情、发起活动、会员定价、学院课时、喜欢列表、成员主页补齐 RingNext",
+ "直播间门禁/布局、工具箱空态与搜索、活动登录提示接入中英文案",
+ "私信空态与发送失败提示、学院 VIP 提示与每日指南 i18n",
+ ],
+ },
{
date: "2026-08-30",
tag: "决策与发现闭环",
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index e9f851e..44b495e 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -3290,10 +3290,26 @@ img { max-width: 100%; display: block; }
transform: translateY(20px);
opacity: 0;
animation: toastIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
+ pointer-events: auto;
}
.toast-stack .toast.show { opacity: 1; transform: translateY(0); }
+.toast-action {
+ margin-left: 0.35rem;
+ padding: 0.25rem 0.55rem;
+ border-radius: var(--radius-sm, 6px);
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--accent, #4ecdc4);
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-decoration: none;
+ white-space: nowrap;
+}
+.toast-action:hover {
+ background: rgba(255, 255, 255, 0.14);
+}
+
@keyframes toastIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
@@ -5886,7 +5902,15 @@ img { max-width: 100%; display: block; }
}
.packing-emoji { font-size: 1.2rem; }
-.packing-label { font-size: 0.9rem; }
+.packing-label { font-size: 0.9rem; flex: 1; }
+.arrival-meetup-link {
+ margin-left: auto;
+ color: var(--accent, #4ecdc4);
+ text-decoration: none;
+ font-weight: 600;
+ padding: 0 0.25rem;
+}
+.arrival-meetup-link:hover { text-decoration: underline; }
/* ===== Season Guide ===== */
.season-card {
@@ -10325,6 +10349,10 @@ img { max-width: 100%; display: block; }
font-size: 2rem;
}
+.tool-runner-exits {
+ margin: 1.25rem 0 0.5rem;
+}
+
.tool-loading {
padding: 48px;
text-align: center;
@@ -10605,6 +10633,15 @@ img { max-width: 100%; display: block; }
border-radius: var(--radius-md);
}
+.member-loc-link {
+ color: inherit;
+ text-decoration: none;
+}
+.member-loc-link:hover {
+ color: var(--accent, #4ecdc4);
+ text-decoration: underline;
+}
+
.weather-strip { margin-top: 32px; }
.weather-cards { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
.weather-card {
@@ -10613,6 +10650,16 @@ img { max-width: 100%; display: block; }
border: var(--border-glass);
border-radius: var(--radius-md);
min-width: 100px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ text-decoration: none;
+ color: inherit;
+ transition: border-color 0.15s ease, transform 0.15s ease;
+}
+a.weather-card:hover {
+ border-color: rgba(78, 205, 196, 0.35);
+ transform: translateY(-1px);
}
.ai-reply {
@@ -11225,3 +11272,506 @@ img { max-width: 100%; display: block; }
color: var(--accent-3, #4ecdc4);
text-decoration: underline;
}
+
+/* ===== Recently viewed + AI history + profile RSVPs ===== */
+.recently-viewed {
+ padding: 2.5rem 0 1rem;
+}
+.recently-viewed.compact {
+ padding: 1.25rem 0;
+ margin-top: 1.5rem;
+}
+.recently-viewed-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+}
+.recently-viewed-head h2 {
+ margin: 0.25rem 0 0;
+ font-size: 1.35rem;
+}
+.recently-viewed.compact .recently-viewed-head h2 {
+ font-size: 1.15rem;
+}
+.recently-viewed-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+.recently-viewed-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0.75rem;
+}
+.recently-viewed-card {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ padding: 0.85rem 1rem;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-md);
+ text-decoration: none;
+ color: inherit;
+ transition: transform 0.2s ease, border-color 0.2s ease;
+}
+.recently-viewed-card:hover {
+ transform: translateY(-2px);
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
+}
+.recently-viewed-emoji {
+ font-size: 1.6rem;
+ line-height: 1;
+}
+.recently-viewed-card strong {
+ display: block;
+ font-size: 0.95rem;
+}
+.recently-viewed-country {
+ color: var(--text-muted);
+ font-weight: 500;
+}
+.recently-viewed-card p {
+ margin: 0.15rem 0 0;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+}
+.recently-viewed-compare {
+ margin-top: 1rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.ai-history {
+ margin-top: 2rem;
+}
+.ai-history-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+.ai-history-head h2 {
+ margin: 0;
+ font-size: 1.1rem;
+}
+.ai-history-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+.ai-history-item {
+ width: 100%;
+ text-align: left;
+ padding: 0.85rem 1rem;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-md);
+ color: inherit;
+ cursor: pointer;
+ transition: border-color 0.2s ease;
+}
+.ai-history-item:hover {
+ border-color: color-mix(in srgb, var(--accent) 35%, transparent);
+}
+.ai-history-item strong {
+ display: block;
+ margin-bottom: 0.25rem;
+}
+.ai-history-item span {
+ font-size: 0.82rem;
+ color: var(--text-muted);
+}
+
+.profile-rsvps {
+ margin-top: 2rem;
+}
+.profile-rsvp-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+.profile-rsvp-card {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ padding: 0.85rem 1rem;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-md);
+}
+.profile-rsvp-card span {
+ font-size: 1.5rem;
+}
+.profile-rsvp-card strong {
+ display: block;
+}
+.profile-rsvp-card p {
+ margin: 0.15rem 0 0;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+.profile-rsvp-card .btn {
+ margin-left: auto;
+}
+
+.compare-history {
+ margin: 0 0 1.5rem;
+ padding: 1rem 1.15rem;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-lg);
+}
+.compare-history-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+.compare-history-head h2 {
+ margin: 0;
+ font-size: 1rem;
+}
+.compare-history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+.compare-history-chip {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ width: 100%;
+ text-align: left;
+ padding: 0.7rem 0.9rem;
+ background: transparent;
+ border: var(--border-glass);
+ border-radius: var(--radius-md);
+ color: inherit;
+ cursor: pointer;
+}
+.compare-history-chip:hover {
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
+}
+.compare-history-chip span {
+ font-size: 0.88rem;
+}
+.compare-history-chip strong {
+ font-size: 0.78rem;
+ color: var(--accent-3, #4ecdc4);
+ white-space: nowrap;
+}
+.search-recent-inline {
+ margin-bottom: 1rem;
+}
+.profile-section-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+/* ===== Digest + watch later ===== */
+.nomad-digest {
+ margin: 1.75rem 0;
+ padding: 1.25rem 1.35rem;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-lg);
+}
+.nomad-digest-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+}
+.nomad-digest-head h2 {
+ margin: 0.2rem 0 0;
+ font-size: 1.2rem;
+}
+.nomad-digest-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.75rem;
+}
+.nomad-digest-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.9rem 1rem;
+ background: color-mix(in srgb, var(--bg-body) 55%, transparent);
+ border: var(--border-glass);
+ border-radius: var(--radius-md);
+ text-decoration: none;
+ color: inherit;
+ transition: transform 0.2s ease, border-color 0.2s ease;
+}
+.nomad-digest-card:hover {
+ transform: translateY(-2px);
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
+}
+.nomad-digest-card strong {
+ font-size: 1.25rem;
+}
+.nomad-digest-card span {
+ font-size: 0.85rem;
+}
+.nomad-digest-card small {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.nomad-digest-recent {
+ margin-top: 1rem;
+}
+.nomad-digest-recent > span {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+.nomad-digest-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+.nomad-digest.compact {
+ margin: 1rem 0 0.25rem;
+ padding: 1rem 1.15rem;
+}
+.nomad-digest.compact h2 {
+ font-size: 1.05rem;
+}
+.nomad-digest.compact .section-desc {
+ margin-bottom: 0;
+ font-size: 0.9rem;
+}
+
+.plan-stop-links {
+ margin: 0.35rem 0 0.55rem;
+}
+.plan-city-meetup {
+ font-size: 0.85rem;
+ color: var(--accent);
+ text-decoration: none;
+}
+.plan-city-meetup:hover {
+ text-decoration: underline;
+}
+
+.meetup-peers {
+ margin-top: 0.85rem;
+ padding-top: 0.75rem;
+ border-top: var(--border-glass);
+}
+.meetup-peers-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ font-size: 0.9rem;
+ color: var(--text-muted);
+}
+.meetup-peers-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+.meetup-peers-list li {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+}
+.meetup-peers-list a {
+ text-decoration: none;
+ color: inherit;
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ min-width: 0;
+}
+.meetup-peers-list strong {
+ font-size: 0.92rem;
+}
+.meetup-peers-list span {
+ font-size: 0.78rem;
+ color: var(--text-muted);
+}
+
+.live-exit {
+ padding: 1.25rem 0 2rem;
+}
+
+.watch-later-panel {
+ margin-bottom: 1.5rem;
+ padding: 1rem 1.15rem;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-lg);
+}
+.watch-later-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+.watch-later-head h2 {
+ margin: 0;
+ font-size: 1.05rem;
+}
+.watch-later-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+}
+.watch-later-chip {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 0.65rem;
+ align-items: center;
+ padding: 0.65rem 0.85rem;
+ border: var(--border-glass);
+ border-radius: var(--radius-md);
+ text-decoration: none;
+ color: inherit;
+}
+.watch-later-chip:hover {
+ border-color: color-mix(in srgb, var(--accent) 35%, transparent);
+}
+.watch-later-chip small {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+}
+.video-card-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+.video-card-wrap > a {
+ text-decoration: none;
+ color: inherit;
+}
+.video-card-wrap > .btn {
+ align-self: flex-start;
+}
+
+.saved-gigs-panel {
+ margin-bottom: 1.5rem;
+ padding: 1rem 1.15rem;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-lg);
+}
+.saved-gigs-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+.saved-gigs-head h2 {
+ margin: 0;
+ font-size: 1.05rem;
+}
+.saved-gigs-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+.saved-gig-chip {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ padding: 0.55rem 0.8rem;
+ border: var(--border-glass);
+ border-radius: var(--radius-md);
+ text-decoration: none;
+ color: inherit;
+ max-width: 220px;
+}
+.saved-gig-chip:hover {
+ border-color: color-mix(in srgb, var(--accent) 35%, transparent);
+}
+.saved-gig-chip small {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+}
+.gig-card-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 0.75rem;
+ margin-bottom: 0.5rem;
+}
+.gig-card-top h3 {
+ margin: 0;
+}
+
+.saved-jobs-panel {
+ margin-bottom: 1.5rem;
+ padding: 1rem 1.15rem;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-lg);
+}
+.saved-jobs-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+.saved-jobs-head h2 {
+ margin: 0;
+ font-size: 1.05rem;
+}
+.saved-jobs-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+.saved-job-chip {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ padding: 0.55rem 0.8rem;
+ border: var(--border-glass);
+ border-radius: var(--radius-md);
+ text-decoration: none;
+ color: inherit;
+ max-width: 240px;
+}
+.saved-job-chip:hover {
+ border-color: color-mix(in srgb, var(--accent) 35%, transparent);
+}
+.saved-job-chip small {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+}
+.jobs-toolbar {
+ margin-bottom: 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx
index a2bc974..c3ec760 100644
--- a/frontend/src/app/login/page.tsx
+++ b/frontend/src/app/login/page.tsx
@@ -27,7 +27,7 @@ function LoginForm() {
const [loading, setLoading] = useState(false);
const goAfterAuth = (msg: string) => {
- toast(msg);
+ toast(msg, "success", { href: meta.path, label: meta.label });
setTimeout(() => { router.replace(meta.path); }, 400);
};
@@ -36,11 +36,11 @@ function LoginForm() {
setError("");
if (mode === "register") {
if (!name.trim() || name.trim().length < 2) {
- setError("昵称至少 2 个字符");
+ setError(t.login.nameMin);
return;
}
if (password.length < 6) {
- setError("密码至少 6 位");
+ setError(t.login.pwdMin);
return;
}
}
@@ -122,19 +122,19 @@ function LoginForm() {
minLength={6}
autoComplete={mode === "login" ? "current-password" : "new-password"}
/>
-
{trip.length === 0 ? (
🗺️
-
还没有规划行程
-
去规划 →
+
{t.profile.tripEmpty}
+
{t.profile.goPlan}
) : (
{(meta?.startMonth || meta?.monthlyBudget) && (
- {meta?.startMonth && 出发 {meta.startMonth}}
+ {meta?.startMonth && (
+ {t.profile.startLabel.replace("{month}", meta.startMonth)}
+ )}
{meta && meta.monthlyBudget > 0 && (
- 月预算 ¥{meta.monthlyBudget.toLocaleString()}
- {avgMonth > 0 && ` · 均月 ¥${avgMonth.toLocaleString()}`}
+ {t.profile.budgetLabel.replace("{budget}", meta.monthlyBudget.toLocaleString())}
+ {avgMonth > 0 &&
+ t.profile.avgMonthLabel.replace("{avg}", avgMonth.toLocaleString())}
)}
- 就绪 {checked}/{MOVE_CHECKLIST.length}
+
+ {t.profile.readyCount
+ .replace("{checked}", String(checked))
+ .replace("{total}", String(MOVE_CHECKLIST.length))}
+
)}
- {trip.map((t, i) => (
-
+ {trip.map((item, i) => (
+
{i + 1}
-
{t.emoji}
+
{item.emoji}
- {t.name}, {t.country}
- {t.months} 个月 · ¥{(t.cost * t.months).toLocaleString()}
+ {item.name}, {item.country}
+
+ {t.profile.monthsCost
+ .replace("{months}", String(item.months))
+ .replace("{cost}", (item.cost * item.months).toLocaleString())}
+
))}
- 总计 {totalMonths} 个月 ·
+ {t.profile.totalLine}{" "}
+
+ {totalMonths} {t.profile.monthsUnit}
+ {" "}
+ ·
¥{totalCost.toLocaleString()}
{trip.length >= 2 && (
t.slug).slice(0, 4).join(",")}`}
+ href={`/compare?cities=${trip.map((item) => item.slug).slice(0, 4).join(",")}`}
className="btn btn-ghost btn-sm"
>
- 对比行程城市
+ {t.profile.compareTrip}
)}
- 继续编辑 →
+ {t.profile.keepEditing}
)}
- 🏅 游民成就
+ {t.profile.achievements}
{badges.map((b) => (
@@ -254,20 +306,20 @@ export default function ProfilePage() {
-
❤️ 我的收藏
+ {t.profile.myFavs}
{favsNotInTrip.length > 0 && (
- 收藏写入计划({favsNotInTrip.length})
+ {t.profile.favsToPlan.replace("{n}", String(favsNotInTrip.length))}
)}
{loading ? (
- 加载中...
+ {t.profile.loading}
) : favoriteDests.length === 0 ? (
🌍
-
还没有收藏目的地
-
去探索 →
+
{t.profile.favsEmpty}
+
{t.profile.goExplore}
) : (
@@ -276,7 +328,10 @@ export default function ProfilePage() {
{d.emoji}
{d.name}, {d.country}
-
💰 ¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}
+
+ 💰 ¥{d.cost.toLocaleString()}
+ {t.profile.perMonth} · ⭐ {d.rating}
+
@@ -285,22 +340,83 @@ export default function ProfilePage() {
)}
+
+
+
🎉 {t.profile.myRsvps}
+
+ {rsvps.length > 0 && (
+ {
+ const ics = buildMeetupIcs(rsvps, "nomadro RSVPs");
+ if (!ics) {
+ toast(t.meetups.exportCalFail, "info");
+ return;
+ }
+ downloadMeetupIcs("nomadro-rsvps.ics", ics);
+ toast(t.meetups.exportCalDone);
+ }}
+ >
+ 📅 {t.profile.exportRsvps}
+
+ )}
+ {t.nav.meetups}
+
+
+ {rsvps.length === 0 ? (
+
+
{t.profile.rsvpEmpty}
+
{t.profile.goMeetups}
+
+ ) : (
+
+ {rsvps.map((m) => (
+
+
{m.emoji}
+
+
{m.title}
+
+ {m.city} · {m.date} {m.time}
+
+
+ {(m.mode === "online" || m.mode === "hybrid") && (
+
+ {t.profile.openLive}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
- 🚀 快捷入口
+ {t.profile.quickLinks}
- 🌍 浏览目的地
- 🗓️ 旅居计划
- ⚖️ 城市对比
- 🧭 智能下一站
- 🎉 游民活动
- 💕 游民匹配
- ✉️ 私信
- 🔔 通知
- {vip ? "✨ VIP 会员" : "✨ 开通 VIP"}
- 🎓 游民学院
- 🛠️ 工具箱
+ {t.profile.qDest}
+ {t.profile.qPlan}
+ {t.profile.qCompare}
+ {t.profile.qNext}
+ {t.profile.qMeetups}
+ {t.profile.qDating}
+ {t.profile.qChat}
+ {t.profile.qNotif}
+ {vip ? t.profile.qVipMember : t.profile.qVip}
+ {t.profile.qDigital}
+ {t.profile.qAi}
+ {t.profile.qTools}
+
+
);
diff --git a/frontend/src/app/tools/page.tsx b/frontend/src/app/tools/page.tsx
index e8a73ce..a7f8e73 100644
--- a/frontend/src/app/tools/page.tsx
+++ b/frontend/src/app/tools/page.tsx
@@ -2,8 +2,8 @@
import { useMemo, useState } from "react";
import Link from "next/link";
-import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
import { useI18n } from "@/lib/i18n";
+import { localizedToolCategories, localizedToolLinks } from "@/lib/toolCopy";
import SiteShell from "@/components/SiteShell";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
@@ -12,25 +12,28 @@ const FEATURED = ["weekend", "day-plan", "bill-split", "mood", "scam-alerts", "d
/** Utility drawer — not a product mega-menu. */
export default function ToolsHubPage() {
- const { t } = useI18n();
+ const { t, locale } = useI18n();
const rings = useRingSteps();
const [filter, setFilter] = useState("all");
const [q, setQ] = useState("");
const [showAll, setShowAll] = useState(false);
+ const tools = useMemo(() => localizedToolLinks(locale), [locale]);
+ const categories = useMemo(() => localizedToolCategories(locale), [locale]);
+
const featured = useMemo(
- () => FEATURED.map((id) => TOOL_LINKS.find((tool) => tool.id === id)).filter((tool): tool is NonNullable
=> !!tool),
- []
+ () => FEATURED.map((id) => tools.find((tool) => tool.id === id)).filter((tool): tool is NonNullable => !!tool),
+ [tools]
);
const filtered = useMemo(() => {
- let list = filter === "all" ? TOOL_LINKS : TOOL_LINKS.filter((tool) => tool.category === filter);
+ let list = filter === "all" ? tools : tools.filter((tool) => tool.category === filter);
if (q.trim()) {
const s = q.toLowerCase();
list = list.filter((tool) => tool.title.toLowerCase().includes(s) || tool.desc.toLowerCase().includes(s));
}
return list;
- }, [filter, q]);
+ }, [filter, q, tools]);
const visible = showAll || q.trim() || filter !== "all" ? filtered : filtered.slice(0, 12);
@@ -52,7 +55,7 @@ export default function ToolsHubPage() {
✨ {t.tools.tag}
- {featured.length} · 先用这几个
+ {featured.length} · {t.tools.featuredHint}
{featured.map((tool) => (
@@ -70,12 +73,12 @@ export default function ToolsHubPage() {
setQ(e.target.value)}
/>
- {TOOL_CATEGORIES.map((c) => (
+ {categories.map((c) => (
{tool.emoji}
{tool.title}
{tool.desc}
- 打开 →
+ {t.tools.open}
))}
{!showAll && !q.trim() && filter === "all" && filtered.length > 12 && (
setShowAll(true)}>
- 展开全部 {filtered.length} 个工具
+ {t.tools.showAll.replace("{n}", String(filtered.length))}
)}
{filtered.length === 0 && (
-
没有匹配的工具,试试其他关键词
+
{t.tools.empty}
- 清除筛选
+ {t.tools.clearFilter}
- 去下一站
+ {t.tools.goNextStop}
diff --git a/frontend/src/components/AiAssistantClient.tsx b/frontend/src/components/AiAssistantClient.tsx
index 3b1fe7a..5f3d876 100644
--- a/frontend/src/components/AiAssistantClient.tsx
+++ b/frontend/src/components/AiAssistantClient.tsx
@@ -1,21 +1,19 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
+import { clearAiHistory, loadAiHistory, pushAiHistory, type AiHistoryItem } from "@/lib/aiHistory";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
import type { Destination } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
-const SUGGESTIONS = [
- "预算 6000,想找网速好的东南亚城市",
- "适合第一次远程办公的城市?",
- "清迈和巴厘岛怎么选?",
-];
+const DRAFT_KEY = "nomadro-ai-compose-draft";
interface Props {
destinations: Destination[];
@@ -30,11 +28,38 @@ export default function AiAssistantClient({ destinations }: Props) {
const [reply, setReply] = useState("");
const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]);
const [loading, setLoading] = useState(false);
+ const [history, setHistory] = useState
([]);
+ const [draftReady, setDraftReady] = useState(false);
+
+ const tips = [t.ai.tip1, t.ai.tip2, t.ai.tip3];
+
+ useEffect(() => {
+ setHistory(loadAiHistory());
+ const draft = loadDraft<{ text?: string }>(DRAFT_KEY);
+ if (draft?.text?.trim()) {
+ setMessage(draft.text);
+ toast(t.ai.draftRestored, "info");
+ }
+ setDraftReady(true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!draftReady) return;
+ const id = window.setTimeout(() => {
+ if (!message.trim()) {
+ clearDraft(DRAFT_KEY);
+ return;
+ }
+ saveDraft(DRAFT_KEY, { text: message });
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [message, draftReady]);
const ask = async (text?: string) => {
const q = (text ?? message).trim();
if (!q) {
- toast("先写一个问题", "info");
+ toast(t.ai.needQuestion, "info");
return;
}
setMessage(q);
@@ -42,7 +67,10 @@ export default function AiAssistantClient({ destinations }: Props) {
try {
const res = await api.askAssistant(q);
setReply(res.reply);
- setCities(res.cities || []);
+ const nextCities = res.cities || [];
+ setCities(nextCities);
+ setHistory(pushAiHistory({ question: q, reply: res.reply, cities: nextCities }));
+ clearDraft(DRAFT_KEY);
} catch {
setReply(t.ai.fail);
setCities([]);
@@ -51,20 +79,27 @@ export default function AiAssistantClient({ destinations }: Props) {
}
};
+ const restore = (item: AiHistoryItem) => {
+ setMessage(item.question);
+ setReply(item.reply);
+ setCities(item.cities);
+ };
+
const addToPlan = () => {
const dests = cities
.map((c) => destinations.find((d) => d.slug === c.slug))
.filter((d): d is Destination => Boolean(d));
+ const planAction = { href: "/plan", label: t.strip.openPlan };
if (dests.length === 0) {
- toast("推荐城市暂无完整数据,请从详情页加入计划", "info");
+ toast(t.ai.noCityData, "info", planAction);
router.push("/plan");
return;
}
const { added, skipped } = mergeDestinationsIntoTrip(dests, 1);
if (added === 0) {
- toast(skipped ? t.compare.alreadyInPlan : "未能写入计划", "info");
+ toast(skipped ? t.compare.alreadyInPlan : t.ai.writeFail, "info", planAction);
} else {
- toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}`);
+ toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}`, "success", planAction);
}
router.push("/plan");
};
@@ -83,7 +118,7 @@ export default function AiAssistantClient({ destinations }: Props) {
{t.ai.subtitle}
- {SUGGESTIONS.map((s) => (
+ {tips.map((s) => (
void ask(s)}>
{s}
@@ -100,9 +135,9 @@ export default function AiAssistantClient({ destinations }: Props) {
}}
/>
void ask()}>
- {loading ? "思考中…" : t.ai.ask}
+ {loading ? t.ai.thinking : t.ai.ask}
-
Ctrl/⌘ + Enter 发送
+
{t.ai.sendHint} · {t.ai.draftHint}
{reply && (
@@ -116,17 +151,60 @@ export default function AiAssistantClient({ destinations }: Props) {
{t.ai.nextStop}
-
- 城市对比
+ c.slug)
+ .join(",")}`
+ : "/compare"
+ }
+ className="btn btn-sm"
+ >
+ {t.ai.compareCities}
{cities.length > 0 && (
- 写入计划
+ {t.ai.writePlan}
)}
)}
+
+ {history.length > 0 && (
+
+
+
{t.ai.history}
+ {
+ clearAiHistory();
+ setHistory([]);
+ }}
+ >
+ {t.ai.clearHistory}
+
+
+
+ {history.map((h) => (
+ -
+ restore(h)}>
+ {h.question}
+
+ {h.cities.length
+ ? h.cities.map((c) => `${c.emoji} ${c.name}`).join(" · ")
+ : h.reply.slice(0, 72)}
+
+
+
+ ))}
+
+
+ )}
+
diff --git a/frontend/src/components/ArrivalChecklist.tsx b/frontend/src/components/ArrivalChecklist.tsx
index fc15a44..e221abc 100644
--- a/frontend/src/components/ArrivalChecklist.tsx
+++ b/frontend/src/components/ArrivalChecklist.tsx
@@ -2,6 +2,9 @@
import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
+import ToolCityExits from "@/components/ToolCityExits";
+import { meetupCityHref } from "@/lib/meetupLinks";
+import Link from "next/link";
interface Task {
id: string;
@@ -110,6 +113,11 @@ export default function ArrivalChecklist({ destinations }: Props) {
{checked[t.id] ? "✓" : ""}
{t.emoji}
{t.label}
+ {t.id === "meetup" && dest && (
+
+ →
+
+ )}
))}
@@ -117,6 +125,7 @@ export default function ArrivalChecklist({ destinations }: Props) {
))}
+ {dest && }
diff --git a/frontend/src/components/Calculator.tsx b/frontend/src/components/Calculator.tsx
index 3f92b60..5e1f271 100644
--- a/frontend/src/components/Calculator.tsx
+++ b/frontend/src/components/Calculator.tsx
@@ -1,18 +1,27 @@
"use client";
import { useState } from "react";
+import Link from "next/link";
import { api } from "@/lib/api";
+import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
+import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
+import { meetupCityHref } from "@/lib/meetupLinks";
import type { CostResult, Destination } from "@/lib/types";
interface Props { destinations: Destination[] }
export default function Calculator({ destinations }: Props) {
+ const { t } = useI18n();
+ const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || "");
const [housing, setHousing] = useState("mid");
const [months, setMonths] = useState(3);
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
+ const selected = destinations.find((d) => d.slug === slug);
+
const calculate = async () => {
setLoading(true);
try {
@@ -25,6 +34,21 @@ export default function Calculator({ destinations }: Props) {
}
};
+ const addToPlan = () => {
+ if (!selected) return;
+ const { added } = mergeDestinationsIntoTrip([selected], months);
+ toast(
+ added
+ ? t.common.addedToPlanMonths
+ .replace("{emoji}", selected.emoji)
+ .replace("{city}", selected.name)
+ .replace("{months}", String(months))
+ : t.common.alreadyInPlan,
+ added ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
+ );
+ };
+
return (
@@ -85,6 +109,19 @@ export default function Calculator({ destinations }: Props) {
月均¥{result.per_month.toLocaleString()}
总计¥{result.total.toLocaleString()}
+ {selected && (
+
+
+ 🗓️ {t.nav.plan}
+
+
+ {t.common.detail}
+
+
+ {t.common.cityMeetups}
+
+
+ )}
) : (
diff --git a/frontend/src/components/ChatClient.tsx b/frontend/src/components/ChatClient.tsx
index 0f2dcfb..9610e9a 100644
--- a/frontend/src/components/ChatClient.tsx
+++ b/frontend/src/components/ChatClient.tsx
@@ -46,7 +46,11 @@ export default function ChatClient() {
{t.chat.loginTitle}
{t.chat.loginDesc}
-
{t.nav.login}
+
+ {t.nav.login}
+ {t.dating.meetAtEvents}
+ {t.chat.goDating}
+
@@ -64,7 +68,7 @@ export default function ChatClient() {
{t.chat.title}
- {loading &&
加载对话…
}
+ {loading &&
{t.chat.loadingList}
}
{!loading &&
items.map((c) => (
-
{c.peer?.name || "游民"}
+
{c.peer?.name || t.chat.peerFallback}
{c.lastMessagePreview || t.chat.noMessages}
{c.unreadCount > 0 &&
{c.unreadCount}}
@@ -83,9 +87,17 @@ export default function ChatClient() {
{!loading && items.length === 0 && (
{t.chat.empty}
-
- 去匹配
-
+
+
+ {t.chat.goDating}
+
+
+ {t.dating.meetAtEvents}
+
+
+ {t.nav.community}
+
+
)}
diff --git a/frontend/src/components/ChatThreadClient.tsx b/frontend/src/components/ChatThreadClient.tsx
index 7c4cab3..00d0675 100644
--- a/frontend/src/components/ChatThreadClient.tsx
+++ b/frontend/src/components/ChatThreadClient.tsx
@@ -7,16 +7,22 @@ import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import type { ChatMessage } from "@/lib/types";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
export default function ChatThreadClient({ convId }: { convId: string }) {
const { token, user } = useAuth();
const { t } = useI18n();
const { toast } = useToast();
+ const rings = useRingSteps();
const [messages, setMessages] = useState([]);
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(Boolean(token));
+ const [draftReady, setDraftReady] = useState(false);
const bottomRef = useRef(null);
+ const draftKey = `nomadro-chat-draft:${convId}`;
const load = () => {
if (!token) return;
@@ -38,6 +44,28 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, convId]);
+ useEffect(() => {
+ const draft = loadDraft<{ text?: string }>(draftKey);
+ if (draft?.text?.trim()) {
+ setText(draft.text);
+ toast(t.chat.draftRestored, "info");
+ }
+ setDraftReady(true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [convId]);
+
+ useEffect(() => {
+ if (!draftReady || !token) return;
+ const id = window.setTimeout(() => {
+ if (!text.trim()) {
+ clearDraft(draftKey);
+ return;
+ }
+ saveDraft(draftKey, { text });
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [text, draftReady, token, draftKey]);
+
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
@@ -48,9 +76,10 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
try {
await api.sendMessage(token, convId, text.trim());
setText("");
+ clearDraft(draftKey);
load();
} catch {
- toast("发送失败,请稍后重试", "error");
+ toast(t.chat.sendFail, "error");
} finally {
setSending(false);
}
@@ -63,9 +92,17 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
{t.chat.loginTitle}
{t.chat.loginDesc}
-
- {t.nav.login}
-
+
+
+ {t.nav.login}
+
+
+ {t.dating.meetAtEvents}
+
+
+ {t.nav.dating}
+
+
@@ -77,11 +114,23 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
- {loading && messages.length === 0 &&
加载消息…
}
+ {loading && messages.length === 0 &&
{t.chat.loadingMessages}
}
{!loading && messages.length === 0 && (
-
打个招呼开始对话吧
+
+
{t.chat.sayHi}
+
+
+ {t.dating.meetAtEvents}
+
+
+ {t.nav.dating}
+
+
+
)}
{messages.map((m) => (
@@ -102,6 +151,7 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
{sending ? "…" : t.chat.send}
+
);
diff --git a/frontend/src/components/CityNotes.tsx b/frontend/src/components/CityNotes.tsx
index 0497600..d6f3aaf 100644
--- a/frontend/src/components/CityNotes.tsx
+++ b/frontend/src/components/CityNotes.tsx
@@ -1,8 +1,10 @@
"use client";
import { useEffect, useState } from "react";
+import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import type { Destination } from "@/lib/types";
+import ToolCityExits from "@/components/ToolCityExits";
const STORAGE_KEY = "nomadro-city-notes";
@@ -11,6 +13,7 @@ interface Props {
}
export default function CityNotes({ destinations }: Props) {
+ const { t } = useI18n();
const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
const [notes, setNotes] = useState>({});
@@ -22,7 +25,9 @@ export default function CityNotes({ destinations }: Props) {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) setNotes(JSON.parse(raw));
- } catch { /* ignore */ }
+ } catch {
+ /* ignore */
+ }
setReady(true);
}, []);
@@ -35,7 +40,10 @@ export default function CityNotes({ destinations }: Props) {
const next = { ...notes, [slug]: text };
setNotes(next);
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
- toast(`${dest?.emoji || ""} 笔记已保存`);
+ toast(`${dest?.emoji || ""} 笔记已保存`, "success", {
+ href: "/plan",
+ label: t.strip.openPlan,
+ });
};
const clear = () => {
@@ -66,7 +74,9 @@ export default function CityNotes({ destinations }: Props) {
className={`notes-city${slug === d.slug ? " active" : ""}`}
onClick={() => setSlug(d.slug)}
>
- {d.emoji} {d.name}
+
+ {d.emoji} {d.name}
+
{notes[d.slug]?.trim() && }
))}
@@ -74,7 +84,9 @@ export default function CityNotes({ destinations }: Props) {
-
{dest?.emoji} {dest?.name}
+
+ {dest?.emoji} {dest?.name}
+
diff --git a/frontend/src/components/CommunityHubClient.tsx b/frontend/src/components/CommunityHubClient.tsx
index 52e0210..5cc13b0 100644
--- a/frontend/src/components/CommunityHubClient.tsx
+++ b/frontend/src/components/CommunityHubClient.tsx
@@ -7,7 +7,7 @@ import type { Discussion, DiscussionDetail } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
-const CATEGORIES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"];
+const CATEGORY_VALUES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"] as const;
interface Props {
discussions: Discussion[];
@@ -20,6 +20,18 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
const [category, setCategory] = useState("全部");
const [q, setQ] = useState("");
+ const catLabel = (value: string) => {
+ const map: Record = {
+ 全部: t.community.catAll,
+ 签证: t.community.catVisa,
+ 远程工作: t.community.catRemote,
+ 住宿: t.community.catHousing,
+ 安全: t.community.catSafety,
+ 社区: t.community.catCommunity,
+ };
+ return map[value] || value;
+ };
+
const filtered = useMemo(() => {
let list = discussions;
if (category !== "全部") list = list.filter((d) => d.category === category);
@@ -84,14 +96,14 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
onChange={(e) => setQ(e.target.value)}
/>
- {CATEGORIES.map((c) => (
+ {CATEGORY_VALUES.map((c) => (
setCategory(c)}
>
- {c}
+ {catLabel(c)}
))}
@@ -106,7 +118,7 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
{d.title}
- {d.author} · {d.category} · {d.created_at}
+ {d.author} · {catLabel(d.category)} · {d.created_at}
@@ -135,7 +147,7 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
setQ("");
}}
>
- {t.meetups.all}
+ {t.community.catAll}
)}
@@ -145,7 +157,7 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
)}
-
+
);
diff --git a/frontend/src/components/CommunityNewClient.tsx b/frontend/src/components/CommunityNewClient.tsx
index 8f5ce6d..596384e 100644
--- a/frontend/src/components/CommunityNewClient.tsx
+++ b/frontend/src/components/CommunityNewClient.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
@@ -9,6 +9,13 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import {
+ clearCommunityDraft,
+ loadCommunityDraft,
+ saveCommunityDraft,
+} from "@/lib/communityDraft";
+import { inferMeetupCity, meetupCityHref } from "@/lib/meetupLinks";
+import type { Destination } from "@/lib/types";
export default function CommunityNewClient() {
const { token } = useAuth();
@@ -20,6 +27,38 @@ export default function CommunityNewClient() {
const [content, setContent] = useState("");
const [category, setCategory] = useState("社区");
const [busy, setBusy] = useState(false);
+ const [draftReady, setDraftReady] = useState(false);
+ const [cities, setCities] = useState([]);
+
+ useEffect(() => {
+ const draft = loadCommunityDraft();
+ if (draft) {
+ setTitle(draft.title || "");
+ setContent(draft.content || "");
+ setCategory(draft.category || "社区");
+ if (draft.title || draft.content) {
+ toast(t.community.draftRestored, "info");
+ }
+ }
+ setDraftReady(true);
+ api
+ .getDestinations()
+ .then(setCities)
+ .catch(() => setCities([]));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!draftReady || !token) return;
+ const id = window.setTimeout(() => {
+ if (!title.trim() && !content.trim()) {
+ clearCommunityDraft();
+ return;
+ }
+ saveCommunityDraft({ title, content, category });
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [title, content, category, draftReady, token]);
if (!token) {
return (
@@ -29,8 +68,12 @@ export default function CommunityNewClient() {
{t.community.newTitle}
{t.community.loginDesc}
-
{t.nav.login}
+
+ {t.nav.login}
+ {t.dating.meetAtEvents}
+
+
);
@@ -44,7 +87,7 @@ export default function CommunityNewClient() {
const tTitle = title.trim();
const body = content.trim();
if (!tTitle || body.length < 10) {
- toast("标题必填,正文至少 10 字", "info");
+ toast(t.community.draftNeed, "info");
return;
}
setBusy(true);
@@ -56,7 +99,15 @@ export default function CommunityNewClient() {
excerpt,
category,
});
- toast(t.community.createOk, "success");
+ clearCommunityDraft();
+ const city = inferMeetupCity(
+ `${tTitle} ${body} ${category}`,
+ cities.map((d) => d.name)
+ );
+ toast(t.community.createOk, "success", {
+ href: city ? meetupCityHref(city) : "/meetups",
+ label: city ? t.common.cityMeetups : t.dating.meetAtEvents,
+ });
const id = res.discussion?.id;
router.push(id ? `/community/${id}` : "/community");
} catch {
@@ -72,23 +123,34 @@ export default function CommunityNewClient() {
{t.community.newTitle}
+
{t.community.draftHint}
setTitle(e.target.value)} maxLength={120} />
-
+
);
diff --git a/frontend/src/components/CompareClient.tsx b/frontend/src/components/CompareClient.tsx
index 7bad7f6..ae0901a 100644
--- a/frontend/src/components/CompareClient.tsx
+++ b/frontend/src/components/CompareClient.tsx
@@ -11,11 +11,18 @@ import {
overallScore,
parseCompareSlugs,
} from "@/lib/compareScore";
+import {
+ clearCompareHistory,
+ loadCompareHistory,
+ pushCompareHistory,
+ type ComparePreset,
+} from "@/lib/compareHistory";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useI18n } from "@/lib/i18n";
import type { Destination } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { meetupCityHref } from "@/lib/meetupLinks";
interface Props {
destinations: Destination[];
@@ -28,6 +35,11 @@ export default function CompareClient({ destinations }: Props) {
const { t } = useI18n();
const rings = useRingSteps();
const [picked, setPicked] = useState([]);
+ const [history, setHistory] = useState([]);
+
+ useEffect(() => {
+ setHistory(loadCompareHistory());
+ }, []);
useEffect(() => {
const fromUrl = parseCompareSlugs(searchParams.get("cities"));
@@ -46,6 +58,16 @@ export default function CompareClient({ destinations }: Props) {
const scores = selected.map(overallScore);
const winnerIdx = scores.length ? scores.indexOf(Math.max(...scores)) : -1;
+ useEffect(() => {
+ if (selected.length < 2) return;
+ setHistory(
+ pushCompareHistory(
+ selected.map((d) => d.slug),
+ selected.map((d) => `${d.emoji} ${d.name}`)
+ )
+ );
+ }, [selected]);
+
const syncUrl = (slugs: string[]) => {
const href = compareUrl(slugs);
router.replace(href, { scroll: false });
@@ -67,12 +89,17 @@ export default function CompareClient({ destinations }: Props) {
const addAll = () => {
if (selected.length < 1) return;
const { added, skipped } = mergeDestinationsIntoTrip(selected, 1);
+ const planAction = { href: "/plan", label: t.strip.openPlan };
if (added === 0) {
- toast(skipped ? t.compare.alreadyInPlan : t.compare.alreadyOne, "info");
+ toast(skipped ? t.compare.alreadyInPlan : t.compare.alreadyOne, "info", planAction);
router.push("/plan");
return;
}
- toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}${skipped ? `(${t.compare.skipped} ${skipped})` : ""}`);
+ toast(
+ `${t.compare.addedN} ${added} ${t.compare.citiesUnit}${skipped ? `(${t.compare.skipped} ${skipped})` : ""}`,
+ "success",
+ planAction
+ );
router.push("/plan");
};
@@ -80,7 +107,11 @@ export default function CompareClient({ destinations }: Props) {
if (winnerIdx < 0) return;
const d = selected[winnerIdx];
const { added } = mergeDestinationsIntoTrip([d], 1);
- toast(added ? `${d.emoji} ${d.name} ${t.compare.wrotePlan}` : t.compare.alreadyOne, added ? "success" : "info");
+ toast(
+ added ? `${d.emoji} ${d.name} ${t.compare.wrotePlan}` : t.compare.alreadyOne,
+ added ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
+ );
if (added) router.push("/plan");
};
@@ -94,6 +125,30 @@ export default function CompareClient({ destinations }: Props) {
toast(t.compare.linkCopied);
};
+ const copySnapshot = async () => {
+ if (selected.length < 2) {
+ toast(t.compare.needShare, "info");
+ return;
+ }
+ const lines = [
+ t.compare.snapshotTitle,
+ ...selected.map((d, i) => {
+ const mark = i === winnerIdx ? "👑 " : "";
+ const scoreText = t.compare.scoreLabel.replace("{score}", String(scores[i]));
+ return `${mark}${d.emoji} ${d.name}, ${d.country} · ¥${d.cost.toLocaleString()}/mo · ${d.speed}Mbps · ⭐${d.rating} · ${scoreText}`;
+ }),
+ `${typeof window !== "undefined" ? window.location.origin : ""}${compareUrl(selected.map((d) => d.slug))}`,
+ ];
+ await navigator.clipboard.writeText(lines.join("\n"));
+ toast(t.compare.snapshotOk);
+ };
+
+ const restorePreset = (preset: ComparePreset) => {
+ const next = preset.slugs.filter((s) => destinations.some((d) => d.slug === s)).slice(0, 4);
+ setPicked(next);
+ syncUrl(next);
+ };
+
return (
@@ -109,17 +164,49 @@ export default function CompareClient({ destinations }: Props) {
{t.compare.subtitle}
+ {history.length > 0 && (
+
+
+
{t.compare.history}
+ {
+ clearCompareHistory();
+ setHistory([]);
+ }}
+ >
+ {t.compare.clearHistory}
+
+
+
+ {history.map((h) => (
+ restorePreset(h)}
+ >
+ {h.labels.join(" vs ")}
+ {t.compare.restore}
+
+ ))}
+
+
+ )}
+
{t.compare.pick}({picked.length}/4)
{ setPicked([]); syncUrl([]); }}>{t.compare.clear}
{t.compare.copyLink}
+ void copySnapshot()}>{t.compare.snapshot}
{destinations.length === 0 ? (
-
目的地数据加载中或暂不可用
+
{t.compare.loadingDest}
) : (
destinations.map((d) => {
const on = picked.includes(d.slug);
@@ -215,12 +302,26 @@ export default function CompareClient({ destinations }: Props) {
+ void copySnapshot()}>
+ {t.compare.snapshot}
+
{t.compare.addWinner}
{t.compare.addAll}
+
+ {t.compare.openPlan}
+
+ {winnerIdx >= 0 && selected[winnerIdx] && (
+
+ {t.compare.winnerMeetups.replace("{city}", selected[winnerIdx].name)}
+
+ )}
@@ -236,7 +337,12 @@ export default function CompareClient({ destinations }: Props) {
>
)}
-
+
= 0 && selected[winnerIdx] ? selected[winnerIdx].name : undefined
+ )}
+ />
);
diff --git a/frontend/src/components/ContentSubmitClient.tsx b/frontend/src/components/ContentSubmitClient.tsx
index 522c366..08966c2 100644
--- a/frontend/src/components/ContentSubmitClient.tsx
+++ b/frontend/src/components/ContentSubmitClient.tsx
@@ -1,15 +1,19 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
-
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+
+const DRAFT_KEY = "nomadro-submit-draft";
+
+type SubmitDraft = { title: string; url: string; notes: string; contentType: string };
export default function ContentSubmitClient() {
const { token } = useAuth();
@@ -23,6 +27,30 @@ export default function ContentSubmitClient() {
const [contentType, setContentType] = useState("article");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ const draft = loadDraft(DRAFT_KEY);
+ if (draft) {
+ setTitle(draft.title || "");
+ setUrl(draft.url || "");
+ setNotes(draft.notes || "");
+ setContentType(draft.contentType || "article");
+ }
+ setReady(true);
+ }, []);
+
+ useEffect(() => {
+ if (!ready) return;
+ const id = window.setTimeout(() => {
+ if (!title && !url && !notes) {
+ clearDraft(DRAFT_KEY);
+ return;
+ }
+ saveDraft(DRAFT_KEY, { title, url, notes, contentType });
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [title, url, notes, contentType, ready]);
const submit = async () => {
if (!token) {
@@ -30,11 +58,11 @@ export default function ContentSubmitClient() {
return;
}
if (!title.trim() || title.trim().length < 4) {
- toast("请填写更完整的标题", "info");
+ toast(t.submit.needTitle, "info");
return;
}
if ((contentType === "article" || contentType === "video") && !/^https?:\/\/.+/.test(url.trim())) {
- toast("文章/视频请填写有效链接(http/https)", "info");
+ toast(t.submit.needUrl, "info");
return;
}
setBusy(true);
@@ -49,7 +77,11 @@ export default function ContentSubmitClient() {
},
token
);
- toast(t.submit.ok, "success");
+ clearDraft(DRAFT_KEY);
+ toast(t.submit.ok, "success", {
+ href: "/community",
+ label: t.submit.goCommunity,
+ });
setTitle("");
setUrl("");
setNotes("");
@@ -71,17 +103,18 @@ export default function ContentSubmitClient() {
{t.submit.tag}
{t.submit.title}
{t.submit.subtitle}
+ {!done && {t.submit.draftHint}
}
{done ? (
-
投稿已收到
-
审核通过后会出现在博客或视频区
+
{t.submit.thanksTitle}
+
{t.submit.thanksDesc}
setDone(false)}>
- 再投一篇
+ {t.submit.writeAgain}
- 去社区
+ {t.submit.goCommunity}
@@ -112,11 +145,11 @@ export default function ContentSubmitClient() {
maxLength={2000}
/>
void submit()}>
- {busy ? "提交中…" : t.submit.send}
+ {busy ? t.submit.submitting : t.submit.send}
{!token && (
- {t.nav.login} 后可提交
+ {t.nav.login} · {t.gigs.loginFirst}
)}
diff --git a/frontend/src/components/CookieConsent.tsx b/frontend/src/components/CookieConsent.tsx
index 21c6b0a..52bb085 100644
--- a/frontend/src/components/CookieConsent.tsx
+++ b/frontend/src/components/CookieConsent.tsx
@@ -2,8 +2,10 @@
import { useEffect, useState } from "react";
import Link from "next/link";
+import { useI18n } from "@/lib/i18n";
export default function CookieConsent() {
+ const { t } = useI18n();
const [visible, setVisible] = useState(false);
useEffect(() => {
@@ -21,19 +23,16 @@ export default function CookieConsent() {
if (!visible) return null;
return (
-
-
-
🍪
-
- 我们使用本地存储保存主题、行程与登录会话,不做广告追踪。
- 隐私政策
- {" · "}
- Cookie 说明
-
-
- 知道了 ✓
-
-
+
+
+ {t.cookies.body}{" "}
+ {t.cookies.privacy}
+ {" · "}
+ {t.cookies.cookies}
+
+
+ {t.cookies.ok}
+
);
}
diff --git a/frontend/src/components/CostCompare.tsx b/frontend/src/components/CostCompare.tsx
index 1603732..cd1cc17 100644
--- a/frontend/src/components/CostCompare.tsx
+++ b/frontend/src/components/CostCompare.tsx
@@ -1,7 +1,12 @@
"use client";
import { useMemo, useState } from "react";
+import Link from "next/link";
import type { Destination } from "@/lib/types";
+import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
+import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
+import { meetupCityHref } from "@/lib/meetupLinks";
const HOME_CITIES = [
{ name: "上海", cost: 12000, emoji: "🏙️" },
@@ -16,6 +21,8 @@ interface Props {
}
export default function CostCompare({ destinations }: Props) {
+ const { t } = useI18n();
+ const { toast } = useToast();
const [homeIdx, setHomeIdx] = useState(0);
const [destSlug, setDestSlug] = useState(destinations[0]?.slug || "");
@@ -34,6 +41,15 @@ export default function CostCompare({ destinations }: Props) {
const maxCost = Math.max(home.cost, dest.cost);
+ const addToPlan = () => {
+ const { added } = mergeDestinationsIntoTrip([dest], 1);
+ toast(
+ added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
+ added ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
+ );
+ };
+
return (
@@ -112,6 +128,17 @@ export default function CostCompare({ destinations }: Props) {
>
)}
+
+
+ 🗓️ {t.nav.plan}
+
+
+ {t.common.detail}
+
+
+ {t.common.cityMeetups}
+
+
diff --git a/frontend/src/components/DatingClient.tsx b/frontend/src/components/DatingClient.tsx
index 4567162..59ffd9d 100644
--- a/frontend/src/components/DatingClient.tsx
+++ b/frontend/src/components/DatingClient.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
@@ -11,17 +11,6 @@ import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
-const INTENTS: { key: MatchIntent; label: string; emoji: string }[] = [
- { key: "friends", label: "交朋友", emoji: "🤝" },
- { key: "dating", label: "约会", emoji: "💕" },
- { key: "partner", label: "伴侣", emoji: "💑" },
- { key: "roommate", label: "合租", emoji: "🏠" },
- { key: "cofounder", label: "联创", emoji: "🚀" },
- { key: "explore", label: "探索", emoji: "🌍" },
-];
-
-const PRIMARY_INTENTS = INTENTS.slice(0, 3);
-
export default function DatingClient() {
const { user, token } = useAuth();
const router = useRouter();
@@ -34,44 +23,89 @@ export default function DatingClient() {
const [quota, setQuota] = useState(null);
const [loading, setLoading] = useState(true);
const [joined, setJoined] = useState(false);
+ const [error, setError] = useState("");
const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null);
- const load = useCallback(async () => {
+ const intents = useMemo(
+ () =>
+ [
+ { key: "friends" as const, label: t.dating.intentFriends, emoji: "🤝" },
+ { key: "dating" as const, label: t.dating.intentDating, emoji: "💕" },
+ { key: "partner" as const, label: t.dating.intentPartner, emoji: "💑" },
+ { key: "roommate" as const, label: t.dating.intentRoommate, emoji: "🏠" },
+ { key: "cofounder" as const, label: t.dating.intentCofounder, emoji: "🚀" },
+ { key: "explore" as const, label: t.dating.intentExplore, emoji: "🌍" },
+ ],
+ [t]
+ );
+ const primary = intents.slice(0, 3);
+
+ const ensureJoined = useCallback(async () => {
if (!token) return;
- setLoading(true);
try {
+ await api.socialJoin(token, {
+ city: t.dating.defaultCity,
+ lookingFor: [intent, "explore"],
+ bio: t.dating.defaultBio,
+ });
+ } catch {
+ // Profile may already exist — continue to candidates.
+ }
+ }, [token, intent, t]);
+
+ const load = useCallback(async () => {
+ if (!token) {
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setError("");
+ try {
+ await ensureJoined();
const [candidates, q] = await Promise.all([
api.getMatchCandidates(token, { intent }),
api.getMatchQuota(token),
]);
- setDeck(candidates);
+ setDeck(Array.isArray(candidates) ? candidates : []);
setQuota(q);
setJoined(true);
- } catch {
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ setError(msg);
setJoined(false);
setDeck([]);
} finally {
setLoading(false);
}
- }, [token, intent]);
+ }, [token, intent, ensureJoined]);
- useEffect(() => { void load(); }, [load]);
+ useEffect(() => {
+ void load();
+ }, [load]);
const quickJoin = async () => {
if (!token) {
router.push("/login?next=/dating");
return;
}
+ setLoading(true);
+ setError("");
try {
await api.socialJoin(token, {
- city: "全球",
+ city: t.dating.defaultCity,
lookingFor: [intent, "explore"],
- bio: "nomadro 游民",
+ bio: t.dating.defaultBio,
+ });
+ toast(t.dating.joinOk, "success", {
+ href: "/meetups",
+ label: t.dating.meetAtEvents,
});
- toast("资料已完善,开始匹配", "success");
await load();
- } catch {
- toast("加入失败", "error");
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : t.dating.joinFail;
+ setError(msg);
+ toast(t.dating.joinFail, "error");
+ setLoading(false);
}
};
@@ -87,7 +121,7 @@ export default function DatingClient() {
const q = await api.getMatchQuota(token);
setQuota(q);
} catch {
- toast("滑动失败,请检查登录或配额", "error");
+ toast(t.dating.swipeFail, "error");
}
};
@@ -100,7 +134,11 @@ export default function DatingClient() {
{t.dating.loginTitle}
{t.dating.loginDesc}
-
{t.nav.login}
+
+ {t.nav.login}
+ {t.dating.meetAtEvents}
+ {t.nav.community}
+
@@ -116,6 +154,8 @@ export default function DatingClient() {
{t.nav.chat}
·
{t.dating.likesTitle}
+ ·
+ {t.nav.meetups}
{t.dating.tag}
@@ -124,7 +164,7 @@ export default function DatingClient() {
- {(moreIntents ? INTENTS : PRIMARY_INTENTS).map((i) => (
+ {(moreIntents ? intents : primary).map((i) => (
setMoreIntents(true)}>
- 更多意图…
+ {t.dating.moreIntents}
)}
{quota && (
- {quota.vip ? "✨ VIP 无限滑" : `今日剩余 ${quota.remaining}/${quota.limit} 次`}
+ {quota.vip
+ ? t.dating.vipUnlimited
+ : t.dating.quotaLeft
+ .replace("{remaining}", String(quota.remaining))
+ .replace("{limit}", String(quota.limit))}
{!quota.vip && quota.remaining <= 0 && (
<>
{" · "}
- 开通 VIP 无限匹配
+ {t.dating.vipUnlock}
>
)}
)}
+ {loading && (
+
+ {joined ? t.dating.loadingCandidates : t.dating.loadingProfile}
+
+ )}
+
{!joined && !loading && (
{t.dating.joinFirst}
-
{t.dating.joinBtn}
+ {error &&
{error}
}
+
+ void quickJoin()}>
+ {t.dating.joinBtn}
+
+ void load()}>
+ {t.dating.refresh}
+
+
)}
- {loading && (
- {joined ? "加载候选人…" : "加载匹配资料…"}
- )}
-
{top && (
{top.photo || "🧑💻"}
{top.name}
-
{top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}
+
+ {top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}
+
{top.bio}
- {(top.tags || []).map((tag) => {tag})}
+ {(top.tags || []).map((tag) => (
+
+ {tag}
+
+ ))}
- swipe("dislike")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>✕
- swipe("superlike")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>⭐
- swipe("like")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>♥
+ void swipe("dislike")}
+ disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}
+ >
+ ✕
+
+ void swipe("superlike")}
+ disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}
+ >
+ ⭐
+
+ void swipe("like")}
+ disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}
+ >
+ ♥
+
{token && (
- api.undoSwipe(token).then(() => load())}>↩
+ void api.undoSwipe(token).then(() => load())}
+ >
+ ↩
+
)}
@@ -188,10 +276,23 @@ export default function DatingClient() {
{t.dating.empty}
- void load()}>刷新
- 去活动认识人
- 查看对话
- {quota && !quota.vip && 开通 VIP}
+ void load()}>
+ {t.dating.refresh}
+
+
+ {t.dating.meetAtEvents}
+
+
+ {t.nav.community}
+
+
+ {t.dating.viewChats}
+
+ {quota && !quota.vip && (
+
+ {t.dating.openVip}
+
+ )}
)}
@@ -202,12 +303,21 @@ export default function DatingClient() {
{matchModal && (
-
🎉 {t.dating.matched} {matchModal.name}!
+
+ 🎉 {t.dating.matched} {matchModal.name}!
+
- setMatchModal(null)}>{t.dating.keepSwiping}
+ setMatchModal(null)}>
+ {t.dating.keepSwiping}
+
{matchModal.conversationId && (
- {t.dating.chatNow}
+
+ {t.dating.chatNow}
+
)}
+ setMatchModal(null)}>
+ {t.dating.meetAtEvents}
+
diff --git a/frontend/src/components/DatingLikesClient.tsx b/frontend/src/components/DatingLikesClient.tsx
index fd91432..f1b3903 100644
--- a/frontend/src/components/DatingLikesClient.tsx
+++ b/frontend/src/components/DatingLikesClient.tsx
@@ -6,10 +6,13 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
import type { MatchProfile } from "@/lib/types";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
export default function DatingLikesClient() {
const { user, token } = useAuth();
const { t } = useI18n();
+ const rings = useRingSteps();
const [likes, setLikes] = useState([]);
const [loading, setLoading] = useState(Boolean(token));
@@ -52,13 +55,18 @@ export default function DatingLikesClient() {
{t.dating.likesTitle}
{t.dating.likesSubtitle}
- {loading && 加载中…
}
+ {loading && {t.common.loading}
}
{!loading && likes.length === 0 && (
{t.dating.likesEmpty}
-
- 去滑动匹配
-
+
+
+ {t.dating.goSwipe}
+
+
+ {t.dating.meetAtEvents}
+
+
)}
@@ -77,11 +85,12 @@ export default function DatingLikesClient() {
)}
{p.name}
- {p.location || "全球"}
+ {p.location || t.dating.globalNomad}
);
})}
+
);
diff --git a/frontend/src/components/DestinationDetailClient.tsx b/frontend/src/components/DestinationDetailClient.tsx
index 003be3b..e913917 100644
--- a/frontend/src/components/DestinationDetailClient.tsx
+++ b/frontend/src/components/DestinationDetailClient.tsx
@@ -6,10 +6,13 @@ import { useToast } from "@/lib/toast";
import { loadTrip } from "@/lib/tripStorage";
import { loadPlanMeta, stayHint } from "@/lib/planMeta";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
+import { trackRecentDestination } from "@/lib/recentDestinations";
import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api";
import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { useI18n } from "@/lib/i18n";
+import { meetupCityHref, meetupEventHref } from "@/lib/meetupLinks";
const TZ_LABELS: Record = {
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
@@ -21,19 +24,9 @@ interface Props {
allDestinations: Destination[];
}
-function getScores(d: Destination) {
- const nomads = parseInt(d.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
- return [
- { label: "性价比", value: Math.round((12000 - d.cost) / 120), emoji: "💰" },
- { label: "网速", value: Math.round(d.speed / 2), emoji: "📶" },
- { label: "气候", value: Math.round(100 - Math.abs(d.temperature - 24) * 4), emoji: "🌡️" },
- { label: "评分", value: Math.round(d.rating * 10), emoji: "⭐" },
- { label: "社区", value: Math.min(Math.round(nomads / 150), 100), emoji: "🤝" },
- ];
-}
-
export default function DestinationDetailClient({ dest, allDestinations }: Props) {
const { toast } = useToast();
+ const { t } = useI18n();
const rings = useRingSteps();
const [added, setAdded] = useState(false);
const [months, setMonths] = useState(1);
@@ -42,7 +35,18 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
const [meetups, setMeetups] = useState([]);
const [discussions, setDiscussions] = useState([]);
const [depthLoading, setDepthLoading] = useState(true);
- const scores = getScores(dest);
+
+ const scores = useMemo(() => {
+ const nomads = parseInt(dest.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
+ return [
+ { label: t.destDetail.scoreValue, value: Math.round((12000 - dest.cost) / 120), emoji: "💰" },
+ { label: t.destDetail.scoreSpeed, value: Math.round(dest.speed / 2), emoji: "📶" },
+ { label: t.destDetail.scoreClimate, value: Math.round(100 - Math.abs(dest.temperature - 24) * 4), emoji: "🌡️" },
+ { label: t.destDetail.scoreRating, value: Math.round(dest.rating * 10), emoji: "⭐" },
+ { label: t.destDetail.scoreCommunity, value: Math.min(Math.round(nomads / 150), 100), emoji: "🤝" },
+ ];
+ }, [dest, t]);
+
const visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
const overBudget = budget > 0 && dest.cost > budget;
@@ -51,6 +55,17 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
.filter((d) => d.region === dest.region && d.slug !== dest.slug)
.slice(0, 3);
+ useEffect(() => {
+ trackRecentDestination({
+ slug: dest.slug,
+ name: dest.name,
+ country: dest.country,
+ emoji: dest.emoji,
+ cost: dest.cost,
+ rating: dest.rating,
+ });
+ }, [dest.slug, dest.name, dest.country, dest.emoji, dest.cost, dest.rating]);
+
useEffect(() => {
setDepthLoading(true);
api
@@ -72,8 +87,8 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
const addToTrip = () => {
const trip = loadTrip();
- if (trip.some((t) => t.slug === dest.slug)) {
- toast("该城市已在行程中", "info");
+ if (trip.some((item) => item.slug === dest.slug)) {
+ toast(t.common.alreadyInTrip, "info", { href: "/plan", label: t.strip.openPlan });
setAdded(true);
return;
}
@@ -81,9 +96,13 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
setAdded(true);
toast(
n > 0
- ? `${dest.emoji} ${dest.name} 已加入旅居计划(${months} 个月)`
- : "该城市已在计划中",
- n > 0 ? "success" : "info"
+ ? t.common.addedToPlanMonths
+ .replace("{emoji}", dest.emoji)
+ .replace("{city}", dest.name)
+ .replace("{months}", String(months))
+ : t.common.alreadyInPlan,
+ n > 0 ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
);
};
@@ -93,13 +112,36 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
await navigator.share({ title: `${dest.name} - nomadro`, url });
} else {
await navigator.clipboard.writeText(url);
- toast("链接已复制到剪贴板 📋");
+ toast(t.common.linkCopied);
+ }
+ };
+
+ const copySnapshot = async () => {
+ const url = typeof window !== "undefined" ? window.location.href : `/destinations/${dest.slug}`;
+ const lines = [
+ `${dest.emoji} ${dest.name}, ${dest.country}`,
+ t.destDetail.snapCost
+ .replace("{cost}", dest.cost.toLocaleString())
+ .replace("{speed}", String(dest.speed))
+ .replace("{rating}", String(dest.rating)),
+ t.destDetail.snapClimate
+ .replace("{temp}", String(dest.temperature))
+ .replace("{nomads}", dest.nomads_count),
+ visa ? t.destDetail.snapVisa.replace("{visa}", visa.text) : "",
+ t.destDetail.snapDetail.replace("{url}", url),
+ "— via nomadro",
+ ].filter(Boolean);
+ try {
+ await navigator.clipboard.writeText(lines.join("\n"));
+ toast(t.profile.snapshotOk);
+ } catch {
+ toast(t.profile.snapshotOk, "info");
}
};
useEffect(() => {
const trip = loadTrip();
- setAdded(trip.some((t) => t.slug === dest.slug));
+ setAdded(trip.some((item) => item.slug === dest.slug));
}, [dest.slug]);
const costBreak = detail?.cost?.breakdown || [];
@@ -110,11 +152,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
<>
{added ? (
- ✓ 已在计划中 · 打开 →
+ {t.common.inPlanOpen}
) : (
<>
- 🗓️ 加入旅居计划
+ {t.common.addToPlan}
>
)}
- ⚖️ 去对比
- 🧭 找下一站
- 🍹 同城活动
- 📤 分享
+ {t.common.goCompare}
+ {t.common.findNext}
+ {t.common.cityMeetups}
+ {t.common.shareBtn}
+ void copySnapshot()}>
+ {t.profile.snapshotBtn}
+
- {depthLoading && 加载城市深度信息…
}
+ {depthLoading && {t.common.loadingDepth}
}
{(visa || overBudget) && (
@@ -142,7 +187,9 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
)}
{overBudget && (
- 💰 月生活费 ¥{dest.cost.toLocaleString()} 高于你的计划月预算 ¥{budget.toLocaleString()}
+ {t.common.cityOverBudget
+ .replace("{cost}", dest.cost.toLocaleString())
+ .replace("{budget}", budget.toLocaleString())}
)}
@@ -150,11 +197,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{guide?.summary && (
- 🧭 落地指南
+ {t.destDetail.guide}
{guide.summary}
{guide.workSetup && {guide.workSetup}
}
{!!guide.bestFor?.length && (
- 适合:{guide.bestFor.join(" · ")}
+ {t.destDetail.bestFor}{guide.bestFor.join(" · ")}
)}
{!!guide.arrivalChecklist?.length && (
@@ -166,7 +213,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
-
📊 城市画像
+
{t.destDetail.radar}
{scores.map((s) => (
@@ -184,24 +231,26 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
🕐
- 时区
- {TZ_LABELS[dest.slug] || "查看时区看板"}
+ {t.destDetail.timezone}
+ {TZ_LABELS[dest.slug] || t.common.viewTimezone}
👥
- 游民社区
- {detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} 活跃
+ {t.destDetail.nomadCommunity}
+ {detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} {t.destDetail.active}
🌡️
- 气候
+ {t.destDetail.climate}
{detail?.weather?.temperature ?? dest.temperature}°C
- {detail?.weather?.bestMonths ? ` · 宜居 ${detail.weather.bestMonths.join("/")}` : ""}
+ {detail?.weather?.bestMonths
+ ? ` · ${t.destDetail.livable} ${detail.weather.bestMonths.join("/")}`
+ : ""}
@@ -210,7 +259,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!costBreak.length && (
- 💸 月度成本拆解
+ {t.destDetail.costBreak}
{costBreak.map((row) => (
@@ -226,11 +275,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{(pros?.pros || pros?.cons) && (
-
👍 优势
+
{t.destDetail.pros}
{(pros?.pros || []).map((p) => - {p}
)}
-
👎 注意
+
{t.destDetail.cons}
{(pros?.cons || []).map((p) => - {p}
)}
@@ -238,7 +287,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!detail?.reviews?.items?.length && (
- 💬 游民评价 · {detail.reviews.rating}/10
+ {t.destDetail.reviews} · {detail.reviews.rating}/10
{detail.reviews.items.map((r) => (
@@ -252,24 +301,26 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!detail?.chat?.channels?.length && (
- 💬 同城社群
+ {t.destDetail.localChat}
{detail.chat.channels.map((c) => (
- - {c.name} · {c.members} 人 · {c.status}
+ - {c.name} · {c.members} {t.destDetail.peopleUnit} · {c.status}
))}
{!!detail.chat.latestTopics?.length && (
- 近期话题:{detail.chat.latestTopics.join(" · ")}
+
+ {t.destDetail.recentTopics}{detail.chat.latestTopics.join(" · ")}
+
)}
)}
{!!meetups.length && (
- 🍹 相关活动
+ {t.destDetail.relatedMeetups}
{meetups.slice(0, 4).map((m) => (
-
+
{m.emoji || "🎉"}
{m.title}
@@ -283,7 +334,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!discussions.length && (
- 🧵 相关讨论
+ {t.destDetail.relatedDiscussions}
{discussions.slice(0, 5).map((d) => (
-
@@ -296,14 +347,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!content.length && (
-
📚 相关内容
+ {t.destDetail.relatedContent}
{content.map((c) => (
📄
{c.title}
- {c.subtitle || c.ctaLabel || "查看"}
+ {c.subtitle || c.ctaLabel || t.destDetail.viewFallback}
))}
@@ -313,14 +364,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{related.length > 0 && (
-
🔗 同区域推荐
+
{t.destDetail.relatedRegion}
{related.map((d) => (
{d.emoji}
{d.name}
- ¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}
+ ¥{d.cost.toLocaleString()}{t.destDetail.perMonth} · ⭐ {d.rating}
))}
@@ -328,7 +379,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
)}
-
+
>
);
}
diff --git a/frontend/src/components/DestinationMatcher.tsx b/frontend/src/components/DestinationMatcher.tsx
index eac4018..f04501b 100644
--- a/frontend/src/components/DestinationMatcher.tsx
+++ b/frontend/src/components/DestinationMatcher.tsx
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useToast } from "@/lib/toast";
+import { useI18n } from "@/lib/i18n";
import { compareUrl } from "@/lib/compareScore";
import {
EMPTY_MATCHER_PREFS,
@@ -26,38 +27,7 @@ interface Props {
onClose: () => void;
}
-const STEPS = [
- { title: "你的月预算?", subtitle: "包含住宿、餐饮、交通等日常开销", key: "budget" as const },
- { title: "偏好什么气候?", subtitle: "选择最让你舒适的环境", key: "climate" as const },
- { title: "最看重什么?", subtitle: "我们会据此为你智能排序", key: "priority" as const },
- { title: "想去哪个区域?", subtitle: "可以选「不限」探索全球", key: "region" as const },
-];
-
-const OPTIONS = {
- budget: [
- { value: "low" as MatcherBudget, emoji: "💰", label: "精打细算", desc: "¥5,000 以下/月" },
- { value: "medium" as MatcherBudget, emoji: "💳", label: "舒适适中", desc: "¥5,000 – 9,000/月" },
- { value: "high" as MatcherBudget, emoji: "💎", label: "品质优先", desc: "¥9,000 以上/月" },
- ],
- climate: [
- { value: "warm" as MatcherClimate, emoji: "☀️", label: "热带温暖", desc: "25°C 以上,阳光沙滩" },
- { value: "mild" as MatcherClimate, emoji: "🌤️", label: "温和宜人", desc: "18–25°C,四季舒适" },
- { value: "cool" as MatcherClimate, emoji: "🍂", label: "凉爽清爽", desc: "18°C 以下,清爽干燥" },
- ],
- priority: [
- { value: "cost" as MatcherPriority, emoji: "💰", label: "生活成本", desc: "花最少的钱过最好的生活" },
- { value: "speed" as MatcherPriority, emoji: "📶", label: "网络速度", desc: "稳定高速,会议不掉线" },
- { value: "community" as MatcherPriority, emoji: "🤝", label: "游民社区", desc: "结识同行,快速融入" },
- { value: "lifestyle" as MatcherPriority, emoji: "🎨", label: "生活方式", desc: "文化、美食与体验" },
- ],
- region: [
- { value: "any" as MatcherRegion, emoji: "🌏", label: "不限", desc: "全球探索" },
- { value: "sea" as MatcherRegion, emoji: "🌴", label: "东南亚", desc: "性价比之王" },
- { value: "europe" as MatcherRegion, emoji: "🏰", label: "欧洲", desc: "历史与签证友好" },
- { value: "latam" as MatcherRegion, emoji: "🌮", label: "拉美", desc: "活力与北美时区" },
- { value: "asia" as MatcherRegion, emoji: "🏯", label: "东亚", desc: "安全高效现代" },
- ],
-};
+const STEP_KEYS = ["budget", "climate", "priority", "region"] as const;
function parseNomads(n: string): number {
return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0;
@@ -92,23 +62,75 @@ function scoreDestination(
export default function DestinationMatcher({ destinations, open, onClose }: Props) {
const router = useRouter();
const { toast } = useToast();
+ const { t } = useI18n();
const [step, setStep] = useState(0);
const [prefs, setPrefs] = useState
(EMPTY_MATCHER_PREFS);
const [direction, setDirection] = useState<"forward" | "back">("forward");
const [hydrated, setHydrated] = useState(false);
+ const steps = useMemo(
+ () => [
+ { title: t.matcher.budgetTitle, subtitle: t.matcher.budgetSub, key: "budget" as const },
+ { title: t.matcher.climateTitle, subtitle: t.matcher.climateSub, key: "climate" as const },
+ { title: t.matcher.priorityTitle, subtitle: t.matcher.prioritySub, key: "priority" as const },
+ { title: t.matcher.regionTitle, subtitle: t.matcher.regionSub, key: "region" as const },
+ ],
+ [t]
+ );
+
+ const options = useMemo(
+ () => ({
+ budget: [
+ { value: "low" as MatcherBudget, emoji: "💰", label: t.matcher.budgetLow, desc: t.matcher.budgetLowDesc },
+ { value: "medium" as MatcherBudget, emoji: "💳", label: t.matcher.budgetMed, desc: t.matcher.budgetMedDesc },
+ { value: "high" as MatcherBudget, emoji: "💎", label: t.matcher.budgetHigh, desc: t.matcher.budgetHighDesc },
+ ],
+ climate: [
+ { value: "warm" as MatcherClimate, emoji: "☀️", label: t.matcher.climateWarm, desc: t.matcher.climateWarmDesc },
+ { value: "mild" as MatcherClimate, emoji: "🌤️", label: t.matcher.climateMild, desc: t.matcher.climateMildDesc },
+ { value: "cool" as MatcherClimate, emoji: "🍂", label: t.matcher.climateCool, desc: t.matcher.climateCoolDesc },
+ ],
+ priority: [
+ { value: "cost" as MatcherPriority, emoji: "💰", label: t.matcher.priCost, desc: t.matcher.priCostDesc },
+ { value: "speed" as MatcherPriority, emoji: "📶", label: t.matcher.priSpeed, desc: t.matcher.priSpeedDesc },
+ { value: "community" as MatcherPriority, emoji: "🤝", label: t.matcher.priCommunity, desc: t.matcher.priCommunityDesc },
+ { value: "lifestyle" as MatcherPriority, emoji: "🎨", label: t.matcher.priLifestyle, desc: t.matcher.priLifestyleDesc },
+ ],
+ region: [
+ { value: "any" as MatcherRegion, emoji: "🌏", label: t.matcher.regionAny, desc: t.matcher.regionAnyDesc },
+ { value: "sea" as MatcherRegion, emoji: "🌴", label: t.matcher.regionSea, desc: t.matcher.regionSeaDesc },
+ { value: "europe" as MatcherRegion, emoji: "🏰", label: t.matcher.regionEurope, desc: t.matcher.regionEuropeDesc },
+ { value: "latam" as MatcherRegion, emoji: "🌮", label: t.matcher.regionLatam, desc: t.matcher.regionLatamDesc },
+ { value: "asia" as MatcherRegion, emoji: "🏯", label: t.matcher.regionAsia, desc: t.matcher.regionAsiaDesc },
+ ],
+ }),
+ [t]
+ );
+
useEffect(() => {
- if (!open) return;
+ if (!open) {
+ setHydrated(false);
+ return;
+ }
const saved = loadMatcherPrefs();
setPrefs(saved);
- if (matcherPrefsComplete(saved)) setStep(STEPS.length);
+ if (matcherPrefsComplete(saved)) setStep(STEP_KEYS.length);
else {
- const filled = STEPS.findIndex((s) => !saved[s.key]);
+ const filled = STEP_KEYS.findIndex((k) => !saved[k]);
setStep(filled === -1 ? 0 : filled);
}
setHydrated(true);
}, [open]);
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
const results = useMemo(() => {
if (!matcherPrefsComplete(prefs)) return [];
return [...destinations]
@@ -118,9 +140,9 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
const top3 = results.slice(0, 3).map((r) => r.dest);
- const currentKey = STEPS[step]?.key;
- const isComplete = step >= STEPS.length;
- const progress = isComplete ? 100 : ((step + 1) / STEPS.length) * 100;
+ const currentKey = steps[step]?.key;
+ const isComplete = step >= STEP_KEYS.length;
+ const progress = isComplete ? 100 : ((step + 1) / STEP_KEYS.length) * 100;
const select = (value: string) => {
if (!currentKey) return;
@@ -152,11 +174,19 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
const { added, skipped } = mergeDestinationsIntoTrip(top3, 1);
onClose();
if (added === 0) {
- toast(skipped ? "这几座城已在计划中" : "未能加入计划", "info");
+ toast(skipped ? t.matcher.alreadyAll : t.matcher.addFail, "info", {
+ href: "/plan",
+ label: t.strip.openPlan,
+ });
router.push("/plan");
return;
}
- toast(`已将 Top ${added} 城写入旅居计划${skipped ? `(跳过 ${skipped} 座已有)` : ""}`);
+ toast(
+ t.matcher.addedTop.replace("{n}", String(added)) +
+ (skipped ? t.matcher.skipped.replace("{n}", String(skipped)) : ""),
+ "success",
+ { href: "/plan", label: t.strip.openPlan }
+ );
router.push("/plan");
};
@@ -164,30 +194,52 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
e.preventDefault();
e.stopPropagation();
const { added } = mergeDestinationsIntoTrip([dest], 1);
- toast(added ? `${dest.emoji} ${dest.name} 已加入计划` : "该城已在计划中", added ? "success" : "info");
+ toast(
+ added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
+ added ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
+ );
};
- if (!open || !hydrated) return null;
+ if (!open) return null;
return (
- e.target === e.currentTarget && onClose()}>
+
e.target === e.currentTarget && onClose()}
+ >
+ {!hydrated ? (
+
+
+ ✕
+
+
{t.common.loading}
+
+ ) : (
-
✕
+
✕
- {!isComplete ? (
+ {!isComplete && currentKey ? (
-
🎯 MATCHER · {step + 1}/{STEPS.length}
-
{STEPS[step].title}
-
{STEPS[step].subtitle}
+
+ {t.matcher.tag
+ .replace("{n}", String(step + 1))
+ .replace("{total}", String(STEP_KEYS.length))}
+
+
{steps[step].title}
+
{steps[step].subtitle}
- {OPTIONS[currentKey].map((opt) => (
+ {options[currentKey].map((opt) => (
- {step === 0 ? "取消" : "← 上一步"}
+ {step === 0 ? t.matcher.cancel : t.matcher.back}
) : (
-
✨ YOUR MATCHES
-
为你推荐的目的地
-
偏好已保存。可一键写入旅居计划,或先对比再决定。
+
{t.matcher.resultsTag}
+
{t.matcher.resultsTitle}
+
{t.matcher.resultsSub}
{results.length === 0 ? (
-
暂无匹配结果,目的地数据可能未加载
+
{t.matcher.empty}
- 重新测试
+ {t.matcher.retry}
- 去智能下一站
+ {t.matcher.goNextStop}
) : (
<>
-
- {results.slice(0, 8).map(({ dest, score }, i) => (
-
-
-
- {i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`}
+
+ {results.slice(0, 8).map(({ dest, score }, i) => (
+
+
+
+ {i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`}
+
+
{dest.emoji}
+
+ {dest.name}
+ {dest.country} · {dest.tag}
+
+
+
+
+ {t.matcher.matchPct.replace(
+ "{n}",
+ String(Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100))
+ )}
+
+
+
+ 💰 ¥{dest.cost.toLocaleString()}
+ 📶 {dest.speed}Mbps
+ ⭐ {dest.rating}
+
+
+
addOne(dest, e)}>
+ {t.matcher.addPlan}
+
-
{dest.emoji}
-
- {dest.name}
- {dest.country} · {dest.tag}
-
-
-
-
{Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100)}% 匹配
-
-
- 💰 ¥{dest.cost.toLocaleString()}
- 📶 {dest.speed}Mbps
- ⭐ {dest.rating}
-
-
-
addOne(dest, e)}>
- + 计划
+ ))}
+
+
+ {t.matcher.retryBtn}
+ {top3.length >= 2 && (
+ d.slug))}
+ className="btn btn-ghost"
+ onClick={onClose}
+ >
+ {t.matcher.compareTop.replace("{n}", String(top3.length))}
+
+ )}
+
+ {t.matcher.writeTop.replace("{n}", String(Math.min(3, top3.length)))}
- ))}
-
-
- 🔄 重新测试
- {top3.length >= 2 && (
- d.slug))}
- className="btn btn-ghost"
- onClick={onClose}
- >
- ⚖️ 对比 Top {top3.length}
-
- )}
-
- 把 Top {Math.min(3, top3.length)} 写入计划 →
-
-
>
)}
)}
+ )}
);
}
diff --git a/frontend/src/components/DigitalCourseClient.tsx b/frontend/src/components/DigitalCourseClient.tsx
index 953eb38..357ccf8 100644
--- a/frontend/src/components/DigitalCourseClient.tsx
+++ b/frontend/src/components/DigitalCourseClient.tsx
@@ -6,10 +6,13 @@ import { useI18n } from "@/lib/i18n";
import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api";
import type { DigitalCourse } from "@/lib/types";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
export default function DigitalCourseClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n();
const { token } = useAuth();
+ const rings = useRingSteps();
const [vip, setVip] = useState(false);
useEffect(() => {
@@ -30,17 +33,16 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
{t.digital.courseTag}
{t.digital.course}
{vip ? (
-
✨ VIP 已解锁付费课时
+
{t.digital.vipUnlocked}
) : (
- 免费课时可直接学 · VIP 课时需{" "}
- 开通会员
+ {t.digital.vipHint} · {t.nav.join}
)}
{course.modules.length === 0 ? (
-
课程内容准备中
+
{t.digital.courseEmpty}
{t.digital.back}
{t.nav.gigs}
@@ -61,7 +63,7 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
🔒 {lesson.title}
- {lesson.duration} · VIP · 开通解锁
+ {lesson.duration} · VIP · {t.digital.unlockVip}
) : (
@@ -81,6 +83,7 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
))}
)}
+
);
diff --git a/frontend/src/components/DigitalHomeClient.tsx b/frontend/src/components/DigitalHomeClient.tsx
index 0b41539..45de3f7 100644
--- a/frontend/src/components/DigitalHomeClient.tsx
+++ b/frontend/src/components/DigitalHomeClient.tsx
@@ -4,9 +4,11 @@ import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { DigitalCourse } from "@/lib/types";
import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
export default function DigitalHomeClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n();
+ const rings = useRingSteps();
const lessonCount = course.modules.reduce((n, m) => n + m.lessons.length, 0);
return (
@@ -45,17 +47,12 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })
📅
-
每日指南
-
从税务居民到落地验网,三天起步清单
+
{t.digital.dayGuide}
+
{t.digital.dayGuideDesc}
-
+
);
diff --git a/frontend/src/components/DigitalJobsClient.tsx b/frontend/src/components/DigitalJobsClient.tsx
index fc5ff51..e1d87b5 100644
--- a/frontend/src/components/DigitalJobsClient.tsx
+++ b/frontend/src/components/DigitalJobsClient.tsx
@@ -1,14 +1,70 @@
"use client";
+import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
import type { DigitalJob } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import {
+ clearSavedJobs,
+ loadSavedJobs,
+ toggleSavedJob,
+ type SavedJob,
+} from "@/lib/savedJobs";
export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
const { t } = useI18n();
+ const { toast } = useToast();
const rings = useRingSteps();
+ const [saved, setSaved] = useState
([]);
+ const [q, setQ] = useState("");
+ const [tag, setTag] = useState("all");
+
+ useEffect(() => {
+ setSaved(loadSavedJobs());
+ }, []);
+
+ const tags = useMemo(() => {
+ const set = new Set();
+ jobs.forEach((j) => j.tags.forEach((x) => set.add(x)));
+ return Array.from(set).slice(0, 8);
+ }, [jobs]);
+
+ const filtered = useMemo(() => {
+ const s = q.trim().toLowerCase();
+ return jobs.filter((j) => {
+ if (tag !== "all" && !j.tags.includes(tag)) return false;
+ if (!s) return true;
+ return (
+ j.title.toLowerCase().includes(s) ||
+ j.company.toLowerCase().includes(s) ||
+ j.location.toLowerCase().includes(s) ||
+ j.tags.some((x) => x.toLowerCase().includes(s))
+ );
+ });
+ }, [jobs, q, tag]);
+
+ const onToggle = (job: DigitalJob) => {
+ const next = toggleSavedJob({
+ id: job.id,
+ title: job.title,
+ company: job.company,
+ location: job.location,
+ salary: job.salary,
+ url: job.url,
+ });
+ const now = next.some((x) => x.id === job.id);
+ setSaved(next);
+ toast(
+ now ? t.digital.jobsSaved : t.digital.jobsUnsaved,
+ "success",
+ now
+ ? { href: "/digital/jobs", label: t.digital.jobsSavedList }
+ : { href: "/gigs", label: t.nav.gigs }
+ );
+ };
return (
@@ -22,31 +78,111 @@ export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
{t.digital.jobsSubtitle}
{t.nav.gigs}
+
+ {saved.length > 0 && (
+
+
+
+ {t.digital.jobsSavedList} · {saved.length}
+
+ {
+ clearSavedJobs();
+ setSaved([]);
+ }}
+ >
+ {t.digital.jobsClearSaved}
+
+
+
+
+ )}
+
+ {jobs.length > 0 && (
+
+
setQ(e.target.value)}
+ />
+
+ setTag("all")}
+ >
+ {t.digital.jobsFilterAll}
+
+ {tags.map((x) => (
+ setTag(x)}
+ >
+ {x}
+
+ ))}
+
+
+ )}
+
{jobs.length === 0 ? (
{t.digital.jobsEmpty}
- 去赏金任务
- 社区求助
+ {t.digital.jobsGoGigs}
+ {t.digital.jobsGoCommunity}
+ ) : filtered.length === 0 ? (
+
+
{t.digital.jobsEmpty}
+
{ setQ(""); setTag("all"); }}>
+ {t.common.clear}
+
+
) : (
- {jobs.map((job) => (
-
- {job.title}
- {job.company} · {job.location}
- {job.type} · {job.salary}
-
- {job.tags.map((tag) => (
- {tag}
- ))}
-
-
- {t.digital.apply}
-
-
- ))}
+ {filtered.map((job) => {
+ const isSaved = saved.some((s) => s.id === job.id);
+ return (
+
+
+
{job.title}
+ onToggle(job)}
+ >
+ {isSaved ? `★ ${t.digital.jobsSaved}` : `☆ ${t.digital.jobsSave}`}
+
+
+ {job.company} · {job.location}
+ {job.type} · {job.salary}
+
+ {job.tags.map((item) => (
+ {item}
+ ))}
+
+
+ {t.digital.apply}
+
+
+ );
+ })}
)}
diff --git a/frontend/src/components/DigitalLessonClient.tsx b/frontend/src/components/DigitalLessonClient.tsx
index 381f809..a9bd3fa 100644
--- a/frontend/src/components/DigitalLessonClient.tsx
+++ b/frontend/src/components/DigitalLessonClient.tsx
@@ -6,6 +6,8 @@ import { useI18n } from "@/lib/i18n";
import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api";
import type { DigitalCourse, DigitalLesson } from "@/lib/types";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
export default function DigitalLessonClient({
moduleIndex,
@@ -16,6 +18,7 @@ export default function DigitalLessonClient({
}) {
const { t } = useI18n();
const { token } = useAuth();
+ const rings = useRingSteps();
const [lesson, setLesson] = useState(null);
const [locked, setLocked] = useState(false);
const [missing, setMissing] = useState(false);
@@ -73,7 +76,7 @@ export default function DigitalLessonClient({
return (
-
加载课时…
+
{t.digital.loadingLesson}
);
@@ -84,7 +87,7 @@ export default function DigitalLessonClient({
-
课时不存在
+ {t.digital.missingLesson}
{t.digital.backCourse}
@@ -109,6 +112,7 @@ export default function DigitalLessonClient({
{t.digital.backCourse}
+
);
@@ -142,12 +146,21 @@ export default function DigitalLessonClient({
{t.digital.next} →
) : (
-
- 返回课程目录
-
+
+
+ {t.digital.backCatalog}
+
+
+ {t.nav.gigs}
+
+
+ {t.nav.meetups}
+
+
)}
+
);
diff --git a/frontend/src/components/DiscussionDetailClient.tsx b/frontend/src/components/DiscussionDetailClient.tsx
index 1695bbd..9c7e3a6 100644
--- a/frontend/src/components/DiscussionDetailClient.tsx
+++ b/frontend/src/components/DiscussionDetailClient.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
@@ -9,6 +9,8 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+import { inferMeetupCity, meetupCityHref } from "@/lib/meetupLinks";
export default function DiscussionDetailClient({
discussionId,
@@ -26,6 +28,49 @@ export default function DiscussionDetailClient({
const [reply, setReply] = useState("");
const [likes, setLikes] = useState(discussion.like_count);
const [loading, setLoading] = useState(false);
+ const [draftReady, setDraftReady] = useState(false);
+ const [cityNames, setCityNames] = useState
([]);
+ const draftKey = `nomadro-reply-draft:${discussionId}`;
+
+ useEffect(() => {
+ const draft = loadDraft<{ text?: string }>(draftKey);
+ if (draft?.text?.trim()) {
+ setReply(draft.text);
+ toast(t.community.replyDraftRestored, "info");
+ }
+ setDraftReady(true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [discussionId]);
+
+ useEffect(() => {
+ api
+ .getDestinations()
+ .then((list) => setCityNames(list.map((d) => d.name).filter(Boolean)))
+ .catch(() => setCityNames([]));
+ }, []);
+
+ useEffect(() => {
+ if (!draftReady || !token) return;
+ const id = window.setTimeout(() => {
+ if (!reply.trim()) {
+ clearDraft(draftKey);
+ return;
+ }
+ saveDraft(draftKey, { text: reply });
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [reply, draftReady, token, draftKey]);
+
+ const body = discussion.excerpt || "";
+ const inferredCity = useMemo(
+ () =>
+ inferMeetupCity(
+ `${discussion.title} ${body} ${discussion.category || ""}`,
+ cityNames
+ ),
+ [discussion.title, discussion.category, body, cityNames]
+ );
+ const meetupsHref = inferredCity ? meetupCityHref(inferredCity) : "/meetups";
const refresh = async () => {
const d = await api.getDiscussion(discussionId);
@@ -39,15 +84,19 @@ export default function DiscussionDetailClient({
return;
}
if (!reply.trim()) {
- toast("请先写一点回复内容", "info");
+ toast(t.community.replyNeed, "info");
return;
}
setLoading(true);
try {
await api.postDiscussionReply(token, discussionId, reply.trim());
setReply("");
+ clearDraft(draftKey);
await refresh();
- toast(t.community.replyOk, "success");
+ toast(t.community.replyOk, "success", {
+ href: meetupsHref,
+ label: inferredCity ? t.common.cityMeetups : t.dating.meetAtEvents,
+ });
} catch {
toast(t.community.replyFail, "error");
} finally {
@@ -68,13 +117,15 @@ export default function DiscussionDetailClient({
}
};
- const body = discussion.excerpt || "";
-
return (
@@ -105,7 +156,7 @@ export default function DiscussionDetailClient({
{t.community.replies} ({discussion.replies.length})
{discussion.replies.length === 0 && (
- 还没有回复,来做第一个吧
+ {t.community.noRepliesYet}
)}
{discussion.replies.map((r) => (
@@ -129,20 +180,25 @@ export default function DiscussionDetailClient({
placeholder={t.community.replyPlaceholder}
maxLength={2000}
/>
-
- {loading ? "发送中…" : t.community.replySend}
+ void submitReply()}>
+ {loading ? t.community.replySending : t.community.replySend}
) : (
{t.community.replyHint}
-
- {t.nav.login}
-
+
+
+ {t.nav.login}
+
+
+ {t.dating.meetAtEvents}
+
+
)}
-
+
);
diff --git a/frontend/src/components/FAQSection.tsx b/frontend/src/components/FAQSection.tsx
index fe4a415..2c3ea2d 100644
--- a/frontend/src/components/FAQSection.tsx
+++ b/frontend/src/components/FAQSection.tsx
@@ -10,7 +10,7 @@ interface Props {
}
export default function FAQSection({ faqs, limit }: Props) {
- const { t, locale } = useI18n();
+ const { t } = useI18n();
const [active, setActive] = useState(null);
const [search, setSearch] = useState("");
const [showAll, setShowAll] = useState(!limit);
@@ -64,16 +64,16 @@ export default function FAQSection({ faqs, limit }: Props) {
{filtered.length === 0 && (
-
{locale === "en" ? "No FAQ matched" : "没有匹配的问题"}
+
{t.faqUi.empty}
@@ -82,18 +82,18 @@ export default function FAQSection({ faqs, limit }: Props) {
{limit && !showAll && sorted.length > limit && (
setShowAll(true)}>
- {locale === "en" ? `Show all ${sorted.length} questions` : `展开全部 ${sorted.length} 个问题`}
+ {t.faqUi.showAll.replace("{n}", String(sorted.length))}
)}
- {locale === "en" ? "Still exploring? " : "还在选城?"}
- {locale === "en" ? "Browse destinations" : "继续看目的地"}
+ {t.faqUi.still}
+ {t.faqUi.browse}
{" · "}
- {locale === "en" ? "Help center" : "帮助中心"}
+ {t.faqUi.help}
{" · "}
- {locale === "en" ? "Ask AI" : "问 AI"}
+ {t.faqUi.askAi}
diff --git a/frontend/src/components/FavoriteButton.tsx b/frontend/src/components/FavoriteButton.tsx
index 956ad4a..1f7a2f1 100644
--- a/frontend/src/components/FavoriteButton.tsx
+++ b/frontend/src/components/FavoriteButton.tsx
@@ -4,9 +4,13 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { loginNextFrom } from "@/lib/authNext";
+import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
export default function FavoriteButton({ slug }: { slug: string }) {
const { user, isFavorite, toggleFavorite } = useAuth();
+ const { t } = useI18n();
+ const { toast } = useToast();
const pathname = usePathname();
const next = loginNextFrom(pathname || `/destinations/${slug}`);
@@ -17,7 +21,7 @@ export default function FavoriteButton({ slug }: { slug: string }) {
className="btn btn-ghost"
style={{ fontSize: "0.85rem" }}
>
- 🤍 登录收藏
+ {t.common.loginFavorite}
);
}
@@ -27,11 +31,21 @@ export default function FavoriteButton({ slug }: { slug: string }) {
void toggleFavorite(slug)}
+ onClick={() => {
+ const adding = !fav;
+ void toggleFavorite(slug).then(() => {
+ if (adding) {
+ toast(t.common.favorited, "success", {
+ href: "/profile",
+ label: t.profile.myFavs,
+ });
+ }
+ });
+ }}
style={{ fontSize: "0.85rem" }}
aria-pressed={fav}
>
- {fav ? "❤️ 已收藏" : "🤍 收藏"}
+ {fav ? t.common.favorited : t.common.favorite}
);
}
diff --git a/frontend/src/components/FeedbackClient.tsx b/frontend/src/components/FeedbackClient.tsx
index dc18d85..dc0bf8d 100644
--- a/frontend/src/components/FeedbackClient.tsx
+++ b/frontend/src/components/FeedbackClient.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
@@ -8,6 +8,11 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+
+const DRAFT_KEY = "nomadro-feedback-draft";
+
+type FeedbackDraft = { content: string; category: string };
export default function FeedbackClient() {
const { token } = useAuth();
@@ -18,16 +23,46 @@ export default function FeedbackClient() {
const [category, setCategory] = useState("general");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ const draft = loadDraft
(DRAFT_KEY);
+ if (draft) {
+ setContent(draft.content || "");
+ setCategory(draft.category || "general");
+ if (draft.content?.trim()) {
+ toast(t.feedback.draftRestored, "info");
+ }
+ }
+ setReady(true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!ready || done) return;
+ const id = window.setTimeout(() => {
+ if (!content.trim()) {
+ clearDraft(DRAFT_KEY);
+ return;
+ }
+ saveDraft(DRAFT_KEY, { content, category });
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [content, category, ready, done]);
const submit = async () => {
if (!content.trim() || content.trim().length < 5) {
- toast("请至少写 5 个字", "info");
+ toast(t.feedback.minLength, "info");
return;
}
setLoading(true);
try {
await api.submitFeedback(content.trim(), token || undefined, category);
- toast(t.feedback.ok, "success");
+ clearDraft(DRAFT_KEY);
+ toast(t.feedback.ok, "success", {
+ href: "/community",
+ label: t.feedback.goCommunity,
+ });
setContent("");
setDone(true);
} catch {
@@ -50,19 +85,20 @@ export default function FeedbackClient() {
{done ? (
-
已收到,谢谢
-
我们会认真阅读每一条反馈
+
{t.feedback.thanksTitle}
+
{t.feedback.thanksDesc}
setDone(false)}>
- 再写一条
+ {t.feedback.writeAgain}
- 去社区
+ {t.feedback.goCommunity}
) : (
+
{t.feedback.draftHint}
)}
-
+
);
diff --git a/frontend/src/components/FeedbackWidget.tsx b/frontend/src/components/FeedbackWidget.tsx
index 5422cb2..22a7544 100644
--- a/frontend/src/components/FeedbackWidget.tsx
+++ b/frontend/src/components/FeedbackWidget.tsx
@@ -1,35 +1,71 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
+import { useI18n } from "@/lib/i18n";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
-const TYPES = [
- { key: "feature", label: "💡 想法" },
- { key: "bug", label: "🐛 问题" },
- { key: "general", label: "❤️ 喜欢" },
-];
+const DRAFT_KEY = "nomadro-feedback-draft";
export default function FeedbackWidget() {
const { token } = useAuth();
const { toast } = useToast();
+ const { t } = useI18n();
const [open, setOpen] = useState(false);
const [type, setType] = useState("feature");
const [msg, setMsg] = useState("");
const [sending, setSending] = useState(false);
+ const [ready, setReady] = useState(false);
+
+ const types = [
+ { key: "feature", label: `💡 ${t.feedback.catFeature}` },
+ { key: "bug", label: `🐛 ${t.feedback.catBug}` },
+ { key: "general", label: `❤️ ${t.feedback.catGeneral}` },
+ ];
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ const draft = loadDraft<{ content?: string; category?: string }>(DRAFT_KEY);
+ if (draft) {
+ if (draft.content) setMsg(draft.content);
+ if (draft.category) setType(draft.category);
+ }
+ setReady(true);
+ }, [open]);
+
+ useEffect(() => {
+ if (!open || !ready) return;
+ const id = window.setTimeout(() => {
+ if (!msg.trim()) {
+ clearDraft(DRAFT_KEY);
+ return;
+ }
+ saveDraft(DRAFT_KEY, { content: msg, category: type });
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [msg, type, open, ready]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!msg.trim() || msg.trim().length < 5) {
- toast("请至少写 5 个字", "info");
+ toast(t.feedback.minLength, "info");
return;
}
setSending(true);
try {
await api.submitFeedback(msg.trim(), token || undefined, type);
- // Keep a local copy for the user as well
try {
const key = "nomadro-feedback";
const prev = JSON.parse(localStorage.getItem(key) || "[]");
@@ -38,11 +74,15 @@ export default function FeedbackWidget() {
} catch {
/* ignore */
}
- toast("感谢反馈!我们会认真看的 🙏");
+ clearDraft(DRAFT_KEY);
+ toast(t.feedback.ok, "success", {
+ href: "/community",
+ label: t.feedback.goCommunity,
+ });
setMsg("");
setOpen(false);
} catch {
- toast("发送失败,请稍后重试或去 /feedback", "error");
+ toast(t.feedback.failRetry, "error");
} finally {
setSending(false);
}
@@ -53,39 +93,45 @@ export default function FeedbackWidget() {
setOpen(true)}
- aria-label="反馈"
- title="反馈建议"
+ aria-label={t.feedback.fabAria}
+ title={t.feedback.fabTitle}
>
💬
{open && (
-
e.target === e.currentTarget && setOpen(false)}>
+
e.target === e.currentTarget && setOpen(false)}
+ >
-
setOpen(false)} aria-label="关闭">
+ setOpen(false)} aria-label={t.feedback.close}>
✕
-
💬 FEEDBACK
-
给 nomadro 提建议
-
功能想法、体验问题,或只是想说喜欢
-
diff --git a/frontend/src/components/FirstMonthCost.tsx b/frontend/src/components/FirstMonthCost.tsx
index 957fad4..96a0198 100644
--- a/frontend/src/components/FirstMonthCost.tsx
+++ b/frontend/src/components/FirstMonthCost.tsx
@@ -1,7 +1,12 @@
"use client";
import { useMemo, useState } from "react";
+import Link from "next/link";
import type { Destination } from "@/lib/types";
+import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
+import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
+import { meetupCityHref } from "@/lib/meetupLinks";
/** First-month one-time + rent estimates in CNY. */
const LANDING: Record
= {
@@ -18,6 +23,8 @@ interface Props {
}
export default function FirstMonthCost({ destinations }: Props) {
+ const { t } = useI18n();
+ const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
const [housing, setHousing] = useState<"budget" | "mid" | "nice">("mid");
const [includeDeposit, setIncludeDeposit] = useState(true);
@@ -46,6 +53,16 @@ export default function FirstMonthCost({ destinations }: Props) {
const total = breakdown.find((b) => b.key === "total")!.amount;
const maxBar = Math.max(...breakdown.filter((b) => b.key !== "total").map((b) => b.amount), 1);
+ const addToPlan = () => {
+ if (!dest) return;
+ const { added } = mergeDestinationsIntoTrip([dest], 1);
+ toast(
+ added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
+ added ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
+ );
+ };
+
return (
@@ -108,6 +125,19 @@ export default function FirstMonthCost({ destinations }: Props) {
))}
+ {dest && (
+
+
+ 🗓️ {t.nav.plan}
+
+
+ {t.common.detail}
+
+
+ {t.common.cityMeetups}
+
+
+ )}
diff --git a/frontend/src/components/FlightCost.tsx b/frontend/src/components/FlightCost.tsx
index 026015b..47d5fde 100644
--- a/frontend/src/components/FlightCost.tsx
+++ b/frontend/src/components/FlightCost.tsx
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
+import ToolCityExits from "@/components/ToolCityExits";
/** 粗略往返机票估算(CNY),基于城市对距离感 */
const FLIGHT_BASE: Record
= {
@@ -120,6 +121,7 @@ export default function FlightCost({ destinations }: Props) {
* 估算仅供参考,实际票价随航司与预订时间波动较大
+ {dest && }
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
index 867bd63..57b0680 100644
--- a/frontend/src/components/Footer.tsx
+++ b/frontend/src/components/Footer.tsx
@@ -27,7 +27,7 @@ function BrandLogo() {
/** Footer mirrors the three rings — essentials only, not a second sitemap. */
export default function Footer() {
- const { t, locale } = useI18n();
+ const { t } = useI18n();
const pathname = usePathname();
const isHome = pathname === "/";
const loginNext = loginNextFrom(pathname);
@@ -38,9 +38,7 @@ export default function Footer() {
- {locale === "en"
- ? "Work and live freely anywhere on this planet 🌏"
- : "让每个人都能自由地工作和生活在这个星球上 🌏"}
+ {t.footer.tagline}
diff --git a/frontend/src/components/GigPostClient.tsx b/frontend/src/components/GigPostClient.tsx
index e85eac4..238c94c 100644
--- a/frontend/src/components/GigPostClient.tsx
+++ b/frontend/src/components/GigPostClient.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
@@ -9,6 +9,11 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+
+const DRAFT_KEY = "nomadro-gig-post-draft";
+
+type GigDraft = { title: string; description: string; budget: string; deadline: string };
export default function GigPostClient() {
const { token } = useAuth();
@@ -21,6 +26,30 @@ export default function GigPostClient() {
const [budget, setBudget] = useState("");
const [deadline, setDeadline] = useState("");
const [busy, setBusy] = useState(false);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ const draft = loadDraft
(DRAFT_KEY);
+ if (draft) {
+ setTitle(draft.title || "");
+ setDescription(draft.description || "");
+ setBudget(draft.budget || "");
+ setDeadline(draft.deadline || "");
+ }
+ setReady(true);
+ }, []);
+
+ useEffect(() => {
+ if (!ready || !token) return;
+ const id = window.setTimeout(() => {
+ if (!title && !description && !budget && !deadline) {
+ clearDraft(DRAFT_KEY);
+ return;
+ }
+ saveDraft(DRAFT_KEY, { title, description, budget, deadline });
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [title, description, budget, deadline, ready, token]);
if (!token) {
return (
@@ -43,7 +72,7 @@ export default function GigPostClient() {
return;
}
if (!title.trim() || !description.trim()) {
- toast("请填写标题和描述", "info");
+ toast(t.gigs.postNeed, "info");
return;
}
setBusy(true);
@@ -51,10 +80,14 @@ export default function GigPostClient() {
await api.createGig(token, {
title: title.trim(),
description: description.trim(),
- budget: budget.trim() || "面议",
+ budget: budget.trim() || t.gigs.budgetNegotiable,
deadline: deadline || "",
});
- toast(t.gigs.postOk, "success");
+ clearDraft(DRAFT_KEY);
+ toast(t.gigs.postOk, "success", {
+ href: "/digital",
+ label: t.nav.digital,
+ });
router.push("/gigs");
} catch {
toast(t.gigs.postFail, "error");
@@ -70,14 +103,15 @@ export default function GigPostClient() {
{t.gigs.tag}
{t.gigs.postTitle}
+
{t.gigs.draftHint}
setTitle(e.target.value)} maxLength={120} />
diff --git a/frontend/src/components/GigsClient.tsx b/frontend/src/components/GigsClient.tsx
index 79e6fd5..23becea 100644
--- a/frontend/src/components/GigsClient.tsx
+++ b/frontend/src/components/GigsClient.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
@@ -10,6 +10,15 @@ import { useI18n } from "@/lib/i18n";
import type { GigItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import {
+ clearApplyDraft,
+ clearSavedGigs,
+ loadApplyDrafts,
+ loadSavedGigs,
+ saveApplyDraft,
+ toggleSavedGig,
+ type SavedGig,
+} from "@/lib/savedGigs";
export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
const { token } = useAuth();
@@ -20,6 +29,24 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
const [msg, setMsg] = useState>({});
const [applied, setApplied] = useState>({});
const [busyId, setBusyId] = useState(null);
+ const [saved, setSaved] = useState([]);
+
+ useEffect(() => {
+ setSaved(loadSavedGigs());
+ setMsg(loadApplyDrafts());
+ }, []);
+
+ const onToggleSave = (g: GigItem) => {
+ const next = toggleSavedGig({
+ id: g.id,
+ title: g.title,
+ budget: g.budget,
+ deadline: g.deadline,
+ });
+ const now = next.some((x) => x.id === g.id);
+ setSaved(next);
+ toast(now ? t.gigs.saved : t.gigs.unsaved, "success");
+ };
const apply = async (id: string) => {
if (!token) {
@@ -28,14 +55,23 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
}
const message = msg[id]?.trim();
if (!message) {
- toast("请先填写申请说明", "info");
+ toast(t.gigs.applyNeed, "info");
return;
}
setBusyId(id);
try {
await api.applyGig(token, id, message);
setApplied((a) => ({ ...a, [id]: true }));
- toast(t.gigs.applyOk, "success");
+ clearApplyDraft(id);
+ setMsg((m) => {
+ const next = { ...m };
+ delete next[id];
+ return next;
+ });
+ toast(t.gigs.applyOk, "success", {
+ href: "/digital",
+ label: t.nav.digital,
+ });
} catch {
toast(t.gigs.applyFail, "error");
} finally {
@@ -53,10 +89,41 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
{t.gigs.subtitle}
{t.gigs.postTitle}
+
+ {saved.length > 0 && (
+
+
+
+ {t.gigs.savedList} · {saved.length}
+
+ {
+ clearSavedGigs();
+ setSaved([]);
+ }}
+ >
+ {t.gigs.clearSaved}
+
+
+
+
+ )}
+
{gigs.length === 0 ? (
-
暂时没有开放任务
-
你可以发布一条赏金,或先去学院看看远程岗位
+
{t.gigs.empty}
+
{t.gigs.emptyHint}
{t.gigs.postTitle}
{t.nav.digital}
@@ -69,37 +136,53 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
{t.nav.login} · {t.gigs.loginFirst}
)}
- {gigs.map((g) => (
-
- {g.title}
- {g.description}
- {g.poster} · {g.budget} · {g.deadline}
- {applied[g.id] ? (
- ✓ 已申请
- ) : !token ? (
-
- {t.gigs.apply}
-
- ) : (
- <>
-
)}
diff --git a/frontend/src/components/GlobalSearch.tsx b/frontend/src/components/GlobalSearch.tsx
index 8dfa74b..e28f3cd 100644
--- a/frontend/src/components/GlobalSearch.tsx
+++ b/frontend/src/components/GlobalSearch.tsx
@@ -1,66 +1,76 @@
"use client";
-import { useEffect, useState, useCallback } from "react";
+import { useEffect, useState, useCallback, useMemo } from "react";
import { useRouter, usePathname } from "next/navigation";
import { api } from "@/lib/api";
-import { TOOL_LINKS } from "@/lib/tools";
+import { loadRecentDestinations } from "@/lib/recentDestinations";
import type { SearchResult } from "@/lib/types";
+import { useI18n } from "@/lib/i18n";
+import type { Dict } from "@/lib/i18n";
+import { localizedToolLinks } from "@/lib/toolCopy";
-const TYPE_LABELS: Record
= {
- destination: "目的地",
- blog: "博客",
- visa: "签证",
- faq: "FAQ",
- tool: "工具",
- page: "页面",
-};
+function typeLabels(s: Dict["search"]): Record {
+ return {
+ destination: s.typeDestination,
+ blog: s.typeBlog,
+ visa: s.typeVisa,
+ faq: s.typeFaq,
+ tool: s.typeTool,
+ page: s.typePage,
+ };
+}
-/** Empty-state shortcuts — three rings only, not the whole sitemap. */
-const RING_SHORTCUTS: SearchResult[] = [
- { type: "page", title: "智能发现", subtitle: "匹配偏好,锁定下一座城市", emoji: "🎯", url: "/#path" },
- { type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" },
- { type: "page", title: "游民活动", subtitle: "先遇见人,再深入聊", emoji: "🎉", url: "/meetups" },
- { type: "page", title: "社区讨论", subtitle: "签证远程住宿经验", emoji: "💬", url: "/community" },
- { type: "page", title: "游民学院", subtitle: "课程与远程路径", emoji: "🎓", url: "/digital" },
- { type: "page", title: "实用工具", subtitle: "按需取用,不堆菜单", emoji: "🛠️", url: "/tools" },
-];
+function buildPages(s: Dict["search"]): { ring: SearchResult[]; core: SearchResult[]; extra: SearchResult[] } {
+ const ring: SearchResult[] = [
+ { type: "page", title: s.ringDiscover, subtitle: s.ringDiscoverSub, emoji: "🎯", url: "/#path" },
+ { type: "page", title: s.ringNext, subtitle: s.ringNextSub, emoji: "🧭", url: "/next-stop" },
+ { type: "page", title: s.ringMeetups, subtitle: s.ringMeetupsSub, emoji: "🎉", url: "/meetups" },
+ { type: "page", title: s.ringCommunity, subtitle: s.ringCommunitySub, emoji: "💬", url: "/community" },
+ { type: "page", title: s.ringDigital, subtitle: s.ringDigitalSub, emoji: "🎓", url: "/digital" },
+ { type: "page", title: s.ringTools, subtitle: s.ringToolsSub, emoji: "🛠️", url: "/tools" },
+ ];
+ const core: SearchResult[] = [
+ { type: "page", title: s.pagePlan, subtitle: s.pagePlanSub, emoji: "🗓️", url: "/plan" },
+ { type: "page", title: s.pageCompare, subtitle: s.pageCompareSub, emoji: "⚖️", url: "/compare" },
+ { type: "page", title: s.ringNext, subtitle: s.ringNextSub, emoji: "🧭", url: "/next-stop" },
+ { type: "page", title: s.ringMeetups, subtitle: s.pageMeetupsSub, emoji: "🎉", url: "/meetups" },
+ { type: "page", title: s.ringCommunity, subtitle: s.pageCommunitySub, emoji: "💬", url: "/community" },
+ { type: "page", title: s.pageDating, subtitle: s.pageDatingSub, emoji: "💕", url: "/dating" },
+ { type: "page", title: s.pageChat, subtitle: s.pageChatSub, emoji: "✉️", url: "/chat" },
+ { type: "page", title: s.ringDigital, subtitle: s.pageDigitalSub, emoji: "🎓", url: "/digital" },
+ { type: "page", title: s.pageGigs, subtitle: s.pageGigsSub, emoji: "💼", url: "/gigs" },
+ { type: "page", title: s.pageTools, subtitle: s.pageToolsSub, emoji: "🛠️", url: "/tools" },
+ { type: "page", title: s.pageJoin, subtitle: s.pageJoinSub, emoji: "✨", url: "/join" },
+ ];
+ const extra: SearchResult[] = [
+ { type: "page", title: s.pageBook, subtitle: s.pageBookSub, emoji: "📖", url: "/book" },
+ { type: "page", title: s.pageVideos, subtitle: s.pageVideosSub, emoji: "🎬", url: "/videos" },
+ { type: "page", title: s.pageServices, subtitle: s.pageServicesSub, emoji: "🛎️", url: "/services" },
+ { type: "page", title: s.pageAi, subtitle: s.pageAiSub, emoji: "🤖", url: "/ai" },
+ { type: "page", title: s.pageMap, subtitle: s.pageMapSub, emoji: "🗺️", url: "/map" },
+ { type: "page", title: s.pageReport, subtitle: s.pageReportSub, emoji: "📊", url: "/report" },
+ { type: "page", title: s.pageFeedback, subtitle: s.pageFeedbackSub, emoji: "💬", url: "/feedback" },
+ { type: "page", title: s.pageSubmit, subtitle: s.pageSubmitSub, emoji: "📝", url: "/submit" },
+ { type: "page", title: s.pageHelp, subtitle: s.pageHelpSub, emoji: "❓", url: "/help" },
+ { type: "page", title: s.pageContact, subtitle: s.pageContactSub, emoji: "📬", url: "/contact" },
+ { type: "page", title: s.pagePricing, subtitle: s.pagePricingSub, emoji: "💎", url: "/pricing" },
+ { type: "page", title: s.pageNotif, subtitle: s.pageNotifSub, emoji: "🔔", url: "/notifications" },
+ { type: "page", title: s.pageAbout, subtitle: s.pageAboutSub, emoji: "🌏", url: "/about" },
+ { type: "page", title: s.pageChangelog, subtitle: s.pageChangelogSub, emoji: "📜", url: "/changelog" },
+ { type: "page", title: s.pagePrivacy, subtitle: s.pagePrivacySub, emoji: "🔒", url: "/privacy" },
+ ];
+ return { ring, core, extra };
+}
-const CORE_PAGES: SearchResult[] = [
- { type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
- { type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" },
- { type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" },
- { type: "page", title: "游民活动", subtitle: "线上圆桌与线下聚会", emoji: "🎉", url: "/meetups" },
- { type: "page", title: "社区讨论", subtitle: "签证远程住宿经验交流", emoji: "💬", url: "/community" },
- { type: "page", title: "游民匹配", subtitle: "滑动匹配同路游民", emoji: "💕", url: "/dating" },
- { type: "page", title: "私信", subtitle: "与匹配成功的游民聊天", emoji: "✉️", url: "/chat" },
- { type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
- { type: "page", title: "赏金任务", subtitle: "远程小任务接单", emoji: "💼", url: "/gigs" },
- { type: "page", title: "工具箱", subtitle: "旅居小工具按需取用", emoji: "🛠️", url: "/tools" },
- { type: "page", title: "开通会员", subtitle: "VIP 匹配直播与课程", emoji: "✨", url: "/join" },
-];
-
-/** Secondary pages only surface when the query matches — avoid dumping the sitemap. */
-const EXTRA_PAGES: SearchResult[] = [
- { type: "page", title: "游牧代码电子书", subtitle: "在线阅读与下载版", emoji: "📖", url: "/book" },
- { type: "page", title: "游民访谈", subtitle: "真实旅居视频", emoji: "🎬", url: "/videos" },
- { type: "page", title: "游民服务", subtitle: "签证税务落地咨询", emoji: "🛎️", url: "/services" },
- { type: "page", title: "旅居助手", subtitle: "AI 推荐下一站城市", emoji: "🤖", url: "/ai" },
- { type: "page", title: "游民地图", subtitle: "会员分布与天气", emoji: "🗺️", url: "/map" },
- { type: "page", title: "数据报告", subtitle: "城市排名快照", emoji: "📊", url: "/report" },
- { type: "page", title: "意见反馈", subtitle: "Bug 与建议", emoji: "💬", url: "/feedback" },
- { type: "page", title: "内容投稿", subtitle: "文章视频电子书", emoji: "📝", url: "/submit" },
- { type: "page", title: "帮助中心", subtitle: "常见问题", emoji: "❓", url: "/help" },
- { type: "page", title: "联系我们", subtitle: "合作与媒体", emoji: "📬", url: "/contact" },
- { type: "page", title: "会员定价", subtitle: "免费版与 VIP 方案", emoji: "💎", url: "/pricing" },
- { type: "page", title: "通知中心", subtitle: "匹配与社区动态", emoji: "🔔", url: "/notifications" },
- { type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
- { type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
- { type: "page", title: "隐私政策", subtitle: "Cookie 与数据说明", emoji: "🔒", url: "/privacy" },
-];
-
-function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
+function searchLocal(
+ q: string,
+ hidePlanCompare: boolean,
+ core: SearchResult[],
+ extra: SearchResult[],
+ toolLinks: ReturnType
+): SearchResult[] {
const s = q.toLowerCase();
- const tools = TOOL_LINKS
+ const tools = toolLinks
.filter((t) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s))
.filter((t) => !hidePlanCompare || (t.href !== "/plan" && t.href !== "/compare"))
.slice(0, 8)
@@ -75,7 +85,7 @@ function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
const matchPage = (p: SearchResult) =>
p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s);
- const pages = [...CORE_PAGES, ...EXTRA_PAGES]
+ const pages = [...core, ...extra]
.filter(matchPage)
.filter((p) => !hidePlanCompare || (p.url !== "/plan" && p.url !== "/compare"));
@@ -83,14 +93,33 @@ function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
}
export default function GlobalSearch() {
+ const { t, locale } = useI18n();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
+ const [recent, setRecent] = useState([]);
const router = useRouter();
const isHome = usePathname() === "/";
+ const labels = useMemo(() => typeLabels(t.search), [t.search]);
+ const pages = useMemo(() => buildPages(t.search), [t.search]);
+ const toolLinks = useMemo(() => localizedToolLinks(locale), [locale]);
+
+ useEffect(() => {
+ if (!open) return;
+ setRecent(
+ loadRecentDestinations().slice(0, 5).map((d) => ({
+ type: "destination" as const,
+ title: d.name,
+ subtitle: `${d.country} · ¥${d.cost.toLocaleString()}/mo`,
+ emoji: d.emoji,
+ url: `/destinations/${d.slug}`,
+ }))
+ );
+ }, [open]);
+
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
@@ -109,7 +138,7 @@ export default function GlobalSearch() {
return;
}
setLoading(true);
- const local = searchLocal(q, isHome);
+ const local = searchLocal(q, isHome, pages.core, pages.extra, toolLinks);
try {
const data = await api.search(q);
const merged = [...local, ...data].slice(0, 16);
@@ -121,7 +150,7 @@ export default function GlobalSearch() {
} finally {
setLoading(false);
}
- }, [isHome]);
+ }, [isHome, pages.core, pages.extra, toolLinks]);
useEffect(() => {
const timer = setTimeout(() => doSearch(query), 250);
@@ -154,17 +183,17 @@ export default function GlobalSearch() {
};
const emptyShortcuts = isHome
- ? RING_SHORTCUTS.filter((p) => p.url !== "/plan" && p.url !== "/compare")
- : RING_SHORTCUTS;
+ ? pages.ring.filter((p) => p.url !== "/plan" && p.url !== "/compare")
+ : pages.ring;
return (
<>
- setOpen(true)} aria-label="搜索">
+ setOpen(true)} aria-label={t.search.aria}>
- 搜索...
+ {t.search.trigger}
⌘K
@@ -178,7 +207,7 @@ export default function GlobalSearch() {
setQuery(e.target.value)}
onKeyDown={onKeyDown}
@@ -186,9 +215,9 @@ export default function GlobalSearch() {
setOpen(false)}>ESC
- {loading &&
搜索中...
}
+ {loading &&
{t.search.loading}
}
{!loading && query && results.length === 0 && (
-
😢 没有找到「{query}」相关结果
+
{t.search.empty.replace("{q}", query)}
)}
{!loading &&
results.map((r, i) => (
@@ -203,12 +232,34 @@ export default function GlobalSearch() {
{r.title}
{r.subtitle}
- {TYPE_LABELS[r.type] || r.type}
+ {labels[r.type] || r.type}
))}
{!query && (
-
从一条路径开始
+ {recent.length > 0 && (
+ <>
+
{t.recent.searchLabel}
+
+ {recent.map((r) => (
+
navigate(r.url)}
+ >
+ {r.emoji}
+
+ {r.title}
+ {r.subtitle}
+
+ {labels.destination}
+
+ ))}
+
+ >
+ )}
+
{t.search.ringLabel}
{emptyShortcuts.map((p) => (
navigate(p.url)}>
@@ -217,7 +268,7 @@ export default function GlobalSearch() {
))}
-
⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭
+
{t.search.kbdHint}
)}
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx
index 84cbec1..643a732 100644
--- a/frontend/src/components/HomeClient.tsx
+++ b/frontend/src/components/HomeClient.tsx
@@ -24,10 +24,23 @@ import { useI18n } from "@/lib/i18n";
const lazy = (loader: () => Promise<{ default: import("react").ComponentType
}>) =>
dynamic(loader, { ssr: false, loading: () => null });
+function MatcherLoadingShell() {
+ return (
+
+ );
+}
+
const VisaSection = lazy(() => import("./VisaSection"));
const BlogSection = lazy(() => import("./BlogSection"));
const FAQSection = lazy(() => import("./FAQSection"));
-const DestinationMatcher = lazy(() => import("./DestinationMatcher"));
+const DestinationMatcher = dynamic(() => import("./DestinationMatcher"), {
+ ssr: false,
+ loading: () => ,
+});
const CookieConsent = lazy(() => import("./CookieConsent"));
const NomadTips = lazy(() => import("./NomadTips"));
const FeedbackWidget = lazy(() => import("./FeedbackWidget"));
@@ -35,6 +48,8 @@ const OnboardingTour = lazy(() => import("./OnboardingTour"));
const ToolsStrip = lazy(() => import("./ToolsStrip"));
const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts"));
const PwaInstallHint = lazy(() => import("./PwaInstallHint"));
+const RecentlyViewed = lazy(() => import("./RecentlyViewed"));
+const NomadDigest = lazy(() => import("./NomadDigest"));
interface Props {
stats: Stats;
@@ -56,7 +71,7 @@ export default function HomeClient(props: Props) {
useEffect(() => {
const hash = window.location.hash.slice(1);
- if (!hash) return;
+ if (!hash || hash === "matcher") return;
const scrollToHash = () => {
const el = document.getElementById(hash);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
@@ -67,8 +82,16 @@ export default function HomeClient(props: Props) {
useEffect(() => {
const onMatcher = () => setMatcherOpen(true);
+ const openFromHash = () => {
+ if (window.location.hash === "#matcher") setMatcherOpen(true);
+ };
+ openFromHash();
window.addEventListener("open-matcher", onMatcher);
- return () => window.removeEventListener("open-matcher", onMatcher);
+ window.addEventListener("hashchange", openFromHash);
+ return () => {
+ window.removeEventListener("open-matcher", onMatcher);
+ window.removeEventListener("hashchange", openFromHash);
+ };
}, []);
useEffect(() => {
@@ -106,6 +129,14 @@ export default function HomeClient(props: Props) {
};
}, []);
+ useEffect(() => {
+ if (!chromeReady) return;
+ const id = window.setTimeout(() => {
+ void import("./DestinationMatcher");
+ }, 1600);
+ return () => clearTimeout(id);
+ }, [chromeReady]);
+
const openMatcher = () => setMatcherOpen(true);
return (
@@ -119,9 +150,18 @@ export default function HomeClient(props: Props) {
+
+
+
+
+
+
+
+
+
@@ -148,7 +188,12 @@ export default function HomeClient(props: Props) {
setMatcherOpen(false)}
+ onClose={() => {
+ setMatcherOpen(false);
+ if (window.location.hash === "#matcher") {
+ window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
+ }
+ }}
/>
)}
diff --git a/frontend/src/components/HousingGuide.tsx b/frontend/src/components/HousingGuide.tsx
index 0580146..62775c2 100644
--- a/frontend/src/components/HousingGuide.tsx
+++ b/frontend/src/components/HousingGuide.tsx
@@ -1,8 +1,8 @@
"use client";
import { useMemo, useState } from "react";
-import Link from "next/link";
import type { Destination } from "@/lib/types";
+import ToolCityExits from "@/components/ToolCityExits";
interface HousingOption {
type: string;
@@ -77,7 +77,7 @@ export default function HousingGuide({ destinations }: Props) {
{dest?.emoji} {dest?.name} · 住宿方案
- 城市详情 →
+ {dest && }
diff --git a/frontend/src/components/JoinClient.tsx b/frontend/src/components/JoinClient.tsx
index 0a5d1a5..378c00d 100644
--- a/frontend/src/components/JoinClient.tsx
+++ b/frontend/src/components/JoinClient.tsx
@@ -9,6 +9,12 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+import { meetupCityHref } from "@/lib/meetupLinks";
+
+const DRAFT_KEY = "nomadro-join-profile-draft";
+
+type JoinDraft = { city: string; bio: string };
export default function JoinClient() {
const { user, token } = useAuth();
@@ -16,11 +22,29 @@ export default function JoinClient() {
const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
- const [city, setCity] = useState("清迈");
+ const [city, setCity] = useState("");
const [bio, setBio] = useState("");
const [loading, setLoading] = useState(false);
const [vip, setVip] = useState(false);
const [checkingVip, setCheckingVip] = useState(Boolean(token));
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ const draft = loadDraft
(DRAFT_KEY);
+ if (draft) {
+ if (draft.city) setCity(draft.city);
+ if (draft.bio) setBio(draft.bio);
+ }
+ setReady(true);
+ }, []);
+
+ useEffect(() => {
+ if (!ready) return;
+ const id = window.setTimeout(() => {
+ saveDraft(DRAFT_KEY, { city, bio });
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [city, bio, ready]);
useEffect(() => {
if (!token) {
@@ -44,7 +68,12 @@ export default function JoinClient() {
setLoading(true);
try {
await api.socialJoin(token, { city, bio, lookingFor: ["friends", "explore"] });
- toast(t.join.profileOk, "success");
+ clearDraft(DRAFT_KEY);
+ const cityHint = city.trim().split(/[,/·|]/)[0]?.trim();
+ toast(t.join.profileOk, "success", {
+ href: cityHint ? meetupCityHref(cityHint) : "/dating",
+ label: cityHint ? t.common.cityMeetups : t.nav.dating,
+ });
} catch {
toast(t.join.profileFail, "error");
} finally {
@@ -91,19 +120,20 @@ export default function JoinClient() {
{t.join.title}
{t.join.subtitle}
{!checkingVip && vip && (
- ✨ 你已是 VIP · 权益已生效
+ {t.join.vipActive}
)}
{t.join.step1}
+
{t.join.draftHint}
setCity(e.target.value)} />
setBio(e.target.value)} rows={3} />
-
- {t.join.saveProfile}
+ void submit()}>
+ {loading ? t.join.saving : t.join.saveProfile}
@@ -115,7 +145,7 @@ export default function JoinClient() {
- 🎙️ {t.join.perk3}
- 📚 {t.join.perk4}
-
+ void pay()}>
{vip ? t.join.goDating : t.join.payBtn}
{!user && {t.nav.login}}
diff --git a/frontend/src/components/JoinPaidClient.tsx b/frontend/src/components/JoinPaidClient.tsx
index e54355b..d8e3045 100644
--- a/frontend/src/components/JoinPaidClient.tsx
+++ b/frontend/src/components/JoinPaidClient.tsx
@@ -6,6 +6,7 @@ import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
+import RingNext from "@/components/RingNext";
export default function JoinPaidClient() {
const { token } = useAuth();
@@ -71,8 +72,15 @@ export default function JoinPaidClient() {
{t.join.goDating}
{t.join.goDigital}
- 去活动
+ {t.nav.meetups}
+
>
)}
{status === "fail" && (
diff --git a/frontend/src/components/KeyboardShortcuts.tsx b/frontend/src/components/KeyboardShortcuts.tsx
index 29ff7d4..9342f3b 100644
--- a/frontend/src/components/KeyboardShortcuts.tsx
+++ b/frontend/src/components/KeyboardShortcuts.tsx
@@ -1,26 +1,31 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
+import { useI18n } from "@/lib/i18n";
interface Props {
onOpenMatcher?: () => void;
}
-const SHORTCUTS = [
- { keys: ["Ctrl", "K"], label: "全局搜索", icon: "🔍" },
- { keys: ["M"], label: "智能匹配", icon: "🎯" },
- { keys: ["N"], label: "下一站", icon: "🧭" },
- { keys: ["P"], label: "旅居计划", icon: "🗓️" },
- { keys: ["T"], label: "打开工具箱", icon: "🛠️" },
- { keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
- { keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
-];
-
export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
const router = useRouter();
+ const { t } = useI18n();
const [open, setOpen] = useState(false);
+ const shortcuts = useMemo(
+ () => [
+ { keys: ["Ctrl", "K"], label: t.shortcuts.search, icon: "🔍" },
+ { keys: ["M"], label: t.shortcuts.matcher, icon: "🎯" },
+ { keys: ["N"], label: t.shortcuts.nextStop, icon: "🧭" },
+ { keys: ["P"], label: t.shortcuts.plan, icon: "🗓️" },
+ { keys: ["T"], label: t.shortcuts.tools, icon: "🛠️" },
+ { keys: ["?"], label: t.shortcuts.help, icon: "⌨️" },
+ { keys: ["Esc"], label: t.shortcuts.esc, icon: "✕" },
+ ],
+ [t]
+ );
+
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
@@ -59,18 +64,16 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
className="modal-overlay open shortcuts-overlay"
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
>
-
-
setOpen(false)} aria-label="关闭">
+
+
setOpen(false)} aria-label={t.shortcuts.close}>
✕
⌨️ SHORTCUTS
-
键盘快捷键
-
- 提升浏览效率,按 ? 随时唤起
-
+
{t.shortcuts.title}
+
{t.shortcuts.subtitle}
- {SHORTCUTS.map((s) => (
+ {shortcuts.map((s) => (
{s.icon}
{s.label}
diff --git a/frontend/src/components/MeetupLiveClient.tsx b/frontend/src/components/MeetupLiveClient.tsx
index c700756..564954e 100644
--- a/frontend/src/components/MeetupLiveClient.tsx
+++ b/frontend/src/components/MeetupLiveClient.tsx
@@ -6,10 +6,13 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
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 }) {
const { token } = useAuth();
const { t } = useI18n();
+ const rings = useRingSteps();
const [session, setSession] = useState
(null);
const [error, setError] = useState(false);
const [layout, setLayout] = useState<"split" | "video" | "chat">("split");
@@ -31,8 +34,8 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
-
无法打开直播间
-
活动可能不存在,或暂时不可用
+
{t.live.unavailable}
+
{t.live.unavailableDesc}
{t.live.back}
@@ -58,9 +61,9 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
reason === "vip_required"
? t.live.vipRequired
: reason === "host_required"
- ? "仅主办方可进入此房间"
+ ? t.live.hostRequired
: reason === "offline"
- ? "此活动为线下场次,没有在线直播间"
+ ? t.live.offlineOnly
: t.live.loginRequired;
const href =
reason === "vip_required"
@@ -104,7 +107,7 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
className={`filter-btn${layout === m ? " active" : ""}`}
onClick={() => setLayout(m)}
>
- {m === "split" ? "分屏" : m === "video" ? "视频" : "聊天"}
+ {m === "split" ? t.live.layoutSplit : m === "video" ? t.live.layoutVideo : t.live.layoutChat}
))}
@@ -123,7 +126,7 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
)}
{!session.videoUrl && !session.chatUrl && (
-
直播链接尚未配置
+
{t.live.linksMissing}
{t.live.back}
@@ -135,6 +138,9 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
)}
+
+
+
);
}
diff --git a/frontend/src/components/MeetupsClient.tsx b/frontend/src/components/MeetupsClient.tsx
index 16415a6..2e8a0e5 100644
--- a/frontend/src/components/MeetupsClient.tsx
+++ b/frontend/src/components/MeetupsClient.tsx
@@ -7,16 +7,12 @@ import { api } from "@/lib/api";
import { useToast } from "@/lib/toast";
import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n";
-import type { Meetup } from "@/lib/types";
+import type { MatchProfile, Meetup } from "@/lib/types";
import NewsletterSubscribe from "@/components/NewsletterSubscribe";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
-
-const MODE_LABELS: Record
= {
- online: "💻 线上",
- offline: "📍 线下",
- hybrid: "🔀 混合",
-};
+import { buildMeetupIcs, downloadMeetupIcs } from "@/lib/meetupIcs";
+import { meetupEventHref } from "@/lib/meetupLinks";
interface Props {
meetups: Meetup[];
@@ -33,6 +29,15 @@ export default function MeetupsClient({ meetups }: Props) {
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
const [rsvpIds, setRsvpIds] = useState>(new Set());
const [busyId, setBusyId] = useState(null);
+ const [peersFor, setPeersFor] = useState(null);
+ const [peers, setPeers] = useState([]);
+ const [peersLoading, setPeersLoading] = useState(false);
+
+ const modeLabels: Record = {
+ online: `💻 ${t.meetups.modeOnline}`,
+ offline: `📍 ${t.meetups.modeOffline}`,
+ hybrid: `🔀 ${t.meetups.modeHybrid}`,
+ };
useEffect(() => {
if (!token) {
@@ -56,9 +61,47 @@ export default function MeetupsClient({ meetups }: Props) {
return list;
}, [meetups, mode, cityFilter]);
+ const myMeetups = useMemo(
+ () => meetups.filter((m) => rsvpIds.has(m.id)),
+ [meetups, rsvpIds]
+ );
+
+ const exportMyCalendar = () => {
+ const ics = buildMeetupIcs(myMeetups, "nomadro meetups");
+ if (!ics) {
+ toast(t.meetups.exportCalFail, "info");
+ return;
+ }
+ downloadMeetupIcs("nomadro-meetups.ics", ics);
+ toast(t.meetups.exportCalDone);
+ };
+
+ const exportOne = (m: Meetup) => {
+ const ics = buildMeetupIcs([m], m.title);
+ if (!ics) {
+ toast(t.meetups.exportCalFail, "info");
+ return;
+ }
+ downloadMeetupIcs(`nomadro-${m.id}.ics`, ics);
+ toast(t.meetups.exportCalDone);
+ };
+
+ const loadPeers = async (meetupId: string) => {
+ setPeersFor(meetupId);
+ setPeersLoading(true);
+ try {
+ const res = await api.getMeetupSocialSuggestions(meetupId);
+ setPeers(res.items.slice(0, 4));
+ } catch {
+ setPeers([]);
+ } finally {
+ setPeersLoading(false);
+ }
+ };
+
const handleRsvp = async (meetup: Meetup) => {
if (!token) {
- toast("登录后报名,名额才会保留", "info");
+ toast(t.meetups.loginToast, "info");
router.push(`/login?next=${encodeURIComponent("/meetups")}`);
return;
}
@@ -69,7 +112,11 @@ export default function MeetupsClient({ meetups }: Props) {
const next = new Set(rsvpIds);
next.delete(meetup.id);
setRsvpIds(next);
- toast("已取消报名", "success");
+ if (peersFor === meetup.id) {
+ setPeersFor(null);
+ setPeers([]);
+ }
+ toast(t.meetups.cancelOk, "success");
} catch {
toast(t.meetups.rsvpFail, "error");
} finally {
@@ -81,7 +128,11 @@ export default function MeetupsClient({ meetups }: Props) {
try {
const res = await api.rsvpMeetup(meetup.id, undefined, token);
setRsvpIds(new Set(rsvpIds).add(meetup.id));
- toast(res.message || t.meetups.rsvpOk, "success");
+ toast(res.message || t.meetups.rsvpOk, "success", {
+ href: "/dating",
+ label: t.meetups.goDating,
+ });
+ void loadPeers(meetup.id);
} catch {
toast(t.meetups.rsvpFail, "error");
} finally {
@@ -104,13 +155,18 @@ export default function MeetupsClient({ meetups }: Props) {
{t.meetups.subtitle}
{!token && (
- 登录 后报名,名额会同步到你的账号
+ {t.nav.login} · {t.meetups.loginHint}
)}
{t.meetups.hostBtn}
+ {myMeetups.length > 0 && (
+
+ 📅 {t.meetups.exportCal}
+
+ )}
{cityFilter && (
setMode(m)}
>
- {m === "all" ? t.meetups.all : MODE_LABELS[m]}
+ {m === "all" ? t.meetups.all : modeLabels[m]}
))}
@@ -142,7 +198,7 @@ export default function MeetupsClient({ meetups }: Props) {
{m.title}
- {MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time}
+ {modeLabels[m.mode]} · {m.city} · {m.date} {m.time}
@@ -164,13 +220,18 @@ export default function MeetupsClient({ meetups }: Props) {
{t.meetups.liveBtn}
)}
+ {joined && (
+
exportOne(m)}>
+ {t.meetups.addToCal}
+
+ )}
handleRsvp(m)}
disabled={busyId === m.id}
>
- {joined ? "取消报名" : t.meetups.rsvpBtn}
+ {joined ? t.meetups.cancelRsvp : t.meetups.rsvpBtn}
@@ -179,6 +240,38 @@ export default function MeetupsClient({ meetups }: Props) {
{t.meetups.viewCity} →
)}
+ {joined && peersFor === m.id && (
+
+
+ {t.meetups.meetPeers}
+
+ {t.meetups.goDating}
+
+
+ {peersLoading ? (
+
{t.common.loading}
+ ) : peers.length === 0 ? null : (
+
+ {peers.map((p) => (
+ -
+
+ {p.name}
+ {p.location ? {p.location} : null}
+
+
+ {t.meetups.viewProfile}
+
+
+ ))}
+
+ )}
+
+ )}
+ {(m.mode === "online" || m.mode === "hybrid") && joined && (
+
+ {t.meetups.liveBtn} →
+
+ )}
);
})}
diff --git a/frontend/src/components/MeetupsHostClient.tsx b/frontend/src/components/MeetupsHostClient.tsx
index 3848039..d94e5f0 100644
--- a/frontend/src/components/MeetupsHostClient.tsx
+++ b/frontend/src/components/MeetupsHostClient.tsx
@@ -1,33 +1,92 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+
+const DRAFT_KEY = "nomadro-meetup-host-draft";
+
+type HostDraft = {
+ title: string;
+ city: string;
+ date: string;
+ time: string;
+ venue: string;
+ description: string;
+ mode: "online" | "offline" | "hybrid";
+ access_level: string;
+ max_attendees: number;
+ emoji: string;
+};
+
+const EMPTY: HostDraft = {
+ title: "",
+ city: "",
+ date: "",
+ time: "19:00",
+ venue: "",
+ description: "",
+ mode: "online",
+ access_level: "public",
+ max_attendees: 30,
+ emoji: "🎉",
+};
export default function MeetupsHostClient() {
const { token } = useAuth();
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
+ const rings = useRingSteps();
const [busy, setBusy] = useState(false);
- const [form, setForm] = useState({
- title: "",
- city: "线上",
- date: "",
- time: "19:00",
- venue: "",
- description: "",
- mode: "online" as "online" | "offline" | "hybrid",
- access_level: "public",
- max_attendees: 30,
- emoji: "🎉",
- });
+ const [ready, setReady] = useState(false);
+ const [form, setForm] = useState
(EMPTY);
- const set = (k: string, v: string | number) => setForm((f) => ({ ...f, [k]: v }));
+ useEffect(() => {
+ const draft = loadDraft>(DRAFT_KEY);
+ if (draft) {
+ setForm({
+ ...EMPTY,
+ ...draft,
+ mode: draft.mode || EMPTY.mode,
+ max_attendees: typeof draft.max_attendees === "number" ? draft.max_attendees : EMPTY.max_attendees,
+ });
+ if (draft.title || draft.description || draft.date) {
+ toast(t.meetups.draftRestored, "info");
+ }
+ } else {
+ setForm({ ...EMPTY, city: t.meetups.modeOnline });
+ }
+ setReady(true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!ready || !token) return;
+ const id = window.setTimeout(() => {
+ const empty =
+ !form.title.trim() &&
+ !form.description.trim() &&
+ !form.date &&
+ !form.venue.trim();
+ if (empty) {
+ clearDraft(DRAFT_KEY);
+ return;
+ }
+ saveDraft(DRAFT_KEY, form);
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [form, ready, token]);
+
+ const set = (k: keyof HostDraft, v: string | number) =>
+ setForm((f) => ({ ...f, [k]: v }));
if (!token) {
return (
@@ -50,7 +109,7 @@ export default function MeetupsHostClient() {
return;
}
if (!form.title.trim() || !form.date) {
- toast("请填写活动名称和日期", "info");
+ toast(t.meetups.hostNeedFields, "info");
return;
}
setBusy(true);
@@ -58,9 +117,14 @@ export default function MeetupsHostClient() {
await api.createMeetup(token, {
...form,
title: form.title.trim(),
+ city: form.city.trim() || t.meetups.modeOnline,
description: form.description.trim(),
});
- toast(t.meetups.hostOk, "success");
+ clearDraft(DRAFT_KEY);
+ toast(t.meetups.hostOk, "success", {
+ href: "/community",
+ label: t.nav.community,
+ });
router.push("/meetups");
} catch {
toast(t.meetups.hostFail, "error");
@@ -76,6 +140,7 @@ export default function MeetupsHostClient() {
{t.meetups.hostTitle}
{t.meetups.hostSubtitle}
+
{t.meetups.draftHint}
@@ -84,9 +149,9 @@ export default function MeetupsHostClient() {
set("city", e.target.value)} />
set("date", e.target.value)} />
@@ -100,10 +165,11 @@ export default function MeetupsHostClient() {
placeholder={t.meetups.hostDesc}
maxLength={2000}
/>
-
- {busy ? "创建中…" : t.meetups.hostSubmit}
+ void submit()}>
+ {busy ? t.meetups.hosting : t.meetups.hostSubmit}
+
);
diff --git a/frontend/src/components/MemberMapClient.tsx b/frontend/src/components/MemberMapClient.tsx
index 2710ab8..6b41606 100644
--- a/frontend/src/components/MemberMapClient.tsx
+++ b/frontend/src/components/MemberMapClient.tsx
@@ -7,12 +7,13 @@ import { useI18n } from "@/lib/i18n";
import type { MemberMapItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { meetupCityHref } from "@/lib/meetupLinks";
export default function MemberMapClient() {
const { t } = useI18n();
const rings = useRingSteps();
const [members, setMembers] = useState([]);
- const [weather, setWeather] = useState<{ name: string; temperature: number | null }[]>([]);
+ const [weather, setWeather] = useState<{ name: string; slug?: string; temperature: number | null }[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -28,6 +29,8 @@ export default function MemberMapClient() {
@@ -35,16 +38,19 @@ export default function MemberMapClient() {
{t.mapPage.title}
{t.mapPage.subtitle}
- {loading && 加载地图…
}
+ {loading && {t.mapPage.loading}
}
{!loading && members.length === 0 && (
-
暂无公开会员分布
+
{t.mapPage.empty}
- 去匹配
+ {t.mapPage.goDating}
- 去活动
+ {t.mapPage.goMeetups}
+
+
+ {t.nav.community}
@@ -55,7 +61,11 @@ export default function MemberMapClient() {
{m.emoji}
{m.name}
-
{m.city}
+
+
+ {m.city}
+
+
))}
@@ -63,14 +73,18 @@ export default function MemberMapClient() {
{t.mapPage.weather}
{!loading && weather.length === 0 ? (
- 天气暂不可用
+ {t.mapPage.weatherEmpty}
) : (
{weather.map((w) => (
-
+
{w.name}
{w.temperature != null ? `${w.temperature}°C` : "—"}
-
+
))}
)}
diff --git a/frontend/src/components/MemberProfileClient.tsx b/frontend/src/components/MemberProfileClient.tsx
index 50b1bb3..aa2e972 100644
--- a/frontend/src/components/MemberProfileClient.tsx
+++ b/frontend/src/components/MemberProfileClient.tsx
@@ -3,11 +3,23 @@
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { MemberProfile } from "@/lib/types";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
+import { meetupCityHref } from "@/lib/meetupLinks";
+
+function cityFromLocation(location?: string) {
+ const raw = location?.trim();
+ if (!raw) return undefined;
+ return raw.split(/[,/·|]/)[0]?.trim() || undefined;
+}
export default function MemberProfileClient({ profile }: { profile: MemberProfile }) {
const { t } = useI18n();
+ const rings = useRingSteps();
const photo = profile.photo || "";
const isUrl = photo.startsWith("http");
+ const city = cityFromLocation(profile.location);
+ const meetupsHref = city ? meetupCityHref(city) : "/meetups";
return (
@@ -16,6 +28,8 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
← {t.nav.dating}
·
{t.nav.chat}
+
·
+
{t.nav.meetups}
@@ -30,8 +44,16 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
{profile.name}
{profile.vip ? " ✨" : ""}
-
{profile.location || "全球游民"}
-
{profile.bio || "这位游民还没写自我介绍"}
+
+ {city ? (
+
+ {profile.location}
+
+ ) : (
+ profile.location || t.dating.globalNomad
+ )}
+
+
{profile.bio || t.dating.noBio}
{(profile.tags || []).map((tag) => (
@@ -41,13 +63,17 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
- 继续匹配
+ {t.dating.keepSwiping}
-
- 去活动认识人
+
+ {city ? t.common.cityMeetups : t.dating.meetAtEvents}
+
+
+ {t.nav.community}
+
);
diff --git a/frontend/src/components/MovePlanClient.tsx b/frontend/src/components/MovePlanClient.tsx
index 1229a0d..92a301f 100644
--- a/frontend/src/components/MovePlanClient.tsx
+++ b/frontend/src/components/MovePlanClient.tsx
@@ -19,6 +19,7 @@ import { useI18n } from "@/lib/i18n";
import type { Destination, TripItem, Visa } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { meetupCityHref } from "@/lib/meetupLinks";
interface Props {
destinations: Destination[];
@@ -38,12 +39,12 @@ function scoreDest(d: Destination) {
export default function MovePlanClient({ destinations, visas = [] }: Props) {
const { toast } = useToast();
- const { t } = useI18n();
+ const { t, locale } = useI18n();
const rings = useRingSteps();
const { token, user, planSyncStatus } = useAuth();
const [trip, setTrip] = useState([]);
const [meta, setMeta] = useState(() => ({
- title: "我的旅居计划",
+ title: "",
startMonth: "",
monthlyBudget: 0,
checklist: {},
@@ -68,9 +69,13 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
} else {
setTrip(loadTrip());
}
- setMeta(loadPlanMeta());
+ const loaded = loadPlanMeta();
+ if (!loaded.title.trim() || loaded.title === "我的旅居计划") {
+ loaded.title = t.plan.defaultTitle;
+ }
+ setMeta(loaded);
setReady(true);
- }, [toast, t.plan.importedShare]);
+ }, [toast, t.plan.importedShare, t.plan.defaultTitle]);
useEffect(() => {
const refresh = () => {
@@ -198,19 +203,37 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
const exportTrip = () => {
const lines = [
`# ${meta.title}`,
- meta.startMonth ? `出发月:${meta.startMonth}` : "",
+ meta.startMonth ? t.plan.exportStartMonth.replace("{month}", meta.startMonth) : "",
"",
- ...timeline.map((t, i) => {
- const range = t.range ? `(${t.range})` : "";
- const note = t.note ? `\n 备注:${t.note}` : "";
- const visaLine = t.visa ? `\n 签证:${t.visa.text}` : "";
- return `${i + 1}. ${t.emoji} **${t.name}, ${t.country}** — ${t.months} 个月 · ¥${(t.cost * t.months).toLocaleString()}${range}${note}${visaLine}`;
+ ...timeline.map((stop, i) => {
+ const range = stop.range ? `(${stop.range})` : "";
+ const note = stop.note ? `\n ${t.plan.exportNote}${stop.note}` : "";
+ const visaLine = stop.visa ? `\n ${t.plan.exportVisa}${stop.visa.text}` : "";
+ return t.plan.exportStop
+ .replace("{n}", String(i + 1))
+ .replace("{emoji}", stop.emoji)
+ .replace("{name}", stop.name)
+ .replace("{country}", stop.country)
+ .replace("{months}", String(stop.months))
+ .replace("{cost}", (stop.cost * stop.months).toLocaleString())
+ .replace("{range}", range)
+ .replace("{note}", note)
+ .replace("{visa}", visaLine);
}),
"",
- `> 总计 **${totalMonths} 个月** · **¥${totalCost.toLocaleString()}** · 均月 ¥${avgMonth.toLocaleString()}`,
- `> 出发就绪度 **${readiness}%**(${checkedCount}/${MOVE_CHECKLIST.length})`,
+ t.plan.exportTotal
+ .replace("{months}", String(totalMonths))
+ .replace("{cost}", totalCost.toLocaleString())
+ .replace("{avg}", avgMonth.toLocaleString()),
+ t.plan.exportReady
+ .replace("{pct}", String(readiness))
+ .replace("{checked}", String(checkedCount))
+ .replace("{total}", String(MOVE_CHECKLIST.length)),
"",
- `_由 nomadro 旅居计划中心生成 · ${new Date().toLocaleDateString("zh-CN")}_`,
+ t.plan.exportFooter.replace(
+ "{date}",
+ new Date().toLocaleDateString(locale === "en" ? "en-US" : "zh-CN")
+ ),
].filter(Boolean);
const blob = new Blob([lines.join("\n")], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
@@ -250,9 +273,24 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
};
const copyPlain = async () => {
- const text = timeline
- .map((t, i) => `${i + 1}. ${t.emoji} ${t.name}, ${t.country} — ${t.months}个月 ¥${(t.cost * t.months).toLocaleString()}${t.range ? ` (${t.range})` : ""}`)
- .join("\n") + `\n\n总计: ${totalMonths}个月 · ¥${totalCost.toLocaleString()} · 就绪 ${readiness}%`;
+ const text =
+ timeline
+ .map((stop, i) =>
+ t.plan.plainStop
+ .replace("{n}", String(i + 1))
+ .replace("{emoji}", stop.emoji)
+ .replace("{name}", stop.name)
+ .replace("{country}", stop.country)
+ .replace("{months}", String(stop.months))
+ .replace("{cost}", (stop.cost * stop.months).toLocaleString())
+ .replace("{range}", stop.range ? ` (${stop.range})` : "")
+ )
+ .join("\n") +
+ "\n\n" +
+ t.plan.plainTotal
+ .replace("{months}", String(totalMonths))
+ .replace("{cost}", totalCost.toLocaleString())
+ .replace("{pct}", String(readiness));
await navigator.clipboard.writeText(text);
toast(t.plan.copied);
};
@@ -403,10 +441,10 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
{t.plan.empty}
- 智能下一站
+ {t.plan.emptyNextStop}
- 城市对比
+ {t.plan.emptyCompare}
{t.plan.goDest}
@@ -431,11 +469,16 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
{stop.range && {stop.range}}
- move(i, -1)}>↑
- move(i, 1)}>↓
- removeCity(stop.slug)}>✕
+ move(i, -1)}>↑
+ move(i, 1)}>↓
+ removeCity(stop.slug)}>✕
+
+
+ {t.meetups.tripEvents} · {stop.name} →
+
+
- - 时长 {v.duration}
- - 收入要求 {v.income_req}
- - 审批 {v.approval_time}
- - 难度 {v.difficulty_label}
+ - {t.plan.visaDuration} {v.duration}
+ - {t.plan.visaIncome} {v.income_req}
+ - {t.plan.visaApproval} {v.approval_time}
+ - {t.plan.visaDifficulty} {v.difficulty_label}
{v.extra &&
{v.extra}
}
@@ -511,7 +554,10 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
)}
{missingVisaCountries.length > 0 && tripVisas.length > 0 && (
- 暂无收录:{missingVisaCountries.join("、")} — 请以官方政策为准,并确认免签天数。
+ {t.plan.visaMissingCountries.replace(
+ "{countries}",
+ missingVisaCountries.join(locale === "en" ? ", " : "、")
+ )}
)}
@@ -621,7 +667,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
diff --git a/frontend/src/components/NextStopClient.tsx b/frontend/src/components/NextStopClient.tsx
index f8ab56f..f632745 100644
--- a/frontend/src/components/NextStopClient.tsx
+++ b/frontend/src/components/NextStopClient.tsx
@@ -9,14 +9,11 @@ import { useI18n } from "@/lib/i18n";
import type { Destination, Meetup, NomadRoute, RecommendedDestination } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { meetupCityHref, meetupEventHref } from "@/lib/meetupLinks";
+import { loadNextStopPrefs, saveNextStopPrefs } from "@/lib/nextStopPrefs";
-const PRIMARY_TAGS = ["低成本", "高速网络", "温暖气候", "社区活跃"];
-const EXTRA_TAGS = ["签证友好", "海滨", "美食"];
-const CLIMATE_OPTIONS = [
- { value: "mild", label: "🌤️ 温和" },
- { value: "warm", label: "☀️ 偏暖" },
- { value: "cool", label: "🍂 偏凉" },
-];
+const PRIMARY_TAG_VALUES = ["低成本", "高速网络", "温暖气候", "社区活跃"] as const;
+const EXTRA_TAG_VALUES = ["签证友好", "海滨", "美食"] as const;
interface Props {
destinations: Destination[];
@@ -24,7 +21,12 @@ interface Props {
initialRoutes: NomadRoute[];
}
-function fallbackScore(d: Destination, budget: number, internet: number): RecommendedDestination {
+function fallbackScore(
+ d: Destination,
+ budget: number,
+ internet: number,
+ reasons: { inBudget: string; overBudget: string; netOk: string; netCheck: string }
+): RecommendedDestination {
let score = d.rating * 10;
if (d.cost <= budget) score += 25;
if (d.speed >= internet) score += 20;
@@ -32,8 +34,8 @@ function fallbackScore(d: Destination, budget: number, internet: number): Recomm
...d,
match_score: Math.round(score),
match_reasons: [
- d.cost <= budget ? "预算内" : "略超预算",
- d.speed >= internet ? "网络达标" : "需确认网络",
+ d.cost <= budget ? reasons.inBudget : reasons.overBudget,
+ d.speed >= internet ? reasons.netOk : reasons.netCheck,
],
};
}
@@ -47,21 +49,79 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
const [climate, setClimate] = useState("mild");
const [selectedTags, setSelectedTags] = useState
(["社区活跃"]);
const [moreTags, setMoreTags] = useState(false);
+ const [prefsReady, setPrefsReady] = useState(false);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [meetups] = useState(initialMeetups);
const [routes] = useState(initialRoutes);
+ const tagLabel = (value: string) => {
+ const map: Record = {
+ 低成本: t.nextStop.tagLowCost,
+ 高速网络: t.nextStop.tagFastNet,
+ 温暖气候: t.nextStop.tagWarm,
+ 社区活跃: t.nextStop.tagCommunity,
+ 签证友好: t.nextStop.tagVisa,
+ 海滨: t.nextStop.tagBeach,
+ 美食: t.nextStop.tagFood,
+ };
+ return map[value] || value;
+ };
+
+ const climateOptions = useMemo(
+ () => [
+ { value: "mild", label: t.nextStop.climateMild },
+ { value: "warm", label: t.nextStop.climateWarm },
+ { value: "cool", label: t.nextStop.climateCool },
+ ],
+ [t]
+ );
+
+ useEffect(() => {
+ const prefs = loadNextStopPrefs();
+ setBudget(prefs.budget);
+ setInternet(prefs.internet);
+ setClimate(prefs.climate);
+ setSelectedTags(prefs.selectedTags);
+ setPrefsReady(true);
+ }, []);
+
+ useEffect(() => {
+ if (!prefsReady) return;
+ saveNextStopPrefs({ budget, internet, climate, selectedTags });
+ }, [prefsReady, budget, internet, climate, selectedTags]);
+
+ const reasonLabels = useMemo(
+ () => ({
+ inBudget: t.nextStop.reasonInBudget,
+ overBudget: t.nextStop.reasonOverBudget,
+ netOk: t.nextStop.reasonNetOk,
+ netCheck: t.nextStop.reasonNetCheck,
+ }),
+ [t]
+ );
+
+ const localizeReason = (reason: string) => {
+ const map: Record = {
+ 预算内: t.nextStop.reasonInBudget,
+ 略超预算: t.nextStop.reasonOverBudget,
+ 网络达标: t.nextStop.reasonNetOk,
+ 需确认网络: t.nextStop.reasonNetCheck,
+ };
+ return map[reason] || reason;
+ };
+
const localFallback = useMemo(
() =>
[...destinations]
- .map((d) => fallbackScore(d, budget, internet))
+ .map((d) => fallbackScore(d, budget, internet, reasonLabels))
.sort((a, b) => b.match_score - a.match_score)
.slice(0, 12),
- [destinations, budget, internet]
+ [destinations, budget, internet, reasonLabels]
);
const fetchRecommendations = useCallback(async () => {
+ if (!prefsReady) return;
setLoading(true);
try {
const data = await api.getNextStop({
@@ -77,7 +137,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
} finally {
setLoading(false);
}
- }, [budget, internet, climate, selectedTags, localFallback]);
+ }, [prefsReady, budget, internet, climate, selectedTags, localFallback]);
useEffect(() => {
const timer = setTimeout(fetchRecommendations, 300);
@@ -105,8 +165,14 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
};
const addToPlan = (dest: Destination) => {
- mergeDestinationsIntoTrip([dest]);
- toast(t.nextStop.addedPlan.replace("{city}", dest.name), "success");
+ const { added } = mergeDestinationsIntoTrip([dest]);
+ toast(
+ added
+ ? t.nextStop.addedPlan.replace("{city}", dest.name)
+ : t.common.alreadyInPlan,
+ added ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
+ );
};
const resolveStop = (slug: string) => destinations.find((d) => d.slug === slug);
@@ -138,7 +204,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
value={budget}
onChange={(e) => setBudget(Number(e.target.value))}
/>
- ¥{budget.toLocaleString()}/月
+ ¥{budget.toLocaleString()}/{t.common.monthsUnit}
@@ -157,7 +223,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
- {CLIMATE_OPTIONS.map((o) => (
+ {climateOptions.map((o) => (
- {(moreTags ? [...PRIMARY_TAGS, ...EXTRA_TAGS] : PRIMARY_TAGS).map((tag) => (
+ {(moreTags
+ ? [...PRIMARY_TAG_VALUES, ...EXTRA_TAG_VALUES]
+ : [...PRIMARY_TAG_VALUES]
+ ).map((tag) => (
toggleTag(tag)}
>
- {tag}
+ {tagLabel(tag)}
))}
{!moreTags && (
setMoreTags(true)}>
- 更多…
+ {t.nextStop.moreTags}
)}
@@ -204,7 +273,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
{best.description}
{best.match_reasons?.map((r) => (
- {r}
+ {localizeReason(r)}
))}
@@ -232,10 +301,10 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
{dest.name}
{dest.match_score}
- ¥{dest.cost}/月 · {dest.speed}Mbps · ⭐{dest.rating}
+ ¥{dest.cost}/{t.common.monthsUnit} · {dest.speed}Mbps · ⭐{dest.rating}
{dest.match_reasons?.slice(0, 2).map((r) => (
- {r}
+ {localizeReason(r)}
))}
@@ -277,7 +346,10 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
.filter((d): d is Destination => !!d);
if (stops.length) {
mergeDestinationsIntoTrip(stops);
- toast(t.nextStop.routeAdded, "success");
+ toast(t.nextStop.routeAdded, "success", {
+ href: "/plan",
+ label: t.strip.openPlan,
+ });
}
}}
>
@@ -294,7 +366,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
{t.nextStop.meetups}{best ? ` · ${best.name}` : ""}
-
+
{t.nextStop.allMeetups} →
@@ -302,7 +374,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
{relevantMeetups.map((m) => (
{m.emoji}
@@ -318,7 +390,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
-
+
);
diff --git a/frontend/src/components/NomadDigest.tsx b/frontend/src/components/NomadDigest.tsx
new file mode 100644
index 0000000..1221e15
--- /dev/null
+++ b/frontend/src/components/NomadDigest.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import Link from "next/link";
+import { useI18n } from "@/lib/i18n";
+import { useAuth } from "@/lib/auth";
+import { api } from "@/lib/api";
+import { loadRecentDestinations } from "@/lib/recentDestinations";
+import { loadAiHistory } from "@/lib/aiHistory";
+import { loadWatchLater } from "@/lib/watchLater";
+import { loadTrip } from "@/lib/tripStorage";
+import { loadPlanMeta, MOVE_CHECKLIST } from "@/lib/planMeta";
+import type { Meetup, TripItem } from "@/lib/types";
+import { useEffect, useState } from "react";
+
+interface Props {
+ rsvpCount?: number;
+ upcoming?: Meetup[];
+ /** Home resume strip: hide when empty; use resume copy. */
+ compact?: boolean;
+}
+
+export default function NomadDigest({ rsvpCount: rsvpProp, upcoming = [], compact = false }: Props) {
+ const { t } = useI18n();
+ const { token } = useAuth();
+ const [ready, setReady] = useState(false);
+ const [tripLen, setTripLen] = useState(0);
+ const [readiness, setReadiness] = useState(0);
+ const [recentCount, setRecentCount] = useState(0);
+ const [watchCount, setWatchCount] = useState(0);
+ const [lastAi, setLastAi] = useState("");
+ const [topRecent, setTopRecent] = useState<{ slug: string; emoji: string; name: string }[]>([]);
+ const [rsvpCount, setRsvpCount] = useState(rsvpProp ?? 0);
+ const [nextUp, setNextUp] = useState(upcoming[0]);
+
+ useEffect(() => {
+ const trip = loadTrip();
+ const meta = loadPlanMeta();
+ const checked = MOVE_CHECKLIST.filter((c) => meta.checklist[c.id]).length;
+ const recent = loadRecentDestinations();
+ const ai = loadAiHistory()[0];
+ setTripLen(trip.length);
+ setReadiness(Math.round((checked / MOVE_CHECKLIST.length) * 100));
+ setRecentCount(recent.length);
+ setWatchCount(loadWatchLater().length);
+ setLastAi(ai?.question || "");
+ setTopRecent(recent.slice(0, 3).map((d) => ({ slug: d.slug, emoji: d.emoji, name: d.name })));
+ setReady(true);
+ }, [rsvpProp]);
+
+ useEffect(() => {
+ if (typeof rsvpProp === "number") {
+ setRsvpCount(rsvpProp);
+ setNextUp(upcoming[0]);
+ return;
+ }
+ if (!token) {
+ setRsvpCount(0);
+ setNextUp(undefined);
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ try {
+ const [mine, list] = await Promise.all([
+ api.getMyRsvps(token),
+ api.getMeetups().catch(() => [] as Meetup[]),
+ ]);
+ if (cancelled) return;
+ const ids = new Set(mine.ids);
+ const joined = list.filter((m) => ids.has(m.id));
+ setRsvpCount(joined.length);
+ setNextUp(joined[0]);
+ } catch {
+ if (!cancelled) setRsvpCount(0);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [token, rsvpProp, upcoming]);
+
+ if (!ready) return null;
+
+ const hasSignal =
+ tripLen > 0 || rsvpCount > 0 || recentCount > 0 || watchCount > 0 || Boolean(lastAi);
+ if (compact && !hasSignal) return null;
+
+ const title = compact ? t.digest.resumeTitle : t.digest.title;
+ const subtitle = compact ? t.digest.resumeSub : t.digest.subtitle;
+
+ return (
+
+
+
+ {!compact &&
{t.digest.tag}}
+
{title}
+
{subtitle}
+
+
+ {t.nav.nextStop}
+
+
+
+
+
+ 🗓️ {tripLen}
+ {t.digest.planCities}
+
+ {t.digest.readiness} {readiness}%
+
+
+
+ 🎉 {rsvpCount}
+ {t.digest.rsvps}
+ {nextUp ? nextUp.title : t.digest.noUpcoming}
+
+
+ 🎬 {watchCount}
+ {t.digest.watchLater}
+ {t.digest.openVideos}
+
+
+ 🤖
+ {t.digest.lastAi}
+ {lastAi || t.digest.askAi}
+
+
+
+ {topRecent.length > 0 && (
+
+
+ {t.recent.title} · {recentCount}
+
+
+ {topRecent.map((d) => (
+
+ {d.emoji} {d.name}
+
+ ))}
+ {topRecent.length >= 2 && (
+ d.slug).join(",")}`}
+ className="filter-btn active"
+ >
+ {t.recent.compareRecent}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/NomadTips.tsx b/frontend/src/components/NomadTips.tsx
index b97f65a..7908f07 100644
--- a/frontend/src/components/NomadTips.tsx
+++ b/frontend/src/components/NomadTips.tsx
@@ -1,21 +1,26 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
-
-const TIPS = [
- { emoji: "📶", text: "入住前用 Speedtest 测网速,低于 20Mbps 慎重签约", href: "/digital/day/3" },
- { emoji: "💳", text: "准备一张无外汇手续费的信用卡,省下不少换汇成本", href: "/digital/day/2" },
- { emoji: "🏥", text: "出发前购买国际医疗保险,月费约 $40+", href: "/tools" },
- { emoji: "🕐", text: "选时区重叠 ≥4 小时的城市,远程协作更轻松", href: "/next-stop" },
- { emoji: "🛂", text: "护照有效期建议预留 6 个月以上,避免登机被拒", href: "/digital/day/1" },
- { emoji: "☕", text: "先订 1 周短租探路,再决定长期租房", href: "/plan" },
-];
+import { useI18n } from "@/lib/i18n";
export default function NomadTips() {
+ const { t } = useI18n();
const [idx, setIdx] = useState(0);
const [visible, setVisible] = useState(false);
+ const tips = useMemo(
+ () => [
+ { emoji: "📶", text: t.tips.tip1, href: "/digital/day/3" },
+ { emoji: "💳", text: t.tips.tip2, href: "/digital/day/2" },
+ { emoji: "🏥", text: t.tips.tip3, href: "/tools" },
+ { emoji: "🕐", text: t.tips.tip4, href: "/next-stop" },
+ { emoji: "🛂", text: t.tips.tip5, href: "/digital/day/1" },
+ { emoji: "☕", text: t.tips.tip6, href: "/plan" },
+ ],
+ [t]
+ );
+
useEffect(() => {
if (localStorage.getItem("nomadro-tips-dismissed") === "1") return;
const show = setTimeout(() => setVisible(true), 8000);
@@ -24,13 +29,13 @@ export default function NomadTips() {
useEffect(() => {
if (!visible) return;
- const t = setInterval(() => setIdx((i) => (i + 1) % TIPS.length), 6000);
- return () => clearInterval(t);
- }, [visible]);
+ const timer = setInterval(() => setIdx((i) => (i + 1) % tips.length), 6000);
+ return () => clearInterval(timer);
+ }, [visible, tips.length]);
if (!visible) return null;
- const tip = TIPS[idx];
+ const tip = tips[idx] || tips[0];
const dismiss = () => {
localStorage.setItem("nomadro-tips-dismissed", "1");
setVisible(false);
@@ -38,19 +43,19 @@ export default function NomadTips() {
return (
-
+
✕
{tip.emoji}
-
游民小贴士
+
{t.tips.title}
{tip.text}
- 了解更多 →
+ {t.tips.more}
- {TIPS.map((_, i) => (
+ {tips.map((_, i) => (
))}
diff --git a/frontend/src/components/NotificationSettingsClient.tsx b/frontend/src/components/NotificationSettingsClient.tsx
index 6b0bbcc..b84088f 100644
--- a/frontend/src/components/NotificationSettingsClient.tsx
+++ b/frontend/src/components/NotificationSettingsClient.tsx
@@ -6,6 +6,7 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
+import RingNext from "@/components/RingNext";
const PREF_KEYS = ["match", "meetup", "community", "push", "email", "marketing"] as const;
@@ -53,6 +54,10 @@ export default function NotificationSettingsClient() {
}
};
+ const toggle = (k: (typeof PREF_KEYS)[number]) => {
+ setPrefs((p) => ({ ...p, [k]: !p[k] }));
+ };
+
if (!token) {
return (
@@ -69,20 +74,18 @@ export default function NotificationSettingsClient() {
);
}
- const toggle = (key: string) => setPrefs({ ...prefs, [key]: !prefs[key] });
-
return (
-
+
{t.notifSettings.title}
{loading ? (
-
加载偏好…
+
{t.notifSettings.loading}
) : (
PREF_KEYS.map((k) => (
))
)}
-
- {saving ? "保存中…" : t.notifSettings.save}
+ void save()}>
+ {saving ? t.notifSettings.saving : t.notifSettings.save}
+
);
diff --git a/frontend/src/components/NotificationsClient.tsx b/frontend/src/components/NotificationsClient.tsx
index 37cc321..3c16074 100644
--- a/frontend/src/components/NotificationsClient.tsx
+++ b/frontend/src/components/NotificationsClient.tsx
@@ -48,11 +48,20 @@ export default function NotificationsClient() {
{t.notifications.title}
-
登录后查看匹配、报名与回复提醒
-
- {t.nav.login}
-
+
{t.notifications.loginDesc}
+
+
+ {t.nav.login}
+
+
+ {t.nav.meetups}
+
+
+ {t.nav.community}
+
+
+
);
@@ -64,7 +73,7 @@ export default function NotificationsClient() {
{t.notifications.title}
- n.read)}>
+ void markAll()} disabled={items.every((n) => n.read)}>
{t.notifications.markRead}
@@ -73,7 +82,7 @@ export default function NotificationsClient() {
- {loading &&
加载中…
}
+ {loading &&
{t.common.loading}
}
{!loading &&
items.map((n) => (
@@ -94,7 +103,7 @@ export default function NotificationsClient() {
) : (
!n.read && (
void openOne(n)}>
- 标为已读
+ {t.notifications.markOne}
)
)}
@@ -103,16 +112,16 @@ export default function NotificationsClient() {
{!loading && items.length === 0 && (
{t.notifications.empty}
-
去互动一下,通知会出现在这里
+
{t.notifications.emptyHint}
- 活动
+ {t.nav.meetups}
- 匹配
+ {t.nav.dating}
- 社区
+ {t.nav.community}
diff --git a/frontend/src/components/OnboardingTour.tsx b/frontend/src/components/OnboardingTour.tsx
index 6b59157..9d8bbb4 100644
--- a/frontend/src/components/OnboardingTour.tsx
+++ b/frontend/src/components/OnboardingTour.tsx
@@ -1,42 +1,46 @@
"use client";
-import { useEffect, useState } from "react";
-
-/** Align onboarding with the three product rings — never dump the full catalog. */
-const STEPS = [
- {
- id: "path",
- emoji: "🌍",
- title: "发现去哪",
- desc: "用智能匹配或地图,先锁定下一座城市",
- action: "matcher" as const,
- },
- {
- id: "meetups",
- emoji: "🤝",
- title: "遇见同行",
- desc: "同城活动与社区讨论,先认识真实的人",
- action: "route" as const,
- href: "/meetups",
- },
- {
- id: "digital",
- emoji: "🚀",
- title: "成长变现",
- desc: "学院与赏金任务,在路上继续做事",
- action: "route" as const,
- href: "/digital",
- },
-];
+import { useEffect, useMemo, useState } from "react";
+import { useI18n } from "@/lib/i18n";
export default function OnboardingTour() {
+ const { t } = useI18n();
const [open, setOpen] = useState(false);
const [step, setStep] = useState(0);
+ const steps = useMemo(
+ () => [
+ {
+ id: "path",
+ emoji: "🌍",
+ title: t.onboard.step1Title,
+ desc: t.onboard.step1Desc,
+ action: "matcher" as const,
+ },
+ {
+ id: "meetups",
+ emoji: "🤝",
+ title: t.onboard.step2Title,
+ desc: t.onboard.step2Desc,
+ action: "route" as const,
+ href: "/meetups",
+ },
+ {
+ id: "digital",
+ emoji: "🚀",
+ title: t.onboard.step3Title,
+ desc: t.onboard.step3Desc,
+ action: "route" as const,
+ href: "/digital",
+ },
+ ],
+ [t]
+ );
+
useEffect(() => {
if (localStorage.getItem("nomadro-onboarded")) return;
- const t = setTimeout(() => setOpen(true), 2500);
- return () => clearTimeout(t);
+ const timer = setTimeout(() => setOpen(true), 2500);
+ return () => clearTimeout(timer);
}, []);
const finish = () => {
@@ -45,7 +49,7 @@ export default function OnboardingTour() {
};
const next = () => {
- const s = STEPS[step];
+ const s = steps[step];
if (s.action === "matcher") {
window.dispatchEvent(new CustomEvent("open-matcher"));
} else if (s.action === "route" && s.href) {
@@ -56,7 +60,7 @@ export default function OnboardingTour() {
document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" });
}
- if (step >= STEPS.length - 1) {
+ if (step >= steps.length - 1) {
finish();
return;
}
@@ -65,25 +69,25 @@ export default function OnboardingTour() {
if (!open) return null;
- const current = STEPS[step];
- const isLast = step >= STEPS.length - 1;
+ const current = steps[step];
+ const isLast = step >= steps.length - 1;
return (
-
+
- 跳过
+ {t.onboard.skip}
{current.emoji}
{current.title}
{current.desc}
- {STEPS.map((_, i) => (
+ {steps.map((_, i) => (
))}
- {isLast ? "去学院看看 →" : step === 0 ? "开始匹配 →" : "下一步 →"}
+ {isLast ? t.onboard.goDigital : step === 0 ? t.onboard.startMatch : t.onboard.next}
diff --git a/frontend/src/components/PricingClient.tsx b/frontend/src/components/PricingClient.tsx
index af53cc2..e950d9d 100644
--- a/frontend/src/components/PricingClient.tsx
+++ b/frontend/src/components/PricingClient.tsx
@@ -2,6 +2,7 @@
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
+import RingNext from "@/components/RingNext";
export default function PricingClient() {
const { t } = useI18n();
@@ -29,6 +30,13 @@ export default function PricingClient() {
{t.join.payBtn}
+
);
diff --git a/frontend/src/components/RecentlyViewed.tsx b/frontend/src/components/RecentlyViewed.tsx
new file mode 100644
index 0000000..1e5df0e
--- /dev/null
+++ b/frontend/src/components/RecentlyViewed.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useI18n } from "@/lib/i18n";
+import {
+ clearRecentDestinations,
+ loadRecentDestinations,
+ type RecentDestination,
+} from "@/lib/recentDestinations";
+import { meetupCityHref } from "@/lib/meetupLinks";
+
+interface Props {
+ compact?: boolean;
+}
+
+export default function RecentlyViewed({ compact = false }: Props) {
+ const { t } = useI18n();
+ const [items, setItems] = useState([]);
+
+ useEffect(() => {
+ setItems(loadRecentDestinations());
+ }, []);
+
+ if (items.length === 0) return null;
+
+ return (
+
+
+
+
+
{t.recent.tag}
+
{t.recent.title}
+ {!compact &&
{t.recent.subtitle}
}
+
+
+ {!compact && (
+
+ {t.nav.compare}
+
+ )}
+ {
+ clearRecentDestinations();
+ setItems([]);
+ }}
+ >
+ {t.common.clear}
+
+
+
+
+ {items.map((d) => (
+
+
+ {d.emoji}
+
+
+
+ {d.name}
+ , {d.country}
+
+
+ ¥{d.cost.toLocaleString()}/mo · ⭐ {d.rating}
+
+
+
+ ))}
+
+ {items.length >= 2 && (
+
+ d.slug)
+ .join(",")}`}
+ className="btn btn-primary btn-sm"
+ >
+ {t.recent.compareRecent}
+
+
+ {t.common.cityMeetups}
+
+
+ {t.strip.openPlan}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/ReportClient.tsx b/frontend/src/components/ReportClient.tsx
index a9121ec..1577ddd 100644
--- a/frontend/src/components/ReportClient.tsx
+++ b/frontend/src/components/ReportClient.tsx
@@ -4,11 +4,14 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { meetupCityHref } from "@/lib/meetupLinks";
export default function ReportClient() {
const { t } = useI18n();
+ const { toast } = useToast();
const rings = useRingSteps();
const [ranking, setRanking] = useState<{ slug: string; name: string; score: number; nomads: string }[]>([]);
const [members, setMembers] = useState<{ name: string; city: string; emoji: string }[]>([]);
@@ -21,59 +24,116 @@ export default function ReportClient() {
]).finally(() => setLoading(false));
}, []);
+ const copySnapshot = async () => {
+ const lines = [
+ `📊 nomadro ${t.report.title}`,
+ "",
+ t.report.ranking,
+ ...ranking.slice(0, 10).map((c, i) => `${i + 1}. ${c.name} · ${c.score} · ${c.nomads}`),
+ "",
+ t.report.members,
+ ...members.slice(0, 8).map((m) => `${m.emoji} ${m.name} · ${m.city}`),
+ ];
+ const action = { href: "/next-stop", label: t.report.goNextStop };
+ try {
+ await navigator.clipboard.writeText(lines.join("\n"));
+ toast(t.report.snapshotOk, "success", action);
+ } catch {
+ toast(t.report.snapshotOk, "info", action);
+ }
+ };
+
return (
{t.report.tag}
{t.report.title}
{t.report.subtitle}
+ {!loading && (ranking.length > 0 || members.length > 0) && (
+
void copySnapshot()}>
+ {t.report.snapshot}
+
+ )}
- {loading &&
加载报告…
}
+ {loading &&
{t.report.loading}
}
{t.report.ranking}
{!loading && ranking.length === 0 ? (
-
排名数据暂不可用
+
{t.report.rankEmpty}
- 去智能下一站
+ {t.report.goNextStop}
) : (
-
- {ranking.map((c, i) => (
- -
- {i + 1}
- {c.name}
- {c.score}
- {c.nomads}
-
- ))}
-
+ <>
+
+ {ranking.map((c, i) => (
+ -
+ {i + 1}
+ {c.name}
+ {c.score}
+ {c.nomads}
+
+ ))}
+
+ {ranking.length > 0 && (
+
+
+ {t.strip.openPlan}
+
+
+ {t.report.goNextStop}
+
+ {ranking[0] && (
+
+ {t.common.cityMeetups}
+
+ )}
+
+ )}
+ >
)}
{t.report.members}
{!loading && members.length === 0 ? (
- 会员分布暂不可用
+
+
{t.report.membersEmpty}
+
+ {t.nav.dating}
+
+
) : (
{members.map((m) => (
{m.emoji}
{m.name}
- {m.city}
+
+ {m.city}
+
))}
)}
-
- {t.report.viewMap}
-
+
+
+ {t.report.viewMap}
+
+
+ {t.nav.meetups}
+
+
diff --git a/frontend/src/components/RunwayCalculator.tsx b/frontend/src/components/RunwayCalculator.tsx
index 4862068..db594bc 100644
--- a/frontend/src/components/RunwayCalculator.tsx
+++ b/frontend/src/components/RunwayCalculator.tsx
@@ -1,7 +1,12 @@
"use client";
import { useEffect, useMemo, useState } from "react";
+import Link from "next/link";
import type { Destination } from "@/lib/types";
+import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
+import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
+import { meetupCityHref } from "@/lib/meetupLinks";
const STORAGE_KEY = "nomadro-runway";
@@ -17,6 +22,8 @@ interface Saved {
}
export default function RunwayCalculator({ destinations }: Props) {
+ const { t } = useI18n();
+ const { toast } = useToast();
const [savings, setSavings] = useState(80000);
const [monthly, setMonthly] = useState(4500);
const [income, setIncome] = useState(0);
@@ -34,7 +41,9 @@ export default function RunwayCalculator({ destinations }: Props) {
setIncome(s.income ?? 0);
setSlug(s.slug ?? "");
}
- } catch { /* ignore */ }
+ } catch {
+ /* ignore */
+ }
setReady(true);
}, []);
@@ -43,18 +52,23 @@ export default function RunwayCalculator({ destinations }: Props) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ savings, monthly, income, slug }));
}, [savings, monthly, income, slug, ready]);
+ const selected = destinations.find((d) => d.slug === slug);
+
const netBurn = Math.max(monthly - income, 0);
const months = useMemo(() => {
if (netBurn <= 0) return income > 0 ? 999 : 0;
- return Math.floor(savings / netBurn * 10) / 10;
+ return Math.floor((savings / netBurn) * 10) / 10;
}, [savings, netBurn, income]);
const pct = Math.min((months / 18) * 100, 100);
const level =
- months >= 12 ? { label: "从容远航", color: "#4ECDC4", tip: "可以大胆规划长线旅居" } :
- months >= 6 ? { label: "稳健起步", color: "#FFE66D", tip: "适合 1–2 站深度停留" } :
- months >= 3 ? { label: "短途试水", color: "#F472B6", tip: "建议选低成本城市先验证" } :
- { label: "需要蓄力", color: "#FF6B6B", tip: "先攒启动金或提高远程收入" };
+ months >= 12
+ ? { label: "从容远航", color: "#4ECDC4", tip: "可以大胆规划长线旅居" }
+ : months >= 6
+ ? { label: "稳健起步", color: "#FFE66D", tip: "适合 1–2 站深度停留" }
+ : months >= 3
+ ? { label: "短途试水", color: "#F472B6", tip: "建议选低成本城市先验证" }
+ : { label: "需要蓄力", color: "#FF6B6B", tip: "先攒启动金或提高远程收入" };
const applyDest = (s: string) => {
const d = destinations.find((x) => x.slug === s);
@@ -65,6 +79,17 @@ export default function RunwayCalculator({ destinations }: Props) {
setTimeout(() => setPulse(false), 500);
};
+ const addToPlan = () => {
+ if (!selected) return;
+ const stay = months >= 999 ? 6 : Math.max(1, Math.min(24, Math.round(months) || 1));
+ const { added } = mergeDestinationsIntoTrip([selected], stay);
+ toast(
+ added ? `${selected.emoji} ${selected.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
+ added ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
+ );
+ };
+
const milestones = [
{ m: 3, label: "试水期" },
{ m: 6, label: "半程" },
@@ -116,7 +141,10 @@ export default function RunwayCalculator({ destinations }: Props) {
min={0}
step={100}
value={monthly}
- onChange={(e) => { setMonthly(Math.max(0, +e.target.value || 0)); setSlug(""); }}
+ onChange={(e) => {
+ setMonthly(Math.max(0, +e.target.value || 0));
+ setSlug("");
+ }}
/>
))}
+
+ {selected && (
+
+
+ 🗓️ {t.nav.plan}
+
+
+ {t.common.detail}
+
+
+ {t.common.cityMeetups}
+
+
+ )}
diff --git a/frontend/src/components/SavingsGoal.tsx b/frontend/src/components/SavingsGoal.tsx
index aa08505..eddb591 100644
--- a/frontend/src/components/SavingsGoal.tsx
+++ b/frontend/src/components/SavingsGoal.tsx
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
+import ToolCityExits from "@/components/ToolCityExits";
const STORAGE_KEY = "nomadro-savings";
@@ -106,6 +107,7 @@ export default function SavingsGoal({ destinations }: Props) {
{dest.emoji} {dest.name} 月均约 ¥{dest.cost.toLocaleString()},建议备 3 个月缓冲 + 机票
)}
+ {dest && }
diff --git a/frontend/src/components/ServicesClient.tsx b/frontend/src/components/ServicesClient.tsx
index 9bedba1..33964ca 100644
--- a/frontend/src/components/ServicesClient.tsx
+++ b/frontend/src/components/ServicesClient.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { useToast } from "@/lib/toast";
@@ -8,6 +8,9 @@ import { useI18n } from "@/lib/i18n";
import type { ServiceItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+
+const DRAFT_KEY = "nomadro-service-lead-draft";
type LeadForm = { name: string; email: string; message: string };
@@ -18,6 +21,35 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
const [form, setForm] = useState>({});
const [sent, setSent] = useState>({});
const [busyId, setBusyId] = useState(null);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ const draft = loadDraft>(DRAFT_KEY);
+ if (draft && typeof draft === "object") {
+ setForm(draft);
+ const hasContent = Object.values(draft).some(
+ (f) => f?.name?.trim() || f?.email?.trim() || f?.message?.trim()
+ );
+ if (hasContent) toast(t.services.draftRestored, "info");
+ }
+ setReady(true);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!ready) return;
+ const id = window.setTimeout(() => {
+ const hasContent = Object.values(form).some(
+ (f) => f?.name?.trim() || f?.email?.trim() || f?.message?.trim()
+ );
+ if (!hasContent) {
+ clearDraft(DRAFT_KEY);
+ return;
+ }
+ saveDraft(DRAFT_KEY, form);
+ }, 400);
+ return () => window.clearTimeout(id);
+ }, [form, ready]);
const patch = (id: string, key: keyof LeadForm, value: string) => {
const cur = form[id] || { name: "", email: "", message: "" };
@@ -27,11 +59,11 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
const submit = async (serviceId: string) => {
const f = form[serviceId];
if (!f?.name?.trim() || !f?.email?.trim() || !f?.message?.trim()) {
- toast("请填写姓名、邮箱和需求说明", "info");
+ toast(t.services.needFields, "info");
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(f.email.trim())) {
- toast("请填写有效邮箱地址", "info");
+ toast(t.services.needEmail, "info");
return;
}
setBusyId(serviceId);
@@ -41,8 +73,15 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
email: f.email.trim(),
message: f.message.trim(),
});
+ const next = { ...form };
+ delete next[serviceId];
+ setForm(next);
+ saveDraft(DRAFT_KEY, next);
setSent((s) => ({ ...s, [serviceId]: true }));
- toast(t.services.leadOk, "success");
+ toast(t.services.leadOk, "success", {
+ href: "/community",
+ label: t.nav.community,
+ });
} catch {
toast(t.services.leadFail, "error");
} finally {
@@ -58,11 +97,12 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
{t.services.tag}
{t.services.title}
{t.services.subtitle}
+ {t.services.draftHint}
{services.length === 0 ? (
-
服务清单准备中
-
先去社区问问
+
{t.services.empty}
+
{t.services.emptyCta}
) : (
@@ -72,7 +112,7 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
{s.description}
{s.price} · {s.provider}
{sent[s.id] ? (
-
✓ 已提交咨询,我们会尽快联系你
+
{t.services.sent}
) : (
<>
patch(s.id, "name", e.target.value)}
/>
patch(s.id, "email", e.target.value)}
@@ -96,9 +135,9 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
type="button"
className="btn btn-primary btn-sm"
disabled={busyId === s.id}
- onClick={() => submit(s.id)}
+ onClick={() => void submit(s.id)}
>
- {busyId === s.id ? "提交中…" : t.services.inquire}
+ {busyId === s.id ? t.services.submitting : t.services.inquire}
>
)}
diff --git a/frontend/src/components/ShareButton.tsx b/frontend/src/components/ShareButton.tsx
index d17de27..e8434b3 100644
--- a/frontend/src/components/ShareButton.tsx
+++ b/frontend/src/components/ShareButton.tsx
@@ -2,19 +2,30 @@
import { useEffect, useState } from "react";
import { useToast } from "@/lib/toast";
+import { useI18n } from "@/lib/i18n";
interface Props {
title: string;
url?: string;
className?: string;
compact?: boolean;
+ /** Optional toast action after copy (defaults to open plan). */
+ afterCopy?: { href: string; label: string };
}
-export default function ShareButton({ title, url, className = "", compact = false }: Props) {
+export default function ShareButton({
+ title,
+ url,
+ className = "",
+ compact = false,
+ afterCopy,
+}: Props) {
const { toast } = useToast();
+ const { t } = useI18n();
const [open, setOpen] = useState(false);
const getUrl = () => url ?? (typeof window !== "undefined" ? window.location.href : "");
+ const copyAction = afterCopy ?? { href: "/plan", label: t.strip.openPlan };
useEffect(() => {
if (!open) return;
@@ -28,9 +39,9 @@ export default function ShareButton({ title, url, className = "", compact = fals
const copyLink = async () => {
try {
await navigator.clipboard.writeText(getUrl());
- toast("链接已复制到剪贴板 📋");
+ toast(t.common.linkCopied, "success", copyAction);
} catch {
- toast("复制失败,请手动复制地址栏", "error");
+ toast(t.common.copyFail, "error");
}
setOpen(false);
};
@@ -58,7 +69,7 @@ export default function ShareButton({ title, url, className = "", compact = fals
if (compact) {
return (
-
void nativeShare()} aria-label="分享">
+ void nativeShare()} aria-label={t.share.aria}>
📤
);
@@ -67,20 +78,20 @@ export default function ShareButton({ title, url, className = "", compact = fals
return (
setOpen(!open)} aria-expanded={open}>
- 📤 分享
+ {t.share.button}
{open && (
<>
setOpen(false)} />
void nativeShare()}>
- 📱 系统分享
+ {t.share.system}
void copyLink()}>
- 📋 复制链接
+ {t.share.copy}
- 🐦 分享到 X
+ {t.share.twitter}
>
diff --git a/frontend/src/components/SiteShell.tsx b/frontend/src/components/SiteShell.tsx
index 9e3b633..507b755 100644
--- a/frontend/src/components/SiteShell.tsx
+++ b/frontend/src/components/SiteShell.tsx
@@ -47,7 +47,7 @@ export default function SiteShell({ children, showFooter = false }: { children:
if (pathname === "/") {
window.dispatchEvent(new Event("open-matcher"));
} else {
- router.push("/next-stop");
+ router.push("/#matcher");
}
}}
/>
diff --git a/frontend/src/components/SpinGlobe.tsx b/frontend/src/components/SpinGlobe.tsx
index 959451a..a11a126 100644
--- a/frontend/src/components/SpinGlobe.tsx
+++ b/frontend/src/components/SpinGlobe.tsx
@@ -1,8 +1,8 @@
"use client";
import { useRef, useState } from "react";
-import Link from "next/link";
import type { Destination } from "@/lib/types";
+import ToolCityExits from "@/components/ToolCityExits";
interface Props {
destinations: Destination[];
@@ -71,11 +71,9 @@ export default function SpinGlobe({ destinations }: Props) {
{result.description}
-
- 查看详情 →
-
再转一次 🎲
+
)}
diff --git a/frontend/src/components/ToolCityExits.tsx b/frontend/src/components/ToolCityExits.tsx
new file mode 100644
index 0000000..fdf5cf0
--- /dev/null
+++ b/frontend/src/components/ToolCityExits.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import Link from "next/link";
+import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
+import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
+import { meetupCityHref } from "@/lib/meetupLinks";
+import type { Destination } from "@/lib/types";
+
+export default function ToolCityExits({ dest, months = 1 }: { dest: Destination; months?: number }) {
+ const { t } = useI18n();
+ const { toast } = useToast();
+
+ const addToPlan = () => {
+ const stay = Math.max(1, Math.min(24, months));
+ const { added } = mergeDestinationsIntoTrip([dest], stay);
+ toast(
+ added ? `${dest.emoji} ${dest.name} ${t.plan.addedToPlan}` : t.common.alreadyInPlan,
+ added ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
+ );
+ };
+
+ return (
+
+
+ 🗓️ {t.nav.plan}
+
+
+ {t.common.detail}
+
+
+ {t.common.cityMeetups}
+
+
+ );
+}
diff --git a/frontend/src/components/ToolRunnerClient.tsx b/frontend/src/components/ToolRunnerClient.tsx
index d413608..cb9e39a 100644
--- a/frontend/src/components/ToolRunnerClient.tsx
+++ b/frontend/src/components/ToolRunnerClient.tsx
@@ -6,6 +6,10 @@ import { useMemo } from "react";
import type { Destination } from "@/lib/types";
import { TOOL_LINKS } from "@/lib/tools";
import { TOOL_COMPONENTS } from "@/lib/toolsRegistry";
+import { useI18n } from "@/lib/i18n";
+import { localizedTool } from "@/lib/toolCopy";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
interface Props {
toolId: string;
@@ -13,16 +17,18 @@ interface Props {
}
export default function ToolRunnerClient({ toolId, destinations }: Props) {
- const tool = TOOL_LINKS.find((t) => t.id === toolId);
+ const { t, locale } = useI18n();
+ const rings = useRingSteps();
+ const tool = localizedTool(toolId, locale) || TOOL_LINKS.find((item) => item.id === toolId);
const loader = TOOL_COMPONENTS[toolId];
const ToolComponent = useMemo(() => {
if (!loader) return null;
return dynamic(loader, {
ssr: false,
- loading: () => 加载中…
,
+ loading: () => {t.tools.loading}
,
});
- }, [toolId, loader]);
+ }, [toolId, loader, t.tools.loading]);
if (!tool || !ToolComponent) return null;
@@ -30,7 +36,7 @@ export default function ToolRunnerClient({ toolId, destinations }: Props) {
{tool.emoji} TOOL
@@ -38,6 +44,18 @@ export default function ToolRunnerClient({ toolId, destinations }: Props) {
{tool.desc}
+
+
+ {t.strip.openPlan}
+
+
+ {t.tools.goNextStop}
+
+
+ {t.nav.meetups}
+
+
+
);
diff --git a/frontend/src/components/VideoDetailClient.tsx b/frontend/src/components/VideoDetailClient.tsx
index 3c0e040..8bbd1b0 100644
--- a/frontend/src/components/VideoDetailClient.tsx
+++ b/frontend/src/components/VideoDetailClient.tsx
@@ -1,19 +1,55 @@
"use client";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
import type { VideoItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import { meetupCityHref } from "@/lib/meetupLinks";
+import { isWatchLater, toggleWatchLater } from "@/lib/watchLater";
export default function VideoDetailClient({ video }: { video: VideoItem }) {
const { t } = useI18n();
+ const { toast } = useToast();
const rings = useRingSteps();
+ const [saved, setSaved] = useState(false);
+ const city = video.city?.trim();
+ const meetupsHref = city ? meetupCityHref(city) : "/meetups";
+
+ useEffect(() => {
+ setSaved(isWatchLater(video.slug));
+ }, [video.slug]);
+
+ const onToggle = () => {
+ const next = toggleWatchLater({
+ slug: video.slug,
+ title: video.title,
+ emoji: video.emoji,
+ city: video.city,
+ duration: video.duration,
+ });
+ const now = next.some((v) => v.slug === video.slug);
+ setSaved(now);
+ toast(
+ now ? t.videos.saved : t.videos.unsaved,
+ "success",
+ now ? { href: "/videos", label: t.videos.watchLater } : undefined
+ );
+ };
+
return (
@@ -29,22 +65,25 @@ export default function VideoDetailClient({ video }: { video: VideoItem }) {
) : (
-
播放链接暂未配置
+
{t.videos.noPlayer}
- 去读电子书
+ {t.videos.readEbook}
)}
-
- 相关活动
+
+ {saved ? `✓ ${t.videos.watchLater}` : t.videos.saveLater}
+
+
+ {t.videos.relatedMeetups}
- 学院
+ {t.videos.goDigital}
-
+
);
diff --git a/frontend/src/components/VideosClient.tsx b/frontend/src/components/VideosClient.tsx
index 6c22d0c..518c375 100644
--- a/frontend/src/components/VideosClient.tsx
+++ b/frontend/src/components/VideosClient.tsx
@@ -1,14 +1,46 @@
"use client";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
+import { useToast } from "@/lib/toast";
import type { VideoItem } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
+import {
+ clearWatchLater,
+ loadWatchLater,
+ toggleWatchLater,
+ type SavedVideo,
+} from "@/lib/watchLater";
export default function VideosClient({ videos }: { videos: VideoItem[] }) {
const { t } = useI18n();
+ const { toast } = useToast();
const rings = useRingSteps();
+ const [saved, setSaved] = useState([]);
+
+ useEffect(() => {
+ setSaved(loadWatchLater());
+ }, []);
+
+ const onToggle = (v: VideoItem) => {
+ const next = toggleWatchLater({
+ slug: v.slug,
+ title: v.title,
+ emoji: v.emoji,
+ city: v.city,
+ duration: v.duration,
+ });
+ const nowSaved = next.some((x) => x.slug === v.slug);
+ setSaved(next);
+ toast(
+ nowSaved ? t.videos.saved : t.videos.unsaved,
+ "success",
+ nowSaved ? { href: "/videos", label: t.videos.watchLater } : undefined
+ );
+ };
+
return (
@@ -20,32 +52,85 @@ export default function VideosClient({ videos }: { videos: VideoItem[] }) {
{t.videos.title}
{t.videos.subtitle}
+
+ {saved.length > 0 ? (
+
+
+
+ {t.videos.watchLater} · {saved.length}
+
+ {
+ clearWatchLater();
+ setSaved([]);
+ }}
+ >
+ {t.videos.clearSaved}
+
+
+
+ {saved.map((v) => (
+
+ {v.emoji}
+ {v.title}
+
+ {v.city} · {v.duration}
+
+
+ ))}
+
+
+ ) : (
+ videos.length > 0 && (
+
+
+
{t.videos.watchLater}
+
+ {t.videos.savedEmpty}
+
+ )
+ )}
+
{videos.length === 0 ? (
-
视频内容准备中
+
{t.videos.empty}
- 先读电子书
+ {t.videos.readEbook}
- 去学院
+ {t.videos.goDigital}
) : (
- {videos.map((v) => (
-
-
-
{v.emoji}
-
- {v.city} · {v.duration}
-
+ {videos.map((v) => {
+ const isSaved = saved.some((s) => s.slug === v.slug);
+ return (
+
+
+
+ {v.emoji}
+
+ {v.city} · {v.duration}
+
+
+
{v.title}
+
{v.excerpt}
+
+
onToggle(v)}
+ >
+ {isSaved ? `✓ ${t.videos.watchLater}` : t.videos.saveLater}
+
-
{v.title}
-
{v.excerpt}
-
- ))}
+ );
+ })}
)}
diff --git a/frontend/src/components/VisaStayCountdown.tsx b/frontend/src/components/VisaStayCountdown.tsx
index d5a96b4..1c1fbe9 100644
--- a/frontend/src/components/VisaStayCountdown.tsx
+++ b/frontend/src/components/VisaStayCountdown.tsx
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
+import ToolCityExits from "@/components/ToolCityExits";
const STORAGE_KEY = "nomadro-visa-stay";
@@ -135,6 +136,7 @@ export default function VisaStayCountdown({ destinations }: Props) {
额度{days} 天
建议离境前{exitDate}
+ {dest &&
}
diff --git a/frontend/src/components/WeekendIdeas.tsx b/frontend/src/components/WeekendIdeas.tsx
index 02b7582..fe14587 100644
--- a/frontend/src/components/WeekendIdeas.tsx
+++ b/frontend/src/components/WeekendIdeas.tsx
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types";
+import ToolCityExits from "@/components/ToolCityExits";
interface Idea {
emoji: string;
@@ -137,6 +138,7 @@ export default function WeekendIdeas({ destinations }: Props) {
))}
+ {dest && }
diff --git a/frontend/src/components/WifiWorkScore.tsx b/frontend/src/components/WifiWorkScore.tsx
index f5f9241..ccd93b2 100644
--- a/frontend/src/components/WifiWorkScore.tsx
+++ b/frontend/src/components/WifiWorkScore.tsx
@@ -1,8 +1,8 @@
"use client";
import { useMemo, useState, type CSSProperties } from "react";
-import Link from "next/link";
import type { Destination } from "@/lib/types";
+import ToolCityExits from "@/components/ToolCityExits";
function workLevel(speed: number) {
if (speed >= 150) return { label: "极速办公", emoji: "🚀", color: "#6ee7b7", tip: "视频会议、大文件传输无压力" };
@@ -57,7 +57,7 @@ export default function WifiWorkScore({ destinations }: Props) {
{level.emoji} {level.label}
{level.tip}
- 查看城市详情 →
+
)}
diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx
index da38445..d00f236 100644
--- a/frontend/src/components/WorldMap.tsx
+++ b/frontend/src/components/WorldMap.tsx
@@ -7,6 +7,7 @@ import { compareUrl } from "@/lib/compareScore";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
+import { meetupCityHref } from "@/lib/meetupLinks";
import type { Destination } from "@/lib/types";
interface Props { destinations: Destination[] }
@@ -21,8 +22,11 @@ export default function WorldMap({ destinations }: Props) {
if (!selected) return;
const { added } = mergeDestinationsIntoTrip([selected], 1);
toast(
- added ? `${selected.emoji} ${selected.name} 已加入计划` : "该城已在计划中",
- added ? "success" : "info"
+ added
+ ? `${selected.emoji} ${selected.name} ${t.plan.addedToPlan}`
+ : t.common.alreadyInPlan,
+ added ? "success" : "info",
+ { href: "/plan", label: t.strip.openPlan }
);
if (added) router.push("/plan");
};
@@ -95,6 +99,9 @@ export default function WorldMap({ destinations }: Props) {
🗓️ {t.nav.plan}
+
+ {t.common.cityMeetups}
+
⚖️ {t.nav.compare}
diff --git a/frontend/src/lib/aiHistory.ts b/frontend/src/lib/aiHistory.ts
new file mode 100644
index 0000000..4a8233d
--- /dev/null
+++ b/frontend/src/lib/aiHistory.ts
@@ -0,0 +1,44 @@
+"use client";
+
+export type AiHistoryItem = {
+ id: string;
+ question: string;
+ reply: string;
+ cities: { slug: string; name: string; emoji: string }[];
+ at: number;
+};
+
+const KEY = "nomadro-ai-history";
+const MAX = 12;
+
+export function loadAiHistory(): AiHistoryItem[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (!raw) return [];
+ const list = JSON.parse(raw) as AiHistoryItem[];
+ return Array.isArray(list) ? list.slice(0, MAX) : [];
+ } catch {
+ return [];
+ }
+}
+
+export function pushAiHistory(item: Omit): AiHistoryItem[] {
+ const entry: AiHistoryItem = {
+ ...item,
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
+ at: Date.now(),
+ };
+ const next = [entry, ...loadAiHistory()].slice(0, MAX);
+ try {
+ localStorage.setItem(KEY, JSON.stringify(next));
+ } catch {
+ /* quota */
+ }
+ return next;
+}
+
+export function clearAiHistory() {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(KEY);
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index ab7baf7..7e1ce2b 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -413,4 +413,9 @@ export const api = {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}),
+ getMeetupSocialSuggestions: (meetupId: string) =>
+ fetchWithFallback<{ items: MatchProfile[] }>(
+ `/meetups/${meetupId}/social-suggestions`,
+ { items: [] }
+ ),
};
diff --git a/frontend/src/lib/authNext.ts b/frontend/src/lib/authNext.ts
index de5cb45..c9886f0 100644
--- a/frontend/src/lib/authNext.ts
+++ b/frontend/src/lib/authNext.ts
@@ -15,6 +15,12 @@ type LoginDict = {
hintGigs: string;
hintCommunity: string;
hintSubmit: string;
+ hintDigital: string;
+ hintVideos: string;
+ hintJobs: string;
+ hintAi: string;
+ hintNextStop: string;
+ hintMap: string;
redirectTo: string;
planMergeNote: string;
destHome: string;
@@ -31,6 +37,12 @@ type LoginDict = {
destGigs: string;
destCommunity: string;
destSubmit: string;
+ destDigital: string;
+ destVideos: string;
+ destJobs: string;
+ destAi: string;
+ destNextStop: string;
+ destMap: string;
submitLoginDefault: string;
submitLoginPlan: string;
demoHintDefault: string;
@@ -69,7 +81,7 @@ function destLabel(path: string, t: LoginDict): string {
if (path === "/profile") return t.destProfile;
if (path === "/plan") return t.destPlan;
if (path === "/compare") return t.destCompare;
- if (path === "/tools") return t.destTools;
+ if (path === "/tools" || path.startsWith("/tools/")) return t.destTools;
if (path.startsWith("/destinations/")) return t.destDestination;
if (path.startsWith("/dating")) return t.destDating;
if (path.startsWith("/chat")) return t.destChat;
@@ -79,13 +91,19 @@ function destLabel(path: string, t: LoginDict): string {
if (path.startsWith("/gigs")) return t.destGigs;
if (path.startsWith("/community")) return t.destCommunity;
if (path === "/submit") return t.destSubmit;
+ if (path.startsWith("/digital/jobs")) return t.destJobs;
+ if (path.startsWith("/digital")) return t.destDigital;
+ if (path.startsWith("/videos")) return t.destVideos;
+ if (path.startsWith("/ai")) return t.destAi;
+ if (path.startsWith("/next-stop")) return t.destNextStop;
+ if (path.startsWith("/map") || path.startsWith("/report")) return t.destMap;
return t.destProfile;
}
function hintFor(path: string, t: LoginDict): string {
if (path === "/plan") return t.hintPlan;
if (path === "/compare") return t.hintCompare;
- if (path === "/tools") return t.hintTools;
+ if (path === "/tools" || path.startsWith("/tools/")) return t.hintTools;
if (path.startsWith("/destinations/")) return t.hintDestination;
if (path === "/profile") return t.hintProfile;
if (path.startsWith("/dating")) return t.hintDating;
@@ -96,6 +114,12 @@ function hintFor(path: string, t: LoginDict): string {
if (path.startsWith("/gigs")) return t.hintGigs;
if (path.startsWith("/community")) return t.hintCommunity;
if (path === "/submit") return t.hintSubmit;
+ if (path.startsWith("/digital/jobs")) return t.hintJobs;
+ if (path.startsWith("/digital")) return t.hintDigital;
+ if (path.startsWith("/videos")) return t.hintVideos;
+ if (path.startsWith("/ai")) return t.hintAi;
+ if (path.startsWith("/next-stop")) return t.hintNextStop;
+ if (path.startsWith("/map") || path.startsWith("/report")) return t.hintMap;
return t.hintExplore;
}
diff --git a/frontend/src/lib/communityDraft.ts b/frontend/src/lib/communityDraft.ts
new file mode 100644
index 0000000..5d6793c
--- /dev/null
+++ b/frontend/src/lib/communityDraft.ts
@@ -0,0 +1,38 @@
+"use client";
+
+const KEY = "nomadro-community-draft";
+
+export type CommunityDraft = {
+ title: string;
+ content: string;
+ category: string;
+ updatedAt: number;
+};
+
+export function loadCommunityDraft(): CommunityDraft | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (!raw) return null;
+ return JSON.parse(raw) as CommunityDraft;
+ } catch {
+ return null;
+ }
+}
+
+export function saveCommunityDraft(draft: Omit) {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(
+ KEY,
+ JSON.stringify({ ...draft, updatedAt: Date.now() } satisfies CommunityDraft)
+ );
+ } catch {
+ /* quota */
+ }
+}
+
+export function clearCommunityDraft() {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(KEY);
+}
diff --git a/frontend/src/lib/compareHistory.ts b/frontend/src/lib/compareHistory.ts
new file mode 100644
index 0000000..87cd619
--- /dev/null
+++ b/frontend/src/lib/compareHistory.ts
@@ -0,0 +1,42 @@
+"use client";
+
+export type ComparePreset = {
+ slugs: string[];
+ labels: string[];
+ at: number;
+};
+
+const KEY = "nomadro-compare-history";
+const MAX = 8;
+
+export function loadCompareHistory(): ComparePreset[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (!raw) return [];
+ const list = JSON.parse(raw) as ComparePreset[];
+ return Array.isArray(list) ? list.slice(0, MAX) : [];
+ } catch {
+ return [];
+ }
+}
+
+export function pushCompareHistory(slugs: string[], labels: string[]): ComparePreset[] {
+ if (slugs.length < 2) return loadCompareHistory();
+ const key = slugs.slice().sort().join(",");
+ const next: ComparePreset[] = [
+ { slugs, labels, at: Date.now() },
+ ...loadCompareHistory().filter((p) => p.slugs.slice().sort().join(",") !== key),
+ ].slice(0, MAX);
+ try {
+ localStorage.setItem(KEY, JSON.stringify(next));
+ } catch {
+ /* quota */
+ }
+ return next;
+}
+
+export function clearCompareHistory() {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(KEY);
+}
diff --git a/frontend/src/lib/coworking.ts b/frontend/src/lib/coworking.ts
index 7c7cb9d..d8c3a08 100644
--- a/frontend/src/lib/coworking.ts
+++ b/frontend/src/lib/coworking.ts
@@ -57,4 +57,34 @@ export const COWORKING_SPACES: CoworkingSpace[] = [
price: "¥3,500/天", wifi: 200, rating: 4.6,
perks: ["极致网速", "地铁直达", "静音舱"], vibe: "高效都市",
},
+ {
+ id: "10", city: "柏林", slug: "berlin", emoji: "🎨", name: "Factory Berlin",
+ price: "€25/天", wifi: 140, rating: 4.7,
+ perks: ["创业生态", "活动多", "开源友好"], vibe: "创意科技",
+ },
+ {
+ id: "11", city: "麦德林", slug: "medellin", emoji: "🌺", name: "Selina Cowork",
+ price: "$12/天", wifi: 95, rating: 4.6,
+ perks: ["社区活动", "英语环境", "短住友好"], vibe: "春城社交",
+ },
+ {
+ id: "12", city: "第比利斯", slug: "tbilisi", emoji: "🍷", name: "Impact Hub Tbilisi",
+ price: "$10/天", wifi: 80, rating: 4.5,
+ perks: ["旧城附近", "活动日历", "成本友好"], vibe: "欧亚基地",
+ },
+ {
+ id: "13", city: "大理", slug: "dali", emoji: "🏔️", name: "苍山联合办公",
+ price: "¥40/天", wifi: 85, rating: 4.4,
+ perks: ["风景", "创作者多", "合租情报"], vibe: "慢创作",
+ },
+ {
+ id: "14", city: "首尔", slug: "seoul", emoji: "🏙️", name: "FastFive Gangnam",
+ price: "₩35,000/天", wifi: 220, rating: 4.8,
+ perks: ["超快网速", "深夜开放", "地铁直达"], vibe: "效率都市",
+ },
+ {
+ id: "15", city: "迪拜", slug: "dubai", emoji: "🏙️", name: "AstroLabs",
+ price: "AED 150/天", wifi: 180, rating: 4.6,
+ perks: ["空调充足", "国际社群", "枢纽位置"], vibe: "中东跳板",
+ },
];
diff --git a/frontend/src/lib/i18n/dictionaries.ts b/frontend/src/lib/i18n/dictionaries.ts
index d8011b1..82ed7ff 100644
--- a/frontend/src/lib/i18n/dictionaries.ts
+++ b/frontend/src/lib/i18n/dictionaries.ts
@@ -96,6 +96,7 @@ export const zh = {
nextStopDesc: "不确定去哪时,用决策器收窄选择。",
digital: "游民学院",
digitalDesc: "课程与远程工作路径。",
+ compareDesc: "并排对比后再写入计划。",
},
home: {
mapTag: "🗺️ WORLD MAP",
@@ -157,6 +158,25 @@ export const zh = {
learnMore: "了解更多",
select: "请选择…",
detail: "查看详情 →",
+ alreadyInTrip: "该城市已在行程中",
+ alreadyInPlan: "该城市已在计划中",
+ addedToPlanMonths: "{emoji} {city} 已加入旅居计划({months} 个月)",
+ cityOverBudget: "💰 月生活费 ¥{cost} 高于你的计划月预算 ¥{budget}",
+ linkCopied: "链接已复制到剪贴板 📋",
+ copyFail: "复制失败,请手动复制地址栏",
+ favorite: "🤍 收藏",
+ favorited: "❤️ 已收藏",
+ loginFavorite: "🤍 登录收藏",
+ monthsUnit: "月",
+ addToPlan: "🗓️ 加入旅居计划",
+ inPlanOpen: "✓ 已在计划中 · 打开 →",
+ stayLabel: "停留",
+ goCompare: "⚖️ 去对比",
+ findNext: "🧭 找下一站",
+ cityMeetups: "🍹 同城活动",
+ shareBtn: "📤 分享",
+ loadingDepth: "加载城市深度信息…",
+ viewTimezone: "查看时区看板",
},
plan: {
tag: "🗓️ MOVE PLAN",
@@ -190,6 +210,11 @@ export const zh = {
checklist: "出发就绪清单",
empty: "还没有城市。从目的地加入,或在下方添加第一站。",
goDest: "去选目的地 →",
+ emptyNextStop: "智能下一站",
+ emptyCompare: "城市对比",
+ moveUp: "上移",
+ moveDown: "下移",
+ removeStop: "移除",
visas: "本行程签证方案",
visaGuide: "签证指南 →",
visaMissing: "当前行程国家暂无收录的游民签证条目,请对照停留提醒与官方政策。",
@@ -215,6 +240,20 @@ export const zh = {
colClimate: "气候",
colRating: "评分",
colCommunity: "社区",
+ exportStartMonth: "出发月:{month}",
+ exportNote: "备注:",
+ exportVisa: "签证:",
+ exportStop: "{n}. {emoji} **{name}, {country}** — {months} 个月 · ¥{cost}{range}{note}{visa}",
+ exportTotal: "> 总计 **{months} 个月** · **¥{cost}** · 均月 ¥{avg}",
+ exportReady: "> 出发就绪度 **{pct}%**({checked}/{total})",
+ exportFooter: "_由 nomadro 旅居计划中心生成 · {date}_",
+ plainStop: "{n}. {emoji} {name}, {country} — {months}个月 ¥{cost}{range}",
+ plainTotal: "总计: {months}个月 · ¥{cost} · 就绪 {pct}%",
+ visaDuration: "时长",
+ visaIncome: "收入要求",
+ visaApproval: "审批",
+ visaDifficulty: "难度",
+ visaMissingCountries: "暂无收录:{countries} — 请以官方政策为准,并确认免签天数。",
},
compare: {
tag: "⚖️ COMPARE",
@@ -240,6 +279,16 @@ export const zh = {
needShare: "至少选 2 座城市再分享",
linkCopied: "对比链接已复制",
winner: "👑 综合推荐",
+ history: "最近对比",
+ clearHistory: "清空记录",
+ restore: "恢复",
+ snapshot: "复制对比快照",
+ snapshotOk: "对比快照已复制",
+ snapshotTitle: "⚖️ nomadro 城市对比",
+ scoreLabel: "综合 {score}",
+ loadingDest: "目的地数据加载中或暂不可用",
+ winnerMeetups: "🍹 {city} 同城活动",
+ openPlan: "打开计划中心",
},
login: {
welcome: "欢迎回来",
@@ -260,6 +309,12 @@ export const zh = {
hintGigs: "登录后申请赏金或发布任务",
hintCommunity: "登录后发帖与参与讨论",
hintSubmit: "登录后提交内容投稿",
+ hintDigital: "登录后继续数字游民课程与成长路径",
+ hintVideos: "登录后收藏访谈并同步稍后再看",
+ hintJobs: "登录后收藏远程岗位并继续申请",
+ hintAi: "登录后使用旅居助手并保存提问记录",
+ hintNextStop: "登录后继续下一站决策并写入计划",
+ hintMap: "登录后查看游民分布与数据报告",
redirectTo: "登录后将前往",
planMergeNote: "检测到本机已有行程,登录后会自动与账号合并",
destHome: "首页",
@@ -276,6 +331,12 @@ export const zh = {
destGigs: "赏金任务",
destCommunity: "社区讨论",
destSubmit: "内容投稿",
+ destDigital: "数字游民学院",
+ destVideos: "游民访谈",
+ destJobs: "远程岗位",
+ destAi: "旅居助手",
+ destNextStop: "下一站决策",
+ destMap: "游民地图",
tabLogin: "登录",
tabRegister: "注册",
nickname: "昵称",
@@ -297,6 +358,13 @@ export const zh = {
demoToast: "演示账号已登录",
loginError: "邮箱或密码错误",
registerError: "注册失败,邮箱可能已被使用",
+ nameMin: "昵称至少 2 个字符",
+ pwdMin: "密码至少 6 位",
+ showPwd: "显示密码",
+ pwdHint: "至少 6 位,建议包含字母与数字",
+ pwdNeed: "还差 {n} 位",
+ pwdOk: "可用 · 再长一点更安全",
+ pwdStrong: "强度不错",
},
footer: {
explore: "探索",
@@ -304,6 +372,7 @@ export const zh = {
grow: "成长",
tools: "工具",
account: "账户",
+ tagline: "让每个人都能自由地工作和生活在这个星球上 🌏",
destinations: "目的地",
plan: "旅居计划",
compare: "城市对比",
@@ -346,6 +415,9 @@ export const zh = {
loginDesc: "完善资料后即可滑动卡片,匹配成功可私信聊天",
joinFirst: "先完善你的游民资料",
joinBtn: "一键加入匹配",
+ joinOk: "资料已完善,开始匹配",
+ joinFail: "加入失败",
+ swipeFail: "滑动失败,请检查登录或配额",
empty: "暂时没有更多候选,明天再来看看",
matched: "你和",
keepSwiping: "继续滑",
@@ -355,6 +427,27 @@ export const zh = {
likesEmpty: "还没有喜欢任何人,去滑一张卡片吧",
backSwipe: "返回匹配",
undo: "撤销",
+ goSwipe: "去滑动匹配",
+ globalNomad: "全球游民",
+ noBio: "这位游民还没写自我介绍",
+ meetAtEvents: "去活动认识人",
+ moreIntents: "更多意图…",
+ intentFriends: "交朋友",
+ intentDating: "约会",
+ intentPartner: "伴侣",
+ intentRoommate: "合租",
+ intentCofounder: "联创",
+ intentExplore: "探索",
+ vipUnlimited: "✨ VIP 无限滑",
+ quotaLeft: "今日剩余 {remaining}/{limit} 次",
+ vipUnlock: "开通 VIP 无限匹配",
+ loadingCandidates: "加载候选人…",
+ loadingProfile: "加载匹配资料…",
+ refresh: "刷新",
+ viewChats: "查看对话",
+ openVip: "开通 VIP",
+ defaultCity: "全球",
+ defaultBio: "nomadro 游民",
},
chat: {
tag: "✉️ MESSAGES",
@@ -363,9 +456,16 @@ export const zh = {
loginDesc: "匹配成功或活动结识的游民,在这里继续对话",
empty: "还没有对话,去匹配页滑一张卡片吧",
noMessages: "暂无消息",
+ sayHi: "打个招呼开始对话吧",
+ loadingMessages: "加载消息…",
+ loadingList: "加载对话…",
+ sendFail: "发送失败,请稍后重试",
+ peerFallback: "游民",
+ goDating: "去匹配",
back: "返回列表",
placeholder: "输入消息…",
send: "发送",
+ draftRestored: "已恢复未发送草稿",
},
join: {
tag: "✨ MEMBERSHIP",
@@ -391,12 +491,23 @@ export const zh = {
goDating: "去匹配",
goDigital: "去学院",
retry: "返回开通页",
+ draftHint: "资料草稿会自动保存在本机",
+ saving: "保存中…",
+ vipActive: "✨ 你已是 VIP · 权益已生效",
},
live: {
loading: "加载直播间…",
loginRequired: "登录并报名后可进入直播",
vipRequired: "VIP 会员可进入此活动直播",
back: "返回活动",
+ unavailable: "无法打开直播间",
+ unavailableDesc: "活动可能不存在,或暂时不可用",
+ hostRequired: "仅主办方可进入此房间",
+ offlineOnly: "此活动为线下场次,没有在线直播间",
+ layoutSplit: "分屏",
+ layoutVideo: "视频",
+ layoutChat: "聊天",
+ linksMissing: "直播链接尚未配置",
},
digital: {
tag: "🎓 ACADEMY",
@@ -414,9 +525,27 @@ export const zh = {
jobsDesc: "精选远程/游民友好职位",
jobsSubtitle: "每周更新,点击直达招聘页",
jobsEmpty: "暂无岗位",
+ jobsGoGigs: "去赏金任务",
+ jobsGoCommunity: "社区求助",
+ jobsSave: "收藏",
+ jobsSaved: "已收藏",
+ jobsUnsaved: "已取消收藏",
+ jobsSavedList: "收藏的岗位",
+ jobsClearSaved: "清空收藏",
+ jobsSearch: "搜索岗位、公司或城市…",
+ jobsFilterAll: "全部",
apply: "查看职位",
vip: "VIP 会员",
vipDesc: "解锁全部付费课时与直播",
+ vipUnlocked: "✨ VIP 已解锁付费课时",
+ vipHint: "免费课时可直接学 · VIP 课时需开通会员",
+ unlockVip: "开通解锁",
+ courseEmpty: "课程内容准备中",
+ loadingLesson: "加载课时…",
+ missingLesson: "课时不存在",
+ backCatalog: "返回课程目录",
+ dayGuide: "每日指南",
+ dayGuideDesc: "从税务居民到落地验网,三天起步清单",
back: "学院首页",
backCourse: "课程目录",
locked: "此课时需要 VIP",
@@ -428,6 +557,15 @@ export const zh = {
tag: "🛠️ TOOLKIT",
title: "nomadro 工具箱",
subtitle: "规划、金钱、工作、生活与安全——一站直达所有旅居工具",
+ backHub: "← 返回工具箱",
+ open: "打开 →",
+ searchPlaceholder: "搜索工具,如「签证」「机票」「笔记」…",
+ featuredHint: "先用这几个",
+ loading: "加载工具…",
+ showAll: "展开全部 {n} 个工具",
+ empty: "没有匹配的工具,试试其他关键词",
+ clearFilter: "清除筛选",
+ goNextStop: "去下一站",
corePlan: "旅居计划中心",
corePlanDesc: "时间轴 · 签证 · 预算 · 日历导出",
coreCompare: "城市对比台",
@@ -448,6 +586,8 @@ export const zh = {
hubDigitalDesc: "课程电子书与远程岗位",
hubJoin: "开通会员",
hubJoinDesc: "VIP 解锁直播与付费课",
+ metaSuffix: "工具箱",
+ metaFallback: "工具 · nomadro",
},
nextStop: {
tag: "🧭 NEXT STOP",
@@ -470,6 +610,23 @@ export const zh = {
addedPlan: "已将 {city} 加入计划",
meetups: "近期活动",
allMeetups: "全部活动",
+ climateMild: "🌤️ 温和",
+ climateWarm: "☀️ 偏暖",
+ climateCool: "🍂 偏凉",
+ tagLowCost: "低成本",
+ tagFastNet: "高速网络",
+ tagWarm: "温暖气候",
+ tagCommunity: "社区活跃",
+ tagVisa: "签证友好",
+ tagBeach: "海滨",
+ tagFood: "美食",
+ reasonInBudget: "预算内",
+ reasonOverBudget: "略超预算",
+ reasonNetOk: "网络达标",
+ reasonNetCheck: "需确认网络",
+ moreTags: "更多标签…",
+ resetPrefs: "重置偏好",
+ prefsSaved: "偏好已记住",
},
meetups: {
tag: "🎉 MEETUPS",
@@ -496,8 +653,27 @@ export const zh = {
hostSubmit: "发布活动",
hostOk: "活动已发布",
hostFail: "发布失败",
+ hostNeedFields: "请填写活动名称和日期",
+ hosting: "创建中…",
+ modeOnline: "线上",
+ modeOffline: "线下",
+ modeHybrid: "混合",
+ loginHint: "登录后报名,名额会同步到你的账号",
+ loginToast: "登录后报名,名额才会保留",
+ cancelRsvp: "取消报名",
+ cancelOk: "已取消报名",
+ exportCal: "导出日历",
+ exportCalDone: "已下载活动日历",
+ exportCalFail: "没有可导出的活动日期",
+ addToCal: "加入日历",
loginDesc: "登录后即可创建线上或线下游民活动",
empty: "暂无匹配的活动",
+ draftRestored: "已恢复未发布的活动草稿",
+ draftHint: "内容会自动保存在本机",
+ tripEvents: "行程城市活动",
+ meetPeers: "同城可能认识的人",
+ goDating: "去匹配",
+ viewProfile: "查看资料",
},
community: {
tag: "💬 COMMUNITY",
@@ -525,8 +701,21 @@ export const zh = {
createFail: "发布失败",
replyPlaceholder: "写下你的回复…",
replySend: "发送回复",
+ replySending: "发送中…",
+ replyNeed: "请先写一点回复内容",
replyOk: "回复已发布",
replyFail: "回复失败",
+ noRepliesYet: "还没有回复,来做第一个吧",
+ catAll: "全部",
+ catVisa: "签证",
+ catRemote: "远程工作",
+ catHousing: "住宿",
+ catSafety: "安全",
+ catCommunity: "社区",
+ draftRestored: "已恢复未发布草稿",
+ draftNeed: "标题必填,正文至少 10 字",
+ draftHint: "内容会自动保存在本机",
+ replyDraftRestored: "已恢复未发送的回复草稿",
},
gigs: {
tag: "💼 GIGS",
@@ -536,20 +725,37 @@ export const zh = {
applyPlaceholder: "简述你的经验与报价…",
applyOk: "申请已提交",
applyFail: "申请失败",
+ applyNeed: "请先填写申请说明",
+ applying: "提交中…",
+ applied: "✓ 已申请",
loginFirst: "请先登录",
+ empty: "暂时没有开放任务",
+ emptyHint: "你可以发布一条赏金,或先去学院看看远程岗位",
+ save: "收藏",
+ saved: "已收藏",
+ unsaved: "已取消收藏",
+ savedList: "收藏的赏金",
+ clearSaved: "清空收藏",
postTitle: "发布赏金任务",
postName: "任务标题",
postDesc: "任务描述与交付要求",
postBudget: "预算(如 ¥800)",
postSubmit: "发布",
+ posting: "发布中…",
+ postNeed: "请填写标题和描述",
postOk: "任务已发布",
postFail: "发布失败",
+ budgetNegotiable: "面议",
+ draftHint: "草稿会自动保存在本机",
back: "返回赏金列表",
},
notifications: {
title: "通知",
empty: "暂无通知",
markRead: "全部标为已读",
+ loginDesc: "登录后查看匹配、报名与回复提醒",
+ markOne: "标为已读",
+ emptyHint: "去互动一下,通知会出现在这里",
},
pricing: {
title: "会员方案",
@@ -568,18 +774,73 @@ export const zh = {
subtitle: "Bug、功能建议或安全问题 —— 我们会认真阅读",
placeholder: "描述你遇到的问题或建议…",
submit: "提交反馈",
+ submitting: "提交中…",
ok: "感谢反馈!",
fail: "提交失败",
+ minLength: "请至少写 5 个字",
+ guestHint: "未登录也可提交;登录后便于我们回复你",
+ thanksTitle: "已收到,谢谢",
+ thanksDesc: "我们会认真阅读每一条反馈",
+ writeAgain: "再写一条",
+ goCommunity: "去社区",
catGeneral: "一般反馈",
catBug: "Bug 报告",
catFeature: "功能建议",
catSafety: "安全问题",
+ draftRestored: "已恢复未提交草稿",
+ draftHint: "内容会自动保存在本机",
+ widgetTitle: "给 nomadro 提建议",
+ widgetSubtitle: "功能想法、体验问题,或只是想说喜欢",
+ openFull: "打开完整反馈页",
+ failRetry: "发送失败,请稍后重试或去反馈页",
+ fabAria: "反馈",
+ fabTitle: "反馈建议",
+ close: "关闭",
+ sendBtn: "发送反馈 🚀",
},
videos: {
tag: "🎬 VIDEOS",
title: "游民访谈",
subtitle: "真实旅居故事与目的地实录",
back: "返回视频列表",
+ empty: "视频内容准备中",
+ readEbook: "先读电子书",
+ goDigital: "去学院",
+ noPlayer: "播放链接暂未配置",
+ relatedMeetups: "相关活动",
+ saveLater: "稍后再看",
+ saved: "已加入稍后再看",
+ unsaved: "已移出稍后再看",
+ watchLater: "稍后再看",
+ clearSaved: "清空",
+ savedEmpty: "还没有收藏视频",
+ },
+ digest: {
+ tag: "📌 DIGEST",
+ title: "今日游民摘要",
+ subtitle: "计划、活动、视频与最近探索,一页收口",
+ planCities: "计划城市",
+ readiness: "就绪",
+ rsvps: "已报名",
+ noUpcoming: "暂无即将开始的活动",
+ watchLater: "稍后再看",
+ openVideos: "打开访谈",
+ lastAi: "最近提问",
+ askAi: "去问旅居助手",
+ resumeTitle: "接着探索",
+ resumeSub: "从计划、活动或最近看过的城市继续",
+ },
+ mapPage: {
+ tag: "🗺️ MAP",
+ title: "游民地图",
+ subtitle: "看看同路游民分布在哪里",
+ weather: "目的地天气",
+ hint: "完整交互地图见首页世界地图区块",
+ loading: "加载地图…",
+ empty: "暂无公开会员分布",
+ weatherEmpty: "天气暂不可用",
+ goDating: "去匹配",
+ goMeetups: "去活动",
},
services: {
tag: "🛎️ SERVICES",
@@ -589,17 +850,16 @@ export const zh = {
email: "邮箱",
message: "需求描述",
inquire: "咨询",
+ submitting: "提交中…",
leadOk: "咨询已提交",
leadFail: "提交失败",
- },
- ai: {
- tag: "🤖 AI",
- title: "旅居助手",
- subtitle: "描述预算、网速、气候偏好 —— 智能推荐城市",
- placeholder: "例如:预算 8000、要网速快、气候温暖…",
- ask: "获取推荐",
- fail: "暂时无法回答,请稍后再试",
- nextStop: "打开下一站决策",
+ needFields: "请填写姓名、邮箱和需求说明",
+ needEmail: "请填写有效邮箱地址",
+ sent: "✓ 已提交咨询,我们会尽快联系你",
+ empty: "服务清单准备中",
+ emptyCta: "先去社区问问",
+ draftRestored: "已恢复未提交的咨询草稿",
+ draftHint: "内容会自动保存在本机",
},
report: {
tag: "📊 REPORT",
@@ -608,13 +868,12 @@ export const zh = {
ranking: "城市排名",
members: "活跃游民",
viewMap: "查看地图",
- },
- mapPage: {
- tag: "🗺️ MAP",
- title: "游民地图",
- subtitle: "看看同路游民分布在哪里",
- weather: "目的地天气",
- hint: "完整交互地图见首页世界地图区块",
+ loading: "加载报告…",
+ rankEmpty: "排名数据暂不可用",
+ membersEmpty: "会员分布暂不可用",
+ goNextStop: "去智能下一站",
+ snapshot: "复制报告快照",
+ snapshotOk: "报告快照已复制",
},
submit: {
tag: "📝 SUBMIT",
@@ -627,8 +886,229 @@ export const zh = {
url: "链接(如有)",
notes: "补充说明",
send: "提交审核",
+ submitting: "提交中…",
ok: "投稿已收到",
fail: "提交失败",
+ needTitle: "请填写更完整的标题",
+ needUrl: "文章/视频请填写有效链接(http/https)",
+ thanksTitle: "投稿已收到",
+ thanksDesc: "审核通过后会出现在博客或视频区",
+ writeAgain: "再投一篇",
+ goCommunity: "去社区",
+ draftHint: "草稿会自动保存在本机",
+ },
+ ai: {
+ tag: "🤖 AI",
+ title: "旅居助手",
+ subtitle: "描述预算、网速、气候偏好 —— 智能推荐城市",
+ placeholder: "例如:预算 8000、要网速快、气候温暖…",
+ ask: "获取推荐",
+ fail: "暂时无法回答,请稍后再试",
+ nextStop: "打开下一站决策",
+ thinking: "思考中…",
+ needQuestion: "先写一个问题",
+ noCityData: "推荐城市暂无完整数据,请从详情页加入计划",
+ writeFail: "未能写入计划",
+ writePlan: "写入计划",
+ compareCities: "城市对比",
+ sendHint: "Ctrl/⌘ + Enter 发送",
+ history: "最近提问",
+ clearHistory: "清空历史",
+ tip1: "预算 6000,想找网速好的东南亚城市",
+ tip2: "适合第一次远程办公的城市?",
+ tip3: "清迈和巴厘岛怎么选?",
+ draftRestored: "已恢复未发送的提问草稿",
+ draftHint: "提问草稿会自动保存在本机",
+ },
+ recent: {
+ tag: "👀 RECENT",
+ title: "最近浏览",
+ subtitle: "接着探索你刚看过的城市,或一键对比",
+ compareRecent: "对比最近浏览",
+ searchLabel: "最近看过",
+ },
+ search: {
+ trigger: "搜索...",
+ aria: "搜索",
+ placeholder: "搜城市、活动、工具… 或从下方路径进入",
+ loading: "搜索中...",
+ empty: "😢 没有找到「{q}」相关结果",
+ ringLabel: "从一条路径开始",
+ kbdHint: "⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭",
+ typeDestination: "目的地",
+ typeBlog: "博客",
+ typeVisa: "签证",
+ typeFaq: "FAQ",
+ typeTool: "工具",
+ typePage: "页面",
+ ringDiscover: "智能发现",
+ ringDiscoverSub: "匹配偏好,锁定下一座城市",
+ ringNext: "下一站决策",
+ ringNextSub: "预算网速气候智能推荐",
+ ringMeetups: "游民活动",
+ ringMeetupsSub: "先遇见人,再深入聊",
+ ringCommunity: "社区讨论",
+ ringCommunitySub: "签证远程住宿经验",
+ ringDigital: "游民学院",
+ ringDigitalSub: "课程与远程路径",
+ ringTools: "实用工具",
+ ringToolsSub: "按需取用,不堆菜单",
+ pagePlan: "旅居计划中心",
+ pagePlanSub: "时间轴、预算、签证提醒与出发清单",
+ pageCompare: "城市对比台",
+ pageCompareSub: "并排对比费用网速气候,写入计划",
+ pageDating: "游民匹配",
+ pageDatingSub: "滑动匹配同路游民",
+ pageChat: "私信",
+ pageChatSub: "与匹配成功的游民聊天",
+ pageGigs: "赏金任务",
+ pageGigsSub: "远程小任务接单",
+ pageJoin: "开通会员",
+ pageJoinSub: "VIP 匹配直播与课程",
+ pageBook: "游牧代码电子书",
+ pageBookSub: "在线阅读与下载版",
+ pageVideos: "游民访谈",
+ pageVideosSub: "真实旅居视频",
+ pageServices: "游民服务",
+ pageServicesSub: "签证税务落地咨询",
+ pageAi: "旅居助手",
+ pageAiSub: "AI 推荐下一站城市",
+ pageMap: "游民地图",
+ pageMapSub: "会员分布与天气",
+ pageReport: "数据报告",
+ pageReportSub: "城市排名快照",
+ pageFeedback: "意见反馈",
+ pageFeedbackSub: "Bug 与建议",
+ pageSubmit: "内容投稿",
+ pageSubmitSub: "文章视频电子书",
+ pageHelp: "帮助中心",
+ pageHelpSub: "常见问题",
+ pageContact: "联系我们",
+ pageContactSub: "合作与媒体",
+ pagePricing: "会员定价",
+ pagePricingSub: "免费版与 VIP 方案",
+ pageNotif: "通知中心",
+ pageNotifSub: "匹配与社区动态",
+ pageAbout: "关于 nomadro",
+ pageAboutSub: "品牌与联系方式",
+ pageChangelog: "更新日志",
+ pageChangelogSub: "功能迭代记录",
+ pagePrivacy: "隐私政策",
+ pagePrivacySub: "Cookie 与数据说明",
+ pageCommunitySub: "签证远程住宿经验交流",
+ pageMeetupsSub: "线上圆桌与线下聚会",
+ pageDigitalSub: "课程电子书与远程岗位",
+ pageTools: "工具箱",
+ pageToolsSub: "旅居小工具按需取用",
+ },
+ share: {
+ button: "📤 分享",
+ aria: "分享",
+ system: "📱 系统分享",
+ copy: "📋 复制链接",
+ twitter: "🐦 分享到 X",
+ },
+ destDetail: {
+ guide: "🧭 落地指南",
+ bestFor: "适合:",
+ radar: "📊 城市画像",
+ scoreValue: "性价比",
+ scoreSpeed: "网速",
+ scoreClimate: "气候",
+ scoreRating: "评分",
+ scoreCommunity: "社区",
+ timezone: "时区",
+ nomadCommunity: "游民社区",
+ active: "活跃",
+ climate: "气候",
+ livable: "宜居",
+ costBreak: "💸 月度成本拆解",
+ pros: "👍 优势",
+ cons: "👎 注意",
+ reviews: "💬 游民评价",
+ localChat: "💬 同城社群",
+ peopleUnit: "人",
+ recentTopics: "近期话题:",
+ relatedMeetups: "🍹 相关活动",
+ relatedDiscussions: "🧵 相关讨论",
+ relatedContent: "📚 相关内容",
+ relatedRegion: "🔗 同区域推荐",
+ viewFallback: "查看",
+ snapCost: "月生活费 ¥{cost} · 网速 {speed}Mbps · 评分 {rating}",
+ snapClimate: "均温 {temp}°C · 游民 {nomads}",
+ snapVisa: "签证提示:{visa}",
+ snapDetail: "详情:{url}",
+ perMonth: "/月",
+ },
+ profile: {
+ myRsvps: "我报名的活动",
+ rsvpEmpty: "还没有报名活动",
+ goMeetups: "去看看活动 →",
+ openLive: "进入直播",
+ snapshotOk: "城市快照已复制",
+ snapshotBtn: "📋 复制快照",
+ exportRsvps: "导出已报名日历",
+ logout: "退出登录",
+ changeAvatar: "更换头像",
+ uploading: "上传中…",
+ avatarOk: "头像已更新",
+ avatarFail: "上传失败",
+ avatarTooBig: "图片请小于 5MB",
+ syncing: "计划同步中…",
+ synced: "✓ 计划已云端同步",
+ offline: "计划仅本机",
+ loggedIn: "账号已登录",
+ statFavs: "收藏目的地",
+ statCities: "计划城市",
+ statReady: "出发就绪",
+ statBadges: "成就徽章",
+ openPlan: "打开计划中心",
+ tripEmpty: "还没有规划行程",
+ goPlan: "去规划 →",
+ startLabel: "出发 {month}",
+ budgetLabel: "月预算 ¥{budget}",
+ avgMonthLabel: " · 均月 ¥{avg}",
+ readyCount: "就绪 {checked}/{total}",
+ monthsCost: "{months} 个月 · ¥{cost}",
+ totalLine: "总计",
+ monthsUnit: "个月",
+ compareTrip: "对比行程城市",
+ keepEditing: "继续编辑 →",
+ achievements: "🏅 游民成就",
+ myFavs: "❤️ 我的收藏",
+ favsToPlan: "收藏写入计划({n})",
+ favsAlready: "收藏城市都已在计划中",
+ favsAdded: "已将 {n} 座收藏城市写入计划",
+ favsEmpty: "还没有收藏目的地",
+ goExplore: "去探索 →",
+ loading: "加载中...",
+ perMonth: "/月",
+ quickLinks: "🚀 快捷入口",
+ qDest: "🌍 浏览目的地",
+ qPlan: "🗓️ 旅居计划",
+ qCompare: "⚖️ 城市对比",
+ qNext: "🧭 智能下一站",
+ qMeetups: "🎉 游民活动",
+ qDating: "💕 游民匹配",
+ qChat: "✉️ 私信",
+ qNotif: "🔔 通知",
+ qVip: "✨ 开通 VIP",
+ qVipMember: "✨ VIP 会员",
+ qDigital: "🎓 游民学院",
+ qAi: "🤖 旅居助手",
+ qTools: "🛠️ 工具箱",
+ badgeNewbie: "初出茅庐",
+ badgeNewbieDesc: "加入 nomadro",
+ badgeCollector: "收藏家",
+ badgeCollectorDesc: "收藏 3+ 目的地",
+ badgePlanner: "行程达人",
+ badgePlannerDesc: "规划 3+ 城市行程",
+ badgeLongStay: "长期旅居",
+ badgeLongStayDesc: "行程总计 6+ 个月",
+ badgeReady: "出发就绪",
+ badgeReadyDesc: "就绪清单 ≥ 50%",
+ badgeGlobal: "环球游民",
+ badgeGlobalDesc: "探索 5+ 城市",
},
notifSettings: {
title: "通知设置",
@@ -637,6 +1117,8 @@ export const zh = {
save: "保存设置",
saved: "设置已保存",
fail: "保存失败",
+ loading: "加载偏好…",
+ saving: "保存中…",
match: "匹配通知",
meetup: "活动提醒",
community: "社区动态",
@@ -655,6 +1137,117 @@ export const zh = {
pwa: {
installHint: "可安装到主屏幕,离线也能打开计划入口",
},
+ matcher: {
+ tag: "🎯 MATCHER · {n}/{total}",
+ resultsTag: "✨ YOUR MATCHES",
+ close: "关闭",
+ cancel: "取消",
+ back: "← 上一步",
+ budgetTitle: "你的月预算?",
+ budgetSub: "包含住宿、餐饮、交通等日常开销",
+ climateTitle: "偏好什么气候?",
+ climateSub: "选择最让你舒适的环境",
+ priorityTitle: "最看重什么?",
+ prioritySub: "我们会据此为你智能排序",
+ regionTitle: "想去哪个区域?",
+ regionSub: "可以选「不限」探索全球",
+ budgetLow: "精打细算",
+ budgetLowDesc: "¥5,000 以下/月",
+ budgetMed: "舒适适中",
+ budgetMedDesc: "¥5,000 – 9,000/月",
+ budgetHigh: "品质优先",
+ budgetHighDesc: "¥9,000 以上/月",
+ climateWarm: "热带温暖",
+ climateWarmDesc: "25°C 以上,阳光沙滩",
+ climateMild: "温和宜人",
+ climateMildDesc: "18–25°C,四季舒适",
+ climateCool: "凉爽清爽",
+ climateCoolDesc: "18°C 以下,清爽干燥",
+ priCost: "生活成本",
+ priCostDesc: "花最少的钱过最好的生活",
+ priSpeed: "网络速度",
+ priSpeedDesc: "稳定高速,会议不掉线",
+ priCommunity: "游民社区",
+ priCommunityDesc: "结识同行,快速融入",
+ priLifestyle: "生活方式",
+ priLifestyleDesc: "文化、美食与体验",
+ regionAny: "不限",
+ regionAnyDesc: "全球探索",
+ regionSea: "东南亚",
+ regionSeaDesc: "性价比之王",
+ regionEurope: "欧洲",
+ regionEuropeDesc: "历史与签证友好",
+ regionLatam: "拉美",
+ regionLatamDesc: "活力与北美时区",
+ regionAsia: "东亚",
+ regionAsiaDesc: "安全高效现代",
+ resultsTitle: "为你推荐的目的地",
+ resultsSub: "偏好已保存。可一键写入旅居计划,或先对比再决定。",
+ empty: "暂无匹配结果,目的地数据可能未加载",
+ retry: "重新测试",
+ goNextStop: "去智能下一站",
+ matchPct: "{n}% 匹配",
+ addPlan: "+ 计划",
+ retryBtn: "🔄 重新测试",
+ compareTop: "⚖️ 对比 Top {n}",
+ writeTop: "把 Top {n} 写入计划 →",
+ alreadyAll: "这几座城已在计划中",
+ addFail: "未能加入计划",
+ addedTop: "已将 Top {n} 城写入旅居计划",
+ skipped: "(跳过 {n} 座已有)",
+ },
+ shortcuts: {
+ title: "键盘快捷键",
+ subtitle: "提升浏览效率,按 ? 随时唤起",
+ aria: "键盘快捷键",
+ close: "关闭",
+ search: "全局搜索",
+ matcher: "智能匹配",
+ nextStop: "下一站",
+ plan: "旅居计划",
+ tools: "打开工具箱",
+ help: "快捷键帮助",
+ esc: "关闭弹窗",
+ },
+ tips: {
+ title: "游民小贴士",
+ more: "了解更多 →",
+ close: "关闭",
+ tip1: "入住前用 Speedtest 测网速,低于 20Mbps 慎重签约",
+ tip2: "准备一张无外汇手续费的信用卡,省下不少换汇成本",
+ tip3: "出发前购买国际医疗保险,月费约 $40+",
+ tip4: "选时区重叠 ≥4 小时的城市,远程协作更轻松",
+ tip5: "护照有效期建议预留 6 个月以上,避免登机被拒",
+ tip6: "先订 1 周短租探路,再决定长期租房",
+ },
+ faqUi: {
+ empty: "没有匹配的问题",
+ clear: "清除搜索",
+ help: "帮助中心",
+ askAi: "问 AI",
+ showAll: "展开全部 {n} 个问题",
+ still: "还在选城?",
+ browse: "继续看目的地",
+ },
+ cookies: {
+ body: "我们使用本地存储保存主题、行程与登录会话,不做广告追踪。",
+ privacy: "隐私政策",
+ cookies: "Cookie 说明",
+ ok: "知道了 ✓",
+ },
+ onboard: {
+ aria: "新手引导",
+ skip: "跳过",
+ next: "下一步 →",
+ startMatch: "开始匹配 →",
+ goDigital: "去学院看看 →",
+ step1Title: "发现去哪",
+ step1Desc: "用智能匹配或地图,先锁定下一座城市",
+ step2Title: "遇见同行",
+ step2Desc: "同城活动与社区讨论,先认识真实的人",
+ step3Title: "成长变现",
+ step3Desc: "学院与赏金任务,在路上继续做事",
+ },
lang: {
zh: "中文",
en: "EN",
@@ -759,6 +1352,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
nextStopDesc: "Narrow choices when you are unsure.",
digital: "Academy",
digitalDesc: "Courses and remote-work tracks.",
+ compareDesc: "Compare side-by-side, then add to plan.",
},
home: {
mapTag: "🗺️ WORLD MAP",
@@ -820,6 +1414,25 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
learnMore: "Learn more",
select: "Select…",
detail: "Details →",
+ alreadyInTrip: "Already in your trip",
+ alreadyInPlan: "Already in your plan",
+ addedToPlanMonths: "{emoji} {city} added to move plan ({months} mo)",
+ cityOverBudget: "💰 Monthly cost ¥{cost} is above your plan budget ¥{budget}",
+ linkCopied: "Link copied 📋",
+ copyFail: "Copy failed — copy from the address bar",
+ favorite: "🤍 Save",
+ favorited: "❤️ Saved",
+ loginFavorite: "🤍 Sign in to save",
+ monthsUnit: "mo",
+ addToPlan: "🗓️ Add to move plan",
+ inPlanOpen: "✓ In plan · Open →",
+ stayLabel: "Stay",
+ goCompare: "⚖️ Compare",
+ findNext: "🧭 Next stop",
+ cityMeetups: "🍹 Local events",
+ shareBtn: "📤 Share",
+ loadingDepth: "Loading city depth…",
+ viewTimezone: "See timezone board",
},
plan: {
tag: "🗓️ MOVE PLAN",
@@ -853,6 +1466,11 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
checklist: "Departure checklist",
empty: "No cities yet. Pick destinations or add your first stop below.",
goDest: "Browse destinations →",
+ emptyNextStop: "Smart next stop",
+ emptyCompare: "Compare cities",
+ moveUp: "Move up",
+ moveDown: "Move down",
+ removeStop: "Remove",
visas: "Visas for this trip",
visaGuide: "Visa guide →",
visaMissing: "No nomad-visa entries for these countries yet — check stay hints and official policy.",
@@ -878,6 +1496,20 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
colClimate: "Climate",
colRating: "Rating",
colCommunity: "Community",
+ exportStartMonth: "Start month: {month}",
+ exportNote: "Note: ",
+ exportVisa: "Visa: ",
+ exportStop: "{n}. {emoji} **{name}, {country}** — {months} mo · ¥{cost}{range}{note}{visa}",
+ exportTotal: "> Total **{months} mo** · **¥{cost}** · avg ¥{avg}/mo",
+ exportReady: "> Readiness **{pct}%** ({checked}/{total})",
+ exportFooter: "_Generated by nomadro move plan · {date}_",
+ plainStop: "{n}. {emoji} {name}, {country} — {months}mo ¥{cost}{range}",
+ plainTotal: "Total: {months}mo · ¥{cost} · readiness {pct}%",
+ visaDuration: "Duration",
+ visaIncome: "Income",
+ visaApproval: "Approval",
+ visaDifficulty: "Difficulty",
+ visaMissingCountries: "No entries yet: {countries} — check official policy and visa-free days.",
},
compare: {
tag: "⚖️ COMPARE",
@@ -903,6 +1535,16 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
needShare: "Pick at least 2 cities to share",
linkCopied: "Compare link copied",
winner: "👑 Top pick",
+ history: "Recent compares",
+ clearHistory: "Clear history",
+ restore: "Restore",
+ snapshot: "Copy compare snapshot",
+ snapshotOk: "Compare snapshot copied",
+ snapshotTitle: "⚖️ nomadro city compare",
+ scoreLabel: "score {score}",
+ loadingDest: "Destinations loading or unavailable",
+ winnerMeetups: "🍹 Events in {city}",
+ openPlan: "Open plan hub",
},
login: {
welcome: "Welcome back",
@@ -923,6 +1565,12 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
hintGigs: "Sign in to apply for gigs or post bounties",
hintCommunity: "Sign in to post and join discussions",
hintSubmit: "Sign in to submit content",
+ hintDigital: "Continue digital nomad courses and growth tracks",
+ hintVideos: "Save interviews and sync watch-later",
+ hintJobs: "Save remote roles and keep applying",
+ hintAi: "Use the travel assistant and keep your questions",
+ hintNextStop: "Continue next-stop decisions and write to plan",
+ hintMap: "Explore member map and city reports",
redirectTo: "After sign-in you'll go to",
planMergeNote: "Local trip data found — we'll merge it with your account",
destHome: "Home",
@@ -939,6 +1587,12 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
destGigs: "Bounty gigs",
destCommunity: "Community",
destSubmit: "Content submit",
+ destDigital: "Digital academy",
+ destVideos: "Nomad videos",
+ destJobs: "Remote jobs",
+ destAi: "Travel assistant",
+ destNextStop: "Next stop",
+ destMap: "Nomad map",
tabLogin: "Log in",
tabRegister: "Sign up",
nickname: "Nickname",
@@ -960,6 +1614,13 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
demoToast: "Signed in with demo account",
loginError: "Wrong email or password",
registerError: "Sign-up failed — email may already be in use",
+ nameMin: "Name must be at least 2 characters",
+ pwdMin: "Password must be at least 6 characters",
+ showPwd: "Show password",
+ pwdHint: "At least 6 characters — letters and numbers recommended",
+ pwdNeed: "{n} more characters needed",
+ pwdOk: "OK · a bit longer is safer",
+ pwdStrong: "Looking strong",
},
footer: {
explore: "Explore",
@@ -967,6 +1628,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
grow: "Grow",
tools: "Tools",
account: "Account",
+ tagline: "Work and live freely anywhere on this planet 🌏",
destinations: "Destinations",
plan: "Move plan",
compare: "Compare",
@@ -1009,6 +1671,9 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
loginDesc: "Complete your profile, then swipe cards and chat on match",
joinFirst: "Complete your nomad profile first",
joinBtn: "Join matching",
+ joinOk: "Profile ready — start matching",
+ joinFail: "Could not join",
+ swipeFail: "Swipe failed — check login or quota",
empty: "No more candidates — check back tomorrow",
matched: "You matched with",
keepSwiping: "Keep swiping",
@@ -1018,6 +1683,27 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
likesEmpty: "No likes yet — go swipe some cards",
backSwipe: "Back to match",
undo: "Undo",
+ goSwipe: "Start swiping",
+ globalNomad: "Global nomad",
+ noBio: "This nomad hasn't written a bio yet",
+ meetAtEvents: "Meet people at events",
+ moreIntents: "More intents…",
+ intentFriends: "Friends",
+ intentDating: "Dating",
+ intentPartner: "Partner",
+ intentRoommate: "Roommate",
+ intentCofounder: "Cofounder",
+ intentExplore: "Explore",
+ vipUnlimited: "✨ VIP unlimited swipes",
+ quotaLeft: "{remaining}/{limit} left today",
+ vipUnlock: "Get VIP for unlimited matching",
+ loadingCandidates: "Loading candidates…",
+ loadingProfile: "Loading match profile…",
+ refresh: "Refresh",
+ viewChats: "View chats",
+ openVip: "Get VIP",
+ defaultCity: "Global",
+ defaultBio: "nomadro nomad",
},
chat: {
tag: "✉️ MESSAGES",
@@ -1026,9 +1712,16 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
loginDesc: "Continue conversations with nomads you matched or met at events",
empty: "No conversations yet — swipe on the match page",
noMessages: "No messages yet",
+ sayHi: "Say hi to start the conversation",
+ loadingMessages: "Loading messages…",
+ loadingList: "Loading conversations…",
+ sendFail: "Could not send — try again",
+ peerFallback: "Nomad",
+ goDating: "Go match",
back: "Back to inbox",
placeholder: "Type a message…",
send: "Send",
+ draftRestored: "Unsent draft restored",
},
join: {
tag: "✨ MEMBERSHIP",
@@ -1054,12 +1747,23 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
goDating: "Go to match",
goDigital: "Go to academy",
retry: "Back to membership",
+ draftHint: "Profile draft autosaves on this device",
+ saving: "Saving…",
+ vipActive: "✨ You're VIP — perks are active",
},
live: {
loading: "Loading live room…",
loginRequired: "Sign in and RSVP to join the live room",
vipRequired: "VIP required for this live event",
back: "Back to events",
+ unavailable: "Can't open live room",
+ unavailableDesc: "This event may not exist or is temporarily unavailable",
+ hostRequired: "Only the host can enter this room",
+ offlineOnly: "This is an offline event — no live room",
+ layoutSplit: "Split",
+ layoutVideo: "Video",
+ layoutChat: "Chat",
+ linksMissing: "Live links are not configured yet",
},
digital: {
tag: "🎓 ACADEMY",
@@ -1077,9 +1781,27 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
jobsDesc: "Curated remote-friendly roles",
jobsSubtitle: "Updated weekly — links open employer pages",
jobsEmpty: "No jobs listed",
+ jobsGoGigs: "Browse gigs",
+ jobsGoCommunity: "Ask community",
+ jobsSave: "Save",
+ jobsSaved: "Saved",
+ jobsUnsaved: "Removed from saved",
+ jobsSavedList: "Saved jobs",
+ jobsClearSaved: "Clear saved",
+ jobsSearch: "Search role, company or city…",
+ jobsFilterAll: "All",
apply: "View role",
vip: "VIP membership",
vipDesc: "Unlock all paid lessons and live events",
+ vipUnlocked: "✨ VIP unlocked — paid lessons available",
+ vipHint: "Free lessons are open · VIP lessons need membership",
+ unlockVip: "Unlock with VIP",
+ courseEmpty: "Course content coming soon",
+ loadingLesson: "Loading lesson…",
+ missingLesson: "Lesson not found",
+ backCatalog: "Back to catalog",
+ dayGuide: "Daily guides",
+ dayGuideDesc: "Tax residency to landing Wi‑Fi — a 3-day starter checklist",
back: "Academy home",
backCourse: "Course catalog",
locked: "VIP required",
@@ -1091,6 +1813,15 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
tag: "🛠️ TOOLKIT",
title: "nomadro toolkit",
subtitle: "Planning, money, work, life and safety — every nomad tool in one place",
+ backHub: "← Back to toolkit",
+ open: "Open →",
+ searchPlaceholder: "Search tools, e.g. visa, flights, notes…",
+ featuredHint: "Start with these",
+ loading: "Loading tool…",
+ showAll: "Show all {n} tools",
+ empty: "No tools match — try another keyword",
+ clearFilter: "Clear filters",
+ goNextStop: "Next stop",
corePlan: "Move plan",
corePlanDesc: "Timeline · visas · budget · ICS",
coreCompare: "City compare",
@@ -1111,6 +1842,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
hubDigitalDesc: "Courses, ebook & remote jobs",
hubJoin: "VIP membership",
hubJoinDesc: "Unlock live events & lessons",
+ metaSuffix: "Toolkit",
+ metaFallback: "Tools · nomadro",
},
nextStop: {
tag: "🧭 NEXT STOP",
@@ -1133,6 +1866,23 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
addedPlan: "{city} added to plan",
meetups: "Upcoming events",
allMeetups: "All events",
+ climateMild: "🌤️ Mild",
+ climateWarm: "☀️ Warm",
+ climateCool: "🍂 Cool",
+ tagLowCost: "Low cost",
+ tagFastNet: "Fast internet",
+ tagWarm: "Warm climate",
+ tagCommunity: "Active community",
+ tagVisa: "Visa friendly",
+ tagBeach: "Beach",
+ tagFood: "Food",
+ reasonInBudget: "In budget",
+ reasonOverBudget: "Slightly over budget",
+ reasonNetOk: "Internet OK",
+ reasonNetCheck: "Check internet",
+ moreTags: "More tags…",
+ resetPrefs: "Reset prefs",
+ prefsSaved: "Preferences remembered",
},
meetups: {
tag: "🎉 MEETUPS",
@@ -1159,8 +1909,27 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
hostSubmit: "Publish",
hostOk: "Event published",
hostFail: "Could not publish",
+ hostNeedFields: "Please fill in the title and date",
+ hosting: "Publishing…",
+ modeOnline: "Online",
+ modeOffline: "In person",
+ modeHybrid: "Hybrid",
+ loginHint: "Sign in to RSVP — seats sync to your account",
+ loginToast: "Sign in to RSVP and keep your seat",
+ cancelRsvp: "Cancel RSVP",
+ cancelOk: "RSVP cancelled",
+ exportCal: "Export calendar",
+ exportCalDone: "Event calendar downloaded",
+ exportCalFail: "No events with dates to export",
+ addToCal: "Add to calendar",
loginDesc: "Sign in to host online or in-person nomad events",
empty: "No events match this filter",
+ draftRestored: "Unpublished event draft restored",
+ draftHint: "Draft autosaves on this device",
+ tripEvents: "Events near your trip",
+ meetPeers: "People you may meet nearby",
+ goDating: "Go match",
+ viewProfile: "View profile",
},
community: {
tag: "💬 COMMUNITY",
@@ -1188,8 +1957,21 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
createFail: "Could not publish",
replyPlaceholder: "Write a reply…",
replySend: "Post reply",
+ replySending: "Sending…",
+ replyNeed: "Write a reply first",
replyOk: "Reply posted",
replyFail: "Reply failed",
+ noRepliesYet: "No replies yet — be the first",
+ catAll: "All",
+ catVisa: "Visa",
+ catRemote: "Remote work",
+ catHousing: "Housing",
+ catSafety: "Safety",
+ catCommunity: "Community",
+ draftRestored: "Unpublished draft restored",
+ draftNeed: "Title required, body at least 10 characters",
+ draftHint: "Draft autosaves on this device",
+ replyDraftRestored: "Unsent reply draft restored",
},
gigs: {
tag: "💼 GIGS",
@@ -1199,20 +1981,37 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
applyPlaceholder: "Your experience and rate…",
applyOk: "Application sent",
applyFail: "Application failed",
+ applyNeed: "Write a short application note first",
+ applying: "Sending…",
+ applied: "✓ Applied",
loginFirst: "Sign in first",
+ empty: "No open gigs right now",
+ emptyHint: "Post a bounty, or browse academy remote jobs",
+ save: "Save",
+ saved: "Saved",
+ unsaved: "Removed from saved",
+ savedList: "Saved gigs",
+ clearSaved: "Clear saved",
postTitle: "Post a gig",
postName: "Task title",
postDesc: "Description and deliverables",
postBudget: "Budget (e.g. ¥800)",
postSubmit: "Publish",
+ posting: "Publishing…",
+ postNeed: "Title and description are required",
postOk: "Gig published",
postFail: "Could not publish",
+ budgetNegotiable: "Negotiable",
+ draftHint: "Draft autosaves on this device",
back: "Back to gigs",
},
notifications: {
title: "Notifications",
empty: "No notifications",
markRead: "Mark all read",
+ loginDesc: "Sign in to see matches, RSVPs and replies",
+ markOne: "Mark read",
+ emptyHint: "Interact a bit — alerts will show up here",
},
pricing: {
title: "Plans",
@@ -1231,18 +2030,61 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
subtitle: "Bugs, ideas, or safety issues — we read every message",
placeholder: "Describe your issue or suggestion…",
submit: "Send feedback",
+ submitting: "Sending…",
ok: "Thanks for your feedback!",
fail: "Could not send",
+ minLength: "Please write at least 5 characters",
+ guestHint: "No login required — sign in if you want a reply",
+ thanksTitle: "Got it — thank you",
+ thanksDesc: "We read every piece of feedback",
+ writeAgain: "Write another",
+ goCommunity: "Go to community",
catGeneral: "General",
catBug: "Bug report",
catFeature: "Feature request",
catSafety: "Safety",
+ draftRestored: "Unsaved draft restored",
+ draftHint: "Draft autosaves on this device",
+ widgetTitle: "Suggest improvements for nomadro",
+ widgetSubtitle: "Feature ideas, UX issues, or just say you like it",
+ openFull: "Open full feedback page",
+ failRetry: "Send failed — retry later or open the feedback page",
+ fabAria: "Feedback",
+ fabTitle: "Send feedback",
+ close: "Close",
+ sendBtn: "Send feedback 🚀",
},
videos: {
tag: "🎬 VIDEOS",
title: "Nomad interviews",
subtitle: "Real stories from the road",
back: "Back to videos",
+ empty: "Videos coming soon",
+ readEbook: "Read the ebook",
+ goDigital: "Academy",
+ noPlayer: "Playback link not configured yet",
+ relatedMeetups: "Related events",
+ saveLater: "Watch later",
+ saved: "Saved to watch later",
+ unsaved: "Removed from watch later",
+ watchLater: "Watch later",
+ clearSaved: "Clear",
+ savedEmpty: "No saved videos yet",
+ },
+ digest: {
+ tag: "📌 DIGEST",
+ title: "Today's nomad digest",
+ subtitle: "Plan, events, videos and recent explores in one place",
+ planCities: "Plan cities",
+ readiness: "Ready",
+ rsvps: "RSVPs",
+ noUpcoming: "No upcoming events",
+ watchLater: "Watch later",
+ openVideos: "Open videos",
+ lastAi: "Last question",
+ askAi: "Ask the assistant",
+ resumeTitle: "Pick up where you left off",
+ resumeSub: "Continue from plan, events, or recent cities",
},
services: {
tag: "🛎️ SERVICES",
@@ -1252,8 +2094,16 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
email: "Email",
message: "What do you need?",
inquire: "Inquire",
+ submitting: "Sending…",
leadOk: "Inquiry sent",
leadFail: "Could not send",
+ needFields: "Name, email and message are required",
+ needEmail: "Enter a valid email address",
+ sent: "✓ Inquiry sent — we'll get back to you soon",
+ empty: "Services listing coming soon",
+ emptyCta: "Ask in the community",
+ draftRestored: "Unsent inquiry draft restored",
+ draftHint: "Draft autosaves on this device",
},
ai: {
tag: "🤖 AI",
@@ -1263,6 +2113,210 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
ask: "Get picks",
fail: "Could not answer — try again",
nextStop: "Open next-stop hub",
+ thinking: "Thinking…",
+ needQuestion: "Write a question first",
+ noCityData: "No full city data yet — add from the detail page",
+ writeFail: "Could not add to plan",
+ writePlan: "Add to plan",
+ compareCities: "Compare cities",
+ sendHint: "Ctrl/⌘ + Enter to send",
+ history: "Recent questions",
+ clearHistory: "Clear history",
+ tip1: "Budget 6000, fast internet in Southeast Asia",
+ tip2: "Best cities for first-time remote work?",
+ tip3: "Chiang Mai vs Bali — which fits me?",
+ draftRestored: "Unsent question draft restored",
+ draftHint: "Question draft autosaves on this device",
+ },
+ recent: {
+ tag: "👀 RECENT",
+ title: "Recently viewed",
+ subtitle: "Pick up where you left off, or compare them",
+ compareRecent: "Compare recent cities",
+ searchLabel: "Recently viewed",
+ },
+ search: {
+ trigger: "Search...",
+ aria: "Search",
+ placeholder: "Cities, events, tools… or pick a path below",
+ loading: "Searching...",
+ empty: "😢 No results for “{q}”",
+ ringLabel: "Start from a path",
+ kbdHint: "⌨️ ↑↓ select · Enter go · Esc close",
+ typeDestination: "City",
+ typeBlog: "Blog",
+ typeVisa: "Visa",
+ typeFaq: "FAQ",
+ typeTool: "Tool",
+ typePage: "Page",
+ ringDiscover: "Smart discover",
+ ringDiscoverSub: "Match preferences to your next city",
+ ringNext: "Next stop",
+ ringNextSub: "Budget, Wi‑Fi, climate picks",
+ ringMeetups: "Events",
+ ringMeetupsSub: "Meet people first",
+ ringCommunity: "Community",
+ ringCommunitySub: "Visas, remote work, housing",
+ ringDigital: "Academy",
+ ringDigitalSub: "Courses and remote paths",
+ ringTools: "Tools",
+ ringToolsSub: "Grab what you need",
+ pagePlan: "Move plan hub",
+ pagePlanSub: "Timeline, budget, visa tips, checklist",
+ pageCompare: "City compare",
+ pageCompareSub: "Side-by-side cost, Wi‑Fi, climate",
+ pageDating: "Nomad match",
+ pageDatingSub: "Swipe to meet fellow nomads",
+ pageChat: "Messages",
+ pageChatSub: "Chat with matches",
+ pageGigs: "Bounty gigs",
+ pageGigsSub: "Small remote tasks",
+ pageJoin: "Membership",
+ pageJoinSub: "VIP matching, live events, courses",
+ pageBook: "Nomad Code ebook",
+ pageBookSub: "Read online or download",
+ pageVideos: "Videos",
+ pageVideosSub: "Real nomad stories",
+ pageServices: "Services",
+ pageServicesSub: "Visa, tax, landing help",
+ pageAi: "AI assistant",
+ pageAiSub: "City picks for your next stop",
+ pageMap: "Nomad map",
+ pageMapSub: "Member map and weather",
+ pageReport: "Data report",
+ pageReportSub: "City ranking snapshot",
+ pageFeedback: "Feedback",
+ pageFeedbackSub: "Bugs and ideas",
+ pageSubmit: "Submit content",
+ pageSubmitSub: "Articles, videos, ebooks",
+ pageHelp: "Help",
+ pageHelpSub: "FAQ",
+ pageContact: "Contact",
+ pageContactSub: "Partners and press",
+ pagePricing: "Pricing",
+ pagePricingSub: "Free and VIP plans",
+ pageNotif: "Notifications",
+ pageNotifSub: "Matches and community",
+ pageAbout: "About nomadro",
+ pageAboutSub: "Brand and contact",
+ pageChangelog: "Changelog",
+ pageChangelogSub: "Product updates",
+ pagePrivacy: "Privacy",
+ pagePrivacySub: "Cookies and data",
+ pageCommunitySub: "Visa and housing tips from nomads",
+ pageMeetupsSub: "Online roundtables and meetups",
+ pageDigitalSub: "Courses, ebook, remote jobs",
+ pageTools: "Toolkit",
+ pageToolsSub: "Nomad utilities on demand",
+ },
+ share: {
+ button: "📤 Share",
+ aria: "Share",
+ system: "📱 System share",
+ copy: "📋 Copy link",
+ twitter: "🐦 Share on X",
+ },
+ destDetail: {
+ guide: "🧭 Arrival guide",
+ bestFor: "Best for:",
+ radar: "📊 City profile",
+ scoreValue: "Value",
+ scoreSpeed: "Wi‑Fi",
+ scoreClimate: "Climate",
+ scoreRating: "Rating",
+ scoreCommunity: "Community",
+ timezone: "Timezone",
+ nomadCommunity: "Nomad community",
+ active: "active",
+ climate: "Climate",
+ livable: "Best months",
+ costBreak: "💸 Monthly cost breakdown",
+ pros: "👍 Pros",
+ cons: "👎 Watch-outs",
+ reviews: "💬 Nomad reviews",
+ localChat: "💬 Local chats",
+ peopleUnit: "people",
+ recentTopics: "Recent topics:",
+ relatedMeetups: "🍹 Related events",
+ relatedDiscussions: "🧵 Related threads",
+ relatedContent: "📚 Related content",
+ relatedRegion: "🔗 Same region",
+ viewFallback: "View",
+ snapCost: "Cost ¥{cost}/mo · {speed}Mbps · rating {rating}",
+ snapClimate: "Avg {temp}°C · nomads {nomads}",
+ snapVisa: "Visa tip: {visa}",
+ snapDetail: "Details: {url}",
+ perMonth: "/mo",
+ },
+ profile: {
+ myRsvps: "My RSVPs",
+ rsvpEmpty: "No event RSVPs yet",
+ goMeetups: "Browse events →",
+ openLive: "Join live",
+ snapshotOk: "City snapshot copied",
+ snapshotBtn: "📋 Copy snapshot",
+ exportRsvps: "Export RSVP calendar",
+ logout: "Sign out",
+ changeAvatar: "Change avatar",
+ uploading: "Uploading…",
+ avatarOk: "Avatar updated",
+ avatarFail: "Upload failed",
+ avatarTooBig: "Image must be under 5MB",
+ syncing: "Syncing plan…",
+ synced: "✓ Plan synced to cloud",
+ offline: "Plan local only",
+ loggedIn: "Signed in",
+ statFavs: "Favorites",
+ statCities: "Plan cities",
+ statReady: "Readiness",
+ statBadges: "Badges",
+ openPlan: "Open plan hub",
+ tripEmpty: "No trip planned yet",
+ goPlan: "Start planning →",
+ startLabel: "Start {month}",
+ budgetLabel: "Budget ¥{budget}/mo",
+ avgMonthLabel: " · avg ¥{avg}",
+ readyCount: "Ready {checked}/{total}",
+ monthsCost: "{months} mo · ¥{cost}",
+ totalLine: "Total",
+ monthsUnit: "mo",
+ compareTrip: "Compare trip cities",
+ keepEditing: "Keep editing →",
+ achievements: "🏅 Nomad badges",
+ myFavs: "❤️ My favorites",
+ favsToPlan: "Add favorites to plan ({n})",
+ favsAlready: "Favorite cities are already in your plan",
+ favsAdded: "Added {n} favorite cities to plan",
+ favsEmpty: "No favorite destinations yet",
+ goExplore: "Explore →",
+ loading: "Loading...",
+ perMonth: "/mo",
+ quickLinks: "🚀 Quick links",
+ qDest: "🌍 Destinations",
+ qPlan: "🗓️ Move plan",
+ qCompare: "⚖️ Compare",
+ qNext: "🧭 Next stop",
+ qMeetups: "🎉 Events",
+ qDating: "💕 Match",
+ qChat: "✉️ Messages",
+ qNotif: "🔔 Alerts",
+ qVip: "✨ Get VIP",
+ qVipMember: "✨ VIP member",
+ qDigital: "🎓 Academy",
+ qAi: "🤖 AI assistant",
+ qTools: "🛠️ Tools",
+ badgeNewbie: "Newcomer",
+ badgeNewbieDesc: "Joined nomadro",
+ badgeCollector: "Collector",
+ badgeCollectorDesc: "Favorited 3+ destinations",
+ badgePlanner: "Route planner",
+ badgePlannerDesc: "Planned 3+ cities",
+ badgeLongStay: "Long stay",
+ badgeLongStayDesc: "Trip totals 6+ months",
+ badgeReady: "Departure ready",
+ badgeReadyDesc: "Checklist ≥ 50%",
+ badgeGlobal: "Global nomad",
+ badgeGlobalDesc: "Explored 5+ cities",
},
report: {
tag: "📊 REPORT",
@@ -1271,6 +2325,12 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
ranking: "City ranking",
members: "Active nomads",
viewMap: "View map",
+ loading: "Loading report…",
+ rankEmpty: "Ranking data unavailable",
+ membersEmpty: "Member map unavailable",
+ goNextStop: "Open next-stop hub",
+ snapshot: "Copy report snapshot",
+ snapshotOk: "Report snapshot copied",
},
mapPage: {
tag: "🗺️ MAP",
@@ -1278,6 +2338,11 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
subtitle: "Where community members are based",
weather: "Destination weather",
hint: "Full interactive map on the homepage",
+ loading: "Loading map…",
+ empty: "No public member locations yet",
+ weatherEmpty: "Weather unavailable",
+ goDating: "Go match",
+ goMeetups: "Browse events",
},
submit: {
tag: "📝 SUBMIT",
@@ -1290,8 +2355,16 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
url: "Link (optional)",
notes: "Notes",
send: "Submit",
+ submitting: "Submitting…",
ok: "Submission received",
fail: "Could not submit",
+ needTitle: "Please enter a fuller title",
+ needUrl: "Articles/videos need a valid http/https link",
+ thanksTitle: "Submission received",
+ thanksDesc: "Approved items appear in blog or videos",
+ writeAgain: "Submit another",
+ goCommunity: "Go to community",
+ draftHint: "Draft autosaves on this device",
},
notifSettings: {
title: "Notification settings",
@@ -1300,6 +2373,8 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
save: "Save",
saved: "Settings saved",
fail: "Could not save",
+ loading: "Loading preferences…",
+ saving: "Saving…",
match: "Match alerts",
meetup: "Event reminders",
community: "Community",
@@ -1318,6 +2393,117 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
pwa: {
installHint: "Install to home screen — open plan offline-ready",
},
+ matcher: {
+ tag: "🎯 MATCHER · {n}/{total}",
+ resultsTag: "✨ YOUR MATCHES",
+ close: "Close",
+ cancel: "Cancel",
+ back: "← Back",
+ budgetTitle: "Monthly budget?",
+ budgetSub: "Housing, food, transport and daily costs",
+ climateTitle: "Preferred climate?",
+ climateSub: "Pick the environment that feels right",
+ priorityTitle: "What matters most?",
+ prioritySub: "We'll rank cities with this in mind",
+ regionTitle: "Which region?",
+ regionSub: "Choose Anywhere to explore the world",
+ budgetLow: "Budget-first",
+ budgetLowDesc: "Under ¥5,000 / month",
+ budgetMed: "Comfortable",
+ budgetMedDesc: "¥5,000 – 9,000 / month",
+ budgetHigh: "Quality first",
+ budgetHighDesc: "¥9,000+ / month",
+ climateWarm: "Tropical warm",
+ climateWarmDesc: "25°C+, sun and beach",
+ climateMild: "Mild",
+ climateMildDesc: "18–25°C, comfortable year-round",
+ climateCool: "Cool & dry",
+ climateCoolDesc: "Under 18°C, crisp and dry",
+ priCost: "Cost of living",
+ priCostDesc: "More life for less money",
+ priSpeed: "Internet speed",
+ priSpeedDesc: "Stable and fast for meetings",
+ priCommunity: "Nomad community",
+ priCommunityDesc: "Meet peers and settle in fast",
+ priLifestyle: "Lifestyle",
+ priLifestyleDesc: "Culture, food and experiences",
+ regionAny: "Anywhere",
+ regionAnyDesc: "Global explore",
+ regionSea: "Southeast Asia",
+ regionSeaDesc: "Best value",
+ regionEurope: "Europe",
+ regionEuropeDesc: "History and visa-friendly",
+ regionLatam: "LatAm",
+ regionLatamDesc: "Energy and US timezones",
+ regionAsia: "East Asia",
+ regionAsiaDesc: "Safe, efficient, modern",
+ resultsTitle: "Destinations for you",
+ resultsSub: "Prefs saved. Add to your move plan, or compare first.",
+ empty: "No matches yet — destination data may still be loading",
+ retry: "Retake quiz",
+ goNextStop: "Open next-stop hub",
+ matchPct: "{n}% match",
+ addPlan: "+ Plan",
+ retryBtn: "🔄 Retake quiz",
+ compareTop: "⚖️ Compare Top {n}",
+ writeTop: "Add Top {n} to plan →",
+ alreadyAll: "These cities are already in your plan",
+ addFail: "Could not add to plan",
+ addedTop: "Added Top {n} cities to your move plan",
+ skipped: " (skipped {n} already there)",
+ },
+ shortcuts: {
+ title: "Keyboard shortcuts",
+ subtitle: "Browse faster — press ? anytime",
+ aria: "Keyboard shortcuts",
+ close: "Close",
+ search: "Global search",
+ matcher: "Smart matcher",
+ nextStop: "Next stop",
+ plan: "Move plan",
+ tools: "Open toolkit",
+ help: "Shortcut help",
+ esc: "Close dialog",
+ },
+ tips: {
+ title: "Nomad tip",
+ more: "Learn more →",
+ close: "Close",
+ tip1: "Speedtest Wi‑Fi before signing a lease — under 20Mbps is risky",
+ tip2: "Carry a no-FX-fee card to save on currency conversion",
+ tip3: "Get international health insurance before you fly (~$40+/mo)",
+ tip4: "Pick cities with ≥4h timezone overlap for easier remote work",
+ tip5: "Keep 6+ months passport validity to avoid boarding issues",
+ tip6: "Book 1 week short-term first, then commit to a longer lease",
+ },
+ faqUi: {
+ empty: "No FAQ matched",
+ clear: "Clear search",
+ help: "Help center",
+ askAi: "Ask AI",
+ showAll: "Show all {n} questions",
+ still: "Still exploring? ",
+ browse: "Browse destinations",
+ },
+ cookies: {
+ body: "We use local storage for theme, trip and login session — no ad tracking.",
+ privacy: "Privacy",
+ cookies: "Cookie notice",
+ ok: "Got it ✓",
+ },
+ onboard: {
+ aria: "Onboarding",
+ skip: "Skip",
+ next: "Next →",
+ startMatch: "Start matching →",
+ goDigital: "See the academy →",
+ step1Title: "Find where to go",
+ step1Desc: "Use smart match or the map to lock your next city",
+ step2Title: "Meet peers",
+ step2Desc: "Local events and community — meet real people first",
+ step3Title: "Grow on the road",
+ step3Desc: "Academy and gigs so you keep building while traveling",
+ },
lang: {
zh: "中文",
en: "EN",
diff --git a/frontend/src/lib/localDraft.ts b/frontend/src/lib/localDraft.ts
new file mode 100644
index 0000000..e3f5e35
--- /dev/null
+++ b/frontend/src/lib/localDraft.ts
@@ -0,0 +1,27 @@
+"use client";
+
+/** Tiny localStorage draft helper used by write forms. */
+export function loadDraft(key: string): T | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
+
+export function saveDraft(key: string, value: unknown) {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ /* quota */
+ }
+}
+
+export function clearDraft(key: string) {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(key);
+}
diff --git a/frontend/src/lib/meetupIcs.ts b/frontend/src/lib/meetupIcs.ts
new file mode 100644
index 0000000..728564b
--- /dev/null
+++ b/frontend/src/lib/meetupIcs.ts
@@ -0,0 +1,76 @@
+import type { Meetup } from "@/lib/types";
+import { downloadPlanIcs } from "@/lib/planIcs";
+
+function pad(n: number) {
+ return String(n).padStart(2, "0");
+}
+
+function escapeText(s: string): string {
+ return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
+}
+
+function parseMeetupStart(m: Meetup): Date | null {
+ const date = (m.date || "").trim();
+ if (!date) return null;
+ const time = (m.time || "19:00").trim();
+ const iso = `${date}T${time.length === 5 ? `${time}:00` : time}`;
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) {
+ const fallback = new Date(`${date}T12:00:00`);
+ return Number.isNaN(fallback.getTime()) ? null : fallback;
+ }
+ return d;
+}
+
+function formatUtc(d: Date): string {
+ return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
+}
+
+/** Build an ICS calendar from meetup RSVPs. */
+export function buildMeetupIcs(meetups: Meetup[], calendarName = "nomadro 活动"): string | null {
+ const events = meetups
+ .map((m) => ({ m, start: parseMeetupStart(m) }))
+ .filter((x): x is { m: Meetup; start: Date } => Boolean(x.start));
+ if (!events.length) return null;
+
+ const now = formatUtc(new Date());
+ const lines = [
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//nomadro//Meetups//CN",
+ "CALSCALE:GREGORIAN",
+ "METHOD:PUBLISH",
+ `X-WR-CALNAME:${escapeText(calendarName)}`,
+ ];
+
+ for (const { m, start } of events) {
+ const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
+ const summary = `${m.emoji} ${m.title}`;
+ const desc = [
+ m.description,
+ `形式:${m.mode}`,
+ m.organizer ? `主办:${m.organizer}` : "",
+ "由 nomadro 活动导出",
+ ]
+ .filter(Boolean)
+ .join("\\n");
+ lines.push(
+ "BEGIN:VEVENT",
+ `UID:nomadro-meetup-${m.id}@nomadro.com`,
+ `DTSTAMP:${now}`,
+ `DTSTART:${formatUtc(start)}`,
+ `DTEND:${formatUtc(end)}`,
+ `SUMMARY:${escapeText(summary)}`,
+ `DESCRIPTION:${escapeText(desc)}`,
+ `LOCATION:${escapeText(m.venue || m.city)}`,
+ "END:VEVENT"
+ );
+ }
+
+ lines.push("END:VCALENDAR");
+ return lines.join("\r\n");
+}
+
+export function downloadMeetupIcs(filename: string, ics: string) {
+ downloadPlanIcs(filename, ics);
+}
diff --git a/frontend/src/lib/meetupLinks.ts b/frontend/src/lib/meetupLinks.ts
new file mode 100644
index 0000000..171e7c6
--- /dev/null
+++ b/frontend/src/lib/meetupLinks.ts
@@ -0,0 +1,30 @@
+import type { Meetup } from "@/lib/types";
+
+/** City-filtered meetups hub. */
+export function meetupCityHref(city: string) {
+ const c = city.trim();
+ if (!c) return "/meetups";
+ return `/meetups?city=${encodeURIComponent(c)}`;
+}
+
+/** Prefer live room for online/hybrid events; otherwise city-filtered list. */
+export function meetupEventHref(m: Pick) {
+ if (m.mode === "online" || m.mode === "hybrid") {
+ return `/meetups/${m.id}/live`;
+ }
+ return meetupCityHref(m.city);
+}
+
+/** Match the longest known city name mentioned in free text. */
+export function inferMeetupCity(text: string, cityNames: string[]): string | undefined {
+ const hay = text.toLowerCase();
+ if (!hay.trim()) return undefined;
+ let best: string | undefined;
+ for (const name of cityNames) {
+ const n = name.trim();
+ if (n.length < 2) continue;
+ if (!hay.includes(n.toLowerCase())) continue;
+ if (!best || n.length > best.length) best = n;
+ }
+ return best;
+}
diff --git a/frontend/src/lib/nextStopPrefs.ts b/frontend/src/lib/nextStopPrefs.ts
new file mode 100644
index 0000000..ed7ce33
--- /dev/null
+++ b/frontend/src/lib/nextStopPrefs.ts
@@ -0,0 +1,40 @@
+"use client";
+
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+
+export type NextStopPrefs = {
+ budget: number;
+ internet: number;
+ climate: string;
+ selectedTags: string[];
+};
+
+const KEY = "nomadro-next-stop-prefs";
+
+const DEFAULTS: NextStopPrefs = {
+ budget: 8000,
+ internet: 50,
+ climate: "mild",
+ selectedTags: ["社区活跃"],
+};
+
+export function loadNextStopPrefs(): NextStopPrefs {
+ const raw = loadDraft>(KEY);
+ if (!raw) return { ...DEFAULTS };
+ return {
+ budget: typeof raw.budget === "number" ? raw.budget : DEFAULTS.budget,
+ internet: typeof raw.internet === "number" ? raw.internet : DEFAULTS.internet,
+ climate: raw.climate || DEFAULTS.climate,
+ selectedTags: Array.isArray(raw.selectedTags) && raw.selectedTags.length
+ ? raw.selectedTags
+ : DEFAULTS.selectedTags,
+ };
+}
+
+export function saveNextStopPrefs(prefs: NextStopPrefs) {
+ saveDraft(KEY, prefs);
+}
+
+export function clearNextStopPrefs() {
+ clearDraft(KEY);
+}
diff --git a/frontend/src/lib/recentDestinations.ts b/frontend/src/lib/recentDestinations.ts
new file mode 100644
index 0000000..1e02d8f
--- /dev/null
+++ b/frontend/src/lib/recentDestinations.ts
@@ -0,0 +1,45 @@
+"use client";
+
+export type RecentDestination = {
+ slug: string;
+ name: string;
+ country: string;
+ emoji: string;
+ cost: number;
+ rating: number;
+ viewedAt: number;
+};
+
+const KEY = "nomadro-recent-destinations";
+const MAX = 8;
+
+export function loadRecentDestinations(): RecentDestination[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (!raw) return [];
+ const list = JSON.parse(raw) as RecentDestination[];
+ return Array.isArray(list) ? list.slice(0, MAX) : [];
+ } catch {
+ return [];
+ }
+}
+
+export function trackRecentDestination(item: Omit): RecentDestination[] {
+ if (typeof window === "undefined") return [];
+ const next: RecentDestination[] = [
+ { ...item, viewedAt: Date.now() },
+ ...loadRecentDestinations().filter((d) => d.slug !== item.slug),
+ ].slice(0, MAX);
+ try {
+ localStorage.setItem(KEY, JSON.stringify(next));
+ } catch {
+ /* quota */
+ }
+ return next;
+}
+
+export function clearRecentDestinations() {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(KEY);
+}
diff --git a/frontend/src/lib/rings.ts b/frontend/src/lib/rings.ts
index f6f1117..f458de7 100644
--- a/frontend/src/lib/rings.ts
+++ b/frontend/src/lib/rings.ts
@@ -1,54 +1,206 @@
"use client";
+
+
import { useI18n } from "@/lib/i18n";
+
import type { RingStep } from "@/components/RingNext";
+import { meetupCityHref } from "@/lib/meetupLinks";
+
+
+
/** Shared ring definitions — hubs pick the next slice, never the full catalog. */
+
export function useRingSteps() {
+
const { t } = useI18n();
- const afterCity: RingStep[] = [
+
+
+ const afterCity = (city?: string): RingStep[] => [
+
{ href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc },
+
+ {
+
+ href: city ? meetupCityHref(city) : "/meetups",
+
+ emoji: "🎉",
+
+ label: t.ring.meetups,
+
+ desc: t.ring.meetupsDesc,
+
+ },
+
+ {
+
+ href: "/compare",
+
+ emoji: "⚖️",
+
+ label: t.nav.compare,
+
+ desc: t.ring.compareDesc,
+
+ },
+
+ ].slice(0, 3);
+
+
+
+ const afterNextStop: RingStep[] = [
+
+ { href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc },
+
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
+
+ { href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
+
];
+
+
+ const afterCompare = (slugs: string[], winnerCity?: string): RingStep[] => [
+
+ { href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc },
+
+ {
+
+ href: winnerCity ? meetupCityHref(winnerCity) : "/meetups",
+
+ emoji: "🎉",
+
+ label: t.ring.meetups,
+
+ desc: t.ring.meetupsDesc,
+
+ },
+
+ {
+
+ href: slugs.length ? `/destinations/${slugs[0]}` : "/#destinations",
+
+ emoji: "🌍",
+
+ label: t.nav.destinations,
+
+ desc: t.ring.nextStopDesc,
+
+ },
+
+ ];
+
+
+
const afterMeetups: RingStep[] = [
+
{ href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityDesc },
+
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
+
];
- const afterCommunity: RingStep[] = [
- { href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsFromCommunity },
+
+
+ const afterCommunity = (city?: string): RingStep[] => [
+
+ {
+
+ href: city ? meetupCityHref(city) : "/meetups",
+
+ emoji: "🎉",
+
+ label: t.ring.meetups,
+
+ desc: t.ring.meetupsFromCommunity,
+
+ },
+
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
+
];
+
+
const afterDating: RingStep[] = [
+
{ href: "/chat", emoji: "✉️", label: t.ring.chat, desc: t.ring.chatDesc },
+
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsFromDating },
+
+ { href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityDesc },
+
];
+
+
const afterDiscover: RingStep[] = [
+
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
+
{ href: "/next-stop", emoji: "🧭", label: t.ring.nextStop, desc: t.ring.nextStopDesc },
+
];
+
+
const afterGigs: RingStep[] = [
+
{ href: "/digital", emoji: "🎓", label: t.ring.digital, desc: t.ring.digitalDesc },
- { href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityWork },
+
+ { href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc },
+
+ { href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
+
];
+
+
+ const afterDigital: RingStep[] = [
+
+ { href: "/gigs", emoji: "💼", label: t.nav.gigs, desc: t.ring.communityWork },
+
+ { href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc },
+
+ { href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
+
+ ];
+
+
+
const afterTools: RingStep[] = [
{ href: "/next-stop", emoji: "🧭", label: t.ring.nextStop, desc: t.ring.nextStopDesc },
{ href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc },
+ { href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
];
+
return {
+
afterCity,
+
+ afterNextStop,
+
+ afterCompare,
+
afterMeetups,
+
afterCommunity,
+
afterDating,
+
afterDiscover,
+
afterGigs,
+
+ afterDigital,
+
afterTools,
+
};
+
}
+
+
diff --git a/frontend/src/lib/savedGigs.ts b/frontend/src/lib/savedGigs.ts
new file mode 100644
index 0000000..83dfe67
--- /dev/null
+++ b/frontend/src/lib/savedGigs.ts
@@ -0,0 +1,54 @@
+"use client";
+
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+
+export type SavedGig = {
+ id: string;
+ title: string;
+ budget: string;
+ deadline: string;
+ savedAt: number;
+};
+
+const LIST_KEY = "nomadro-saved-gigs";
+const APPLY_KEY = "nomadro-gig-apply-drafts";
+const MAX = 30;
+
+export function loadSavedGigs(): SavedGig[] {
+ const list = loadDraft(LIST_KEY);
+ return Array.isArray(list) ? list.slice(0, MAX) : [];
+}
+
+export function toggleSavedGig(item: Omit): SavedGig[] {
+ const prev = loadSavedGigs();
+ const exists = prev.some((g) => g.id === item.id);
+ const next = exists
+ ? prev.filter((g) => g.id !== item.id)
+ : [{ ...item, savedAt: Date.now() }, ...prev].slice(0, MAX);
+ saveDraft(LIST_KEY, next);
+ return next;
+}
+
+export function clearSavedGigs() {
+ clearDraft(LIST_KEY);
+}
+
+export function loadApplyDrafts(): Record {
+ return loadDraft>(APPLY_KEY) || {};
+}
+
+export function saveApplyDraft(id: string, message: string) {
+ const all = loadApplyDrafts();
+ if (!message.trim()) {
+ delete all[id];
+ } else {
+ all[id] = message;
+ }
+ saveDraft(APPLY_KEY, all);
+}
+
+export function clearApplyDraft(id: string) {
+ const all = loadApplyDrafts();
+ delete all[id];
+ saveDraft(APPLY_KEY, all);
+}
diff --git a/frontend/src/lib/savedJobs.ts b/frontend/src/lib/savedJobs.ts
new file mode 100644
index 0000000..8c7a777
--- /dev/null
+++ b/frontend/src/lib/savedJobs.ts
@@ -0,0 +1,35 @@
+"use client";
+
+import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
+
+export type SavedJob = {
+ id: string;
+ title: string;
+ company: string;
+ location: string;
+ salary: string;
+ url: string;
+ savedAt: number;
+};
+
+const KEY = "nomadro-saved-jobs";
+const MAX = 30;
+
+export function loadSavedJobs(): SavedJob[] {
+ const list = loadDraft(KEY);
+ return Array.isArray(list) ? list.slice(0, MAX) : [];
+}
+
+export function toggleSavedJob(item: Omit): SavedJob[] {
+ const prev = loadSavedJobs();
+ const exists = prev.some((j) => j.id === item.id);
+ const next = exists
+ ? prev.filter((j) => j.id !== item.id)
+ : [{ ...item, savedAt: Date.now() }, ...prev].slice(0, MAX);
+ saveDraft(KEY, next);
+ return next;
+}
+
+export function clearSavedJobs() {
+ clearDraft(KEY);
+}
diff --git a/frontend/src/lib/toast.tsx b/frontend/src/lib/toast.tsx
index 12a0f10..df2a931 100644
--- a/frontend/src/lib/toast.tsx
+++ b/frontend/src/lib/toast.tsx
@@ -1,18 +1,25 @@
"use client";
+import Link from "next/link";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
type ToastType = "success" | "error" | "info";
+export type ToastAction = {
+ href: string;
+ label: string;
+};
+
interface ToastItem {
id: number;
message: string;
type: ToastType;
icon: string;
+ action?: ToastAction;
}
interface ToastContextValue {
- toast: (message: string, type?: ToastType) => void;
+ toast: (message: string, type?: ToastType, action?: ToastAction) => void;
}
const ICONS: Record = {
@@ -26,10 +33,11 @@ const ToastContext = createContext({ toast: () => {} });
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState([]);
- const toast = useCallback((message: string, type: ToastType = "success") => {
+ const toast = useCallback((message: string, type: ToastType = "success", action?: ToastAction) => {
const id = Date.now() + Math.random();
- setItems((prev) => [...prev, { id, message, type, icon: ICONS[type] }]);
- setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), 3800);
+ setItems((prev) => [...prev, { id, message, type, icon: ICONS[type], action }]);
+ const ttl = action ? 5600 : 3800;
+ setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), ttl);
}, []);
return (
@@ -40,6 +48,11 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{t.icon}
{t.message}
+ {t.action && (
+
+ {t.action.label}
+
+ )}
))}
diff --git a/frontend/src/lib/toolCopy.ts b/frontend/src/lib/toolCopy.ts
new file mode 100644
index 0000000..f3d4bc2
--- /dev/null
+++ b/frontend/src/lib/toolCopy.ts
@@ -0,0 +1,98 @@
+import type { Locale } from "@/lib/i18n";
+import { TOOL_LINKS, type ToolLink } from "@/lib/tools";
+
+type ToolCopy = Record;
+
+/** English titles/descriptions for the toolkit catalog (ZH lives on TOOL_LINKS). */
+const EN: ToolCopy = {
+ trip: { title: "Trip planner", desc: "Multi-city routes and budget totals" },
+ spin: { title: "Destiny wheel", desc: "Randomize your next stop" },
+ matcher: { title: "Smart matcher", desc: "Quiz-based destination picks" },
+ season: { title: "Best months", desc: "Avoid rain and peak season" },
+ weekend: { title: "Weekend ideas", desc: "City activities by energy level" },
+ "rainy-day": { title: "Rainy-day plan", desc: "Indoor ideas when it pours" },
+ "day-plan": { title: "Remote day planner", desc: "Block work into a timeline" },
+ daylight: { title: "Daylight work window", desc: "Sunrise, sunset & deep work" },
+ calculator: { title: "Cost calculator", desc: "Estimate by housing tier" },
+ "cost-compare": { title: "Home vs nomad cost", desc: "Compare hometown vs travel costs" },
+ savings: { title: "Launch fund goal", desc: "Track savings progress" },
+ runway: { title: "Nomad runway", desc: "How many months can savings last" },
+ "first-month": { title: "First-month landing cost", desc: "Rent, deposit, SIM in one calc" },
+ expenses: { title: "Expense ledger", desc: "Categorize and sum spending" },
+ "bill-split": { title: "Bill split", desc: "Per-person + tip in seconds" },
+ "invoice-fx": { title: "Invoice FX", desc: "Net after fees and FX" },
+ tipping: { title: "Tipping cheat sheet", desc: "Local tipping norms" },
+ laundry: { title: "Laundry day budget", desc: "Estimate by kilo" },
+ flight: { title: "Flight estimate", desc: "Rough round-trip fare" },
+ currency: { title: "Currency converter", desc: "Multi-currency convert" },
+ "tax-days": { title: "Tax-day counter", desc: "Track the 183-day rule" },
+ timezone: { title: "Timezone board", desc: "Work-hour overlap" },
+ meetings: { title: "Meeting golden hours", desc: "Align shared meeting windows" },
+ jetlag: { title: "Jetlag recovery", desc: "Recovery tips after flights" },
+ sleep: { title: "Sleep cycles", desc: "Back-calculate bedtime" },
+ wifi: { title: "Wi‑Fi grade", desc: "Is speed enough for remote work" },
+ workspot: { title: "Workspot cost", desc: "Month pass vs café value" },
+ "data-budget": { title: "Data budget", desc: "Monthly data and plan tips" },
+ focus: { title: "Focus timer", desc: "Pomodoro deep work" },
+ mood: { title: "Mood check-in", desc: "One-minute travel mood log" },
+ hydration: { title: "Hydration tracker", desc: "Daily water goals by climate" },
+ coworking: { title: "Coworking", desc: "Popular co-working spaces" },
+ cafes: { title: "Cafés", desc: "Laptop-friendly coffee shops" },
+ visa: { title: "Visa guide", desc: "Policy and smart matching" },
+ "visa-stay": { title: "Stay countdown", desc: "Visa days remaining" },
+ housing: { title: "Housing guide", desc: "Coliving / apartment options" },
+ "coliving-qa": { title: "Viewing questions", desc: "Must-ask coliving checklist" },
+ "deposit-return": { title: "Deposit return checklist", desc: "Checkout without surprises" },
+ sim: { title: "Connectivity", desc: "SIM / eSIM recommendations" },
+ plugs: { title: "Power plugs", desc: "Voltage and plug types" },
+ packing: { title: "Packing list", desc: "Pre-departure checklist" },
+ "bag-weight": { title: "Bag weight", desc: "Estimate vs airline limits" },
+ arrival: { title: "Arrival checklist", desc: "First week on the ground" },
+ "airport-transfer": { title: "Airport transfer", desc: "Cheap / fast / easy ranking" },
+ phrases: { title: "Survival phrases", desc: "Copy common phrases" },
+ notes: { title: "City notes", desc: "Private local notes" },
+ insurance: { title: "Insurance guide", desc: "Compare travel health cover" },
+ emergency: { title: "Emergency contacts", desc: "First aid and consulates" },
+ atm: { title: "ATM pitfalls", desc: "Cash and FX tips" },
+ "food-water": { title: "Food & water", desc: "Stomach and drinking water tips" },
+ "scam-alerts": { title: "Local scam alerts", desc: "Common tricks at a glance" },
+};
+
+const CAT_EN: Record = {
+ all: "🌏 All",
+ plan: "🗺️ Plan",
+ money: "💰 Money",
+ work: "💻 Work",
+ life: "🌴 Life",
+ safety: "🛡️ Safety",
+};
+
+const CAT_ZH: Record = {
+ all: "🌏 全部",
+ plan: "🗺️ 规划",
+ money: "💰 金钱",
+ work: "💻 工作",
+ life: "🌴 生活",
+ safety: "🛡️ 安全",
+};
+
+export function localizedToolLinks(locale: Locale): ToolLink[] {
+ if (locale !== "en") return TOOL_LINKS;
+ return TOOL_LINKS.map((tool) => {
+ const copy = EN[tool.id];
+ if (!copy) return tool;
+ return { ...tool, title: copy.title, desc: copy.desc };
+ });
+}
+
+export function localizedToolCategories(locale: Locale) {
+ const labels = locale === "en" ? CAT_EN : CAT_ZH;
+ return (["all", "plan", "money", "work", "life", "safety"] as const).map((key) => ({
+ key,
+ label: labels[key],
+ }));
+}
+
+export function localizedTool(id: string, locale: Locale): ToolLink | undefined {
+ return localizedToolLinks(locale).find((t) => t.id === id);
+}
diff --git a/frontend/src/lib/watchLater.ts b/frontend/src/lib/watchLater.ts
new file mode 100644
index 0000000..87fc579
--- /dev/null
+++ b/frontend/src/lib/watchLater.ts
@@ -0,0 +1,48 @@
+"use client";
+
+export type SavedVideo = {
+ slug: string;
+ title: string;
+ emoji: string;
+ city: string;
+ duration: string;
+ savedAt: number;
+};
+
+const KEY = "nomadro-watch-later";
+const MAX = 20;
+
+export function loadWatchLater(): SavedVideo[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (!raw) return [];
+ const list = JSON.parse(raw) as SavedVideo[];
+ return Array.isArray(list) ? list.slice(0, MAX) : [];
+ } catch {
+ return [];
+ }
+}
+
+export function isWatchLater(slug: string): boolean {
+ return loadWatchLater().some((v) => v.slug === slug);
+}
+
+export function toggleWatchLater(item: Omit): SavedVideo[] {
+ const prev = loadWatchLater();
+ const exists = prev.some((v) => v.slug === item.slug);
+ const next = exists
+ ? prev.filter((v) => v.slug !== item.slug)
+ : [{ ...item, savedAt: Date.now() }, ...prev].slice(0, MAX);
+ try {
+ localStorage.setItem(KEY, JSON.stringify(next));
+ } catch {
+ /* quota */
+ }
+ return next;
+}
+
+export function clearWatchLater() {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(KEY);
+}
diff --git a/scripts/deploy_quick.py b/scripts/deploy_quick.py
index 1e0e38b..5e62e6f 100644
--- a/scripts/deploy_quick.py
+++ b/scripts/deploy_quick.py
@@ -32,6 +32,10 @@ API_FILES = [
"backend/app/services/meetup_live.py",
"backend/app/data/community_data.py",
"backend/app/data/social_profiles.py",
+ "backend/app/data/mock_data.py",
+ "backend/app/data/platform_data.py",
+ "backend/app/data/digital_content.py",
+ "backend/app/data/city_details_data.py",
]
WEB_FILES = [