Fix dating match gate and expand seed data for usable demos.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric 2026-09-04 06:18:25 -05:00
parent 8ea3f622ed
commit bdf0516197
99 changed files with 7149 additions and 909 deletions

View File

@ -187,6 +187,71 @@ EXTRA_ROUTES: list[dict[str, Any]] = [
"status": "published", "status": "published",
"emoji": "🚄", "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", "status": "published",
"authorName": "nomadro", "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",
},
] ]

View File

@ -26,7 +26,7 @@ MEETUPS = [
"id": "chiangmai-cowork", "id": "chiangmai-cowork",
"title": "清迈联合办公下午茶", "title": "清迈联合办公下午茶",
"city": "清迈", "city": "清迈",
"destination_slug": "chiang-mai", "destination_slug": "chiangmai",
"emoji": "☕", "emoji": "☕",
"date": "2026-09-18", "date": "2026-09-18",
"time": "15:00", "time": "15:00",
@ -96,6 +96,133 @@ MEETUPS = [
"tags": ["税务", "日本", "干货"], "tags": ["税务", "日本", "干货"],
"is_upcoming": True, "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 = [ DISCUSSIONS = [
@ -164,6 +291,90 @@ DISCUSSIONS = [
"created_at": "2026-08-28", "created_at": "2026-08-28",
"tags": ["活动", "投票"], "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 = [ GIGS = [
@ -176,6 +387,8 @@ GIGS = [
"tags": ["设计", "远程"], "tags": ["设计", "远程"],
"poster": "nomadro", "poster": "nomadro",
"status": "open", "status": "open",
"category": "设计",
"location": "远程",
}, },
{ {
"id": "meetup-host", "id": "meetup-host",
@ -186,6 +399,8 @@ GIGS = [
"tags": ["活动", "深圳"], "tags": ["活动", "深圳"],
"poster": "深圳湾区主理人", "poster": "深圳湾区主理人",
"status": "open", "status": "open",
"category": "活动",
"location": "深圳",
}, },
{ {
"id": "content-translate", "id": "content-translate",
@ -196,6 +411,116 @@ GIGS = [
"tags": ["翻译", "内容"], "tags": ["翻译", "内容"],
"poster": "nomadro", "poster": "nomadro",
"status": "open", "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", "created_at": "2026-08-21",
"like_count": 8, "like_count": 8,
}, },
{
"id": "r3",
"author": "Yuki",
"author_emoji": "🇮🇩",
"content": "巴厘岛短期用 B211A 足够,长期再看第二家园成本是否划算。",
"created_at": "2026-08-22",
"like_count": 6,
},
], ],
"async-remote": [ "async-remote": [
{ {
@ -227,6 +560,64 @@ DISCUSSION_REPLIES = {
"created_at": "2026-08-23", "created_at": "2026-08-23",
"like_count": 9, "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"], "stops": ["tokyo", "chiangmai"],
"emoji": "🚄", "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": "🖥️",
},
] ]

View File

@ -117,4 +117,94 @@ JOBS = [
"tags": ["社区", "写作", "游民"], "tags": ["社区", "写作", "游民"],
"url": "https://nomadro.com", "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",
},
] ]

View File

@ -43,6 +43,48 @@ DESTINATIONS = [
"nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"], "nomads_count": "4,100+", "highlights": ["🚄 极致公共交通", "🛡️ 全球最安全城市", "📶 网速亚洲第一", "🍣 美食文化巅峰"],
"map_x": 820, "map_y": 210, "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 = [ VISAS = [
@ -58,6 +100,18 @@ VISAS = [
"duration": "1-4年", "income_req": "$2,500/月", "approval_time": "2-4 周", "extra": "🌮 北美时区友好", "difficulty": 30, "difficulty_label": "简单"}, "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", {"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": "中等"}, "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 = [ FAQS = [
@ -79,6 +133,11 @@ TESTIMONIALS = [
{"id": "1", "avatar": "👩‍💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5}, {"id": "1", "avatar": "👩‍💻", "content": "在清迈住了 8 个月,月花费不到 4000 元,但生活质量比国内一线城市高太多了。每天早上骑摩托去咖啡馆,这种感觉无法形容。", "author": "小林", "role": "前端开发 · 清迈 🇹🇭", "rating": 5},
{"id": "2", "avatar": "👨‍🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5}, {"id": "2", "avatar": "👨‍🎨", "content": "里斯本的 D7 签证让我在欧洲有了基地。白天在 Alfama 区的共享办公空间工作,周末去 Sintra 徒步,完美平衡。", "author": "Marco", "role": "UI 设计师 · 里斯本 🇵🇹", "rating": 5},
{"id": "3", "avatar": "🧑‍💼", "content": "带着家人做数字游民听起来疯狂,但在巴厘岛乌布,孩子们上国际学校,我和妻子远程工作,这是我们做过最正确的决定。", "author": "张家", "role": "产品经理 · 巴厘岛 🇮🇩", "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 = [ 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": "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": "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": "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] = { BLOG_CONTENT: dict[str, str] = {
@ -217,6 +283,102 @@ A: 满足居住要求后可以申请永居或入籍。""",
3. 🏦 使用 Wise 等工具便于跨境汇款记录 3. 🏦 使用 Wise 等工具便于跨境汇款记录
4. 👨‍💼 收入超过一定金额建议聘请税务顾问 4. 👨‍💼 收入超过一定金额建议聘请税务顾问
5. 📱 推荐工具:Xero(记账)、TaxScouts(报税)""", 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 = [ TICKER_MESSAGES = [
@ -226,4 +388,10 @@ TICKER_MESSAGES = [
"🏝️ 巴厘岛 Co-working 今日 89% 满座", "🏝️ 巴厘岛 Co-working 今日 89% 满座",
"📶 清迈平均网速 95Mbps", "📶 清迈平均网速 95Mbps",
"🌅 东京远程工作者满意度 9.1", "🌅 东京远程工作者满意度 9.1",
"🌺 Sofia 在麦德林办完本地 SIM",
"🍷 Devon 把下一站设为第比利斯",
"🎨 Ken 报名柏林开源 Meetup",
"🏙️ Omar 从迪拜中转飞往里斯本",
"📷 Lina 在大理发布本周创作日志",
"🇰🇷 Yuki 实测首尔联合办公 220Mbps",
] ]

View File

@ -37,6 +37,60 @@ SERVICES = [
"category": "职业", "category": "职业",
"provider": "HR 志愿者", "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 = [ VIDEOS = [
@ -79,6 +133,123 @@ VIDEOS = [
"tags": ["印尼", "办公"], "tags": ["印尼", "办公"],
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", "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 = [ MEMBER_MAP = [
@ -90,6 +261,14 @@ MEMBER_MAP = [
{"name": "Ken", "city": "东京", "lat": 35.68, "lng": 139.69, "emoji": "🗼"}, {"name": "Ken", "city": "东京", "lat": 35.68, "lng": 139.69, "emoji": "🗼"},
{"name": "Lina", "city": "大理", "lat": 25.60, "lng": 100.27, "emoji": "🏔️"}, {"name": "Lina", "city": "大理", "lat": 25.60, "lng": 100.27, "emoji": "🏔️"},
{"name": "Omar", "city": "迪拜", "lat": 25.20, "lng": 55.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 = [ CITY_RANKING = [
@ -98,12 +277,23 @@ CITY_RANKING = [
{"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"}, {"slug": "bali", "name": "巴厘岛", "score": 87, "nomads": "15k+"},
{"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"}, {"slug": "mexico", "name": "墨西哥城", "score": 85, "nomads": "6k+"},
{"slug": "barcelona", "name": "巴塞罗那", "score": 84, "nomads": "7k+"}, {"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 = { DAILY_TIPS = {
"1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"}, "1": {"title": "第一天:确定税务居民身份", "body": "出发前了解母国与目标国的 183 天规则,避免双重征税意外。"},
"2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"}, "2": {"title": "第二天:准备多币种账户", "body": "Wise / Revolut 等跨境账户可大幅降低取现与换汇成本。"},
"3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"}, "3": {"title": "第三天:测试远程办公网速", "body": "落地先住 3 天短租,实测联合办公与公寓 WiFi 再签长租。"},
"4": {"title": "第四天:加入同城社群", "body": "报名一场 meetup,比独自逛咖啡馆更快建立支持网络。"},
"5": {"title": "第五天:备份通讯方案", "body": "本地 SIM + eSIM 热点双备份,开会前先压测上行带宽。"},
"6": {"title": "第六天:校准月预算", "body": "用第一周真实账单更新计划表,别只信网上平均值。"},
"7": {"title": "第七天:固定作息", "body": "保护每日 4 小时深度工作,探索放在下午后段。"},
} }
WEATHER_CITIES = { WEATHER_CITIES = {
@ -113,4 +303,10 @@ WEATHER_CITIES = {
"mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"}, "mexico": {"lat": 19.43, "lng": -99.13, "name": "墨西哥城"},
"barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"}, "barcelona": {"lat": 41.39, "lng": 2.17, "name": "巴塞罗那"},
"tokyo": {"lat": 35.68, "lng": 139.69, "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": "第比利斯"},
} }

View File

@ -4,7 +4,7 @@ MATCH_INTENTS = ("friends", "dating", "partner", "roommate", "cofounder", "explo
CANDIDATE_PROFILES = [ CANDIDATE_PROFILES = [
{ {
"id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiang-mai", "id": "p1", "userId": "u1", "name": "小林", "location": "清迈", "citySlug": "chiangmai",
"gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。", "gender": "女", "single": "是", "bio": "远程前端,喜欢咖啡和徒步。",
"photo": "🧳", "tags": ["远程", "咖啡", "徒步"], "photo": "🧳", "tags": ["远程", "咖啡", "徒步"],
"lookingFor": ["friends", "dating", "explore"], "lookingFor": ["friends", "dating", "explore"],
@ -22,7 +22,7 @@ CANDIDATE_PROFILES = [
"lookingFor": ["roommate", "friends"], "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": "独立开发者,拉美时区常驻。", "gender": "男", "single": "是", "bio": "独立开发者,拉美时区常驻。",
"photo": "🚀", "tags": ["独立开发", "拉美"], "photo": "🚀", "tags": ["独立开发", "拉美"],
"lookingFor": ["cofounder", "friends", "dating"], "lookingFor": ["cofounder", "friends", "dating"],
@ -51,4 +51,52 @@ CANDIDATE_PROFILES = [
"photo": "🖥️", "tags": ["DevOps", "开源"], "photo": "🖥️", "tags": ["DevOps", "开源"],
"lookingFor": ["cofounder", "friends"], "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"],
},
] ]

View File

@ -25,14 +25,36 @@ def _token_user(authorization: str | None) -> dict:
async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)): async def join_member(body: JoinMemberRequest, authorization: str | None = Header(None)):
user = _token_user(authorization) user = _token_user(authorization)
profile = social_store.join_member(user["id"], user["name"], body.model_dump()) 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") @router.get("/join/status")
async def join_status(authorization: str | None = Header(None)): async def join_status(authorization: str | None = Header(None)):
user = _token_user(authorization) user = _token_user(authorization)
profile = social_store._profiles.get(user["id"]) profile = social_store.get_public_profile(user["id"])
return {"complete": bool(profile), "profile": profile} 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]) @router.get("/matches/candidates", response_model=list[MatchProfile])
@ -45,8 +67,13 @@ async def match_candidates(
exclude_swiped: bool = Query(True), exclude_swiped: bool = Query(True),
): ):
user = _token_user(authorization) user = _token_user(authorization)
if user["id"] not in social_store._profiles: if not _has_match_profile(user["id"]):
raise HTTPException(400, "请先完成加入资料") # 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( return [MatchProfile(**p) for p in social_store.list_candidates(
user["id"], intent=intent, city=city, gender=gender, single=single, exclude_swiped=exclude_swiped 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)): async def vip_check(authorization: str | None = Header(None)):
user = _token_user(authorization) user = _token_user(authorization)
vip = social_store.is_vip(user["id"]) 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) return VipStatus(vip=vip, expires_at=exp)

View File

@ -400,13 +400,111 @@ def find_user_public_by_email(email: str) -> dict | None:
return _user_profile(user) if user else 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 # Boot: JSON fallback for local dev; migrate + seed demo account on PocketBase
if not use_pb(): if not use_pb():
if not _restore_json(): if not _restore_json():
login_demo() login_demo()
elif "demo@nomadro.com" not in _users: elif "demo@nomadro.com" not in _users:
login_demo() login_demo()
_seed_demo_users()
else: else:
_migrate_json_to_pb() _migrate_json_to_pb()
if not _get_account_by_email("demo@nomadro.com"): if not _get_account_by_email("demo@nomadro.com"):
register_user("demo@nomadro.com", "demo123", "演示用户") 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)

View File

@ -577,18 +577,21 @@ def _jload() -> None:
_j.update(json.loads(_JSON_PATH.read_text(encoding="utf-8"))) _j.update(json.loads(_JSON_PATH.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
pass 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({ _j.update({
"meetups": deepcopy(community_data.MEETUPS), "meetups": deepcopy(community_data.MEETUPS),
"discussions": deepcopy(community_data.DISCUSSIONS), "discussions": deepcopy(community_data.DISCUSSIONS),
"replies": deepcopy(community_data.DISCUSSION_REPLIES), "replies": deepcopy(community_data.DISCUSSION_REPLIES),
"gigs": deepcopy(getattr(community_data, "GIGS", [])), "gigs": deepcopy(getattr(community_data, "GIGS", [])),
"discussion_likes": {}, "discussion_likes": _j.get("discussion_likes") or {},
"rsvps": {}, "rsvps": _j.get("rsvps") or {},
"gig_apps": [], "gig_apps": _j.get("gig_apps") or [],
"notifications": {}, "notifications": _j.get("notifications") or {},
"feedback": [], "feedback": _j.get("feedback") or [],
"views": {}, "views": _j.get("views") or {},
"seeded": True, "seeded": True,
}) })
_jsave() _jsave()

View File

@ -73,6 +73,14 @@ def is_vip(user_id: str) -> bool:
return bool(m and (m.get("expires_at") or 0) > int(time.time())) 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: def ensure_membership(user_id: str, days: int = 365) -> None:
exp = int(time.time()) + days * 86400 exp = int(time.time()) + days * 86400
if use_pb(): if use_pb():

View File

@ -8,6 +8,205 @@ export const metadata: Metadata = {
}; };
const LOGS = [ 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", date: "2026-08-30",
tag: "决策与发现闭环", tag: "决策与发现闭环",

View File

@ -3290,10 +3290,26 @@ img { max-width: 100%; display: block; }
transform: translateY(20px); transform: translateY(20px);
opacity: 0; opacity: 0;
animation: toastIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; 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-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 { @keyframes toastIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); } from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); } 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-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 Guide ===== */
.season-card { .season-card {
@ -10325,6 +10349,10 @@ img { max-width: 100%; display: block; }
font-size: 2rem; font-size: 2rem;
} }
.tool-runner-exits {
margin: 1.25rem 0 0.5rem;
}
.tool-loading { .tool-loading {
padding: 48px; padding: 48px;
text-align: center; text-align: center;
@ -10605,6 +10633,15 @@ img { max-width: 100%; display: block; }
border-radius: var(--radius-md); 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-strip { margin-top: 32px; }
.weather-cards { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; } .weather-cards { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
.weather-card { .weather-card {
@ -10613,6 +10650,16 @@ img { max-width: 100%; display: block; }
border: var(--border-glass); border: var(--border-glass);
border-radius: var(--radius-md); border-radius: var(--radius-md);
min-width: 100px; 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 { .ai-reply {
@ -11225,3 +11272,506 @@ img { max-width: 100%; display: block; }
color: var(--accent-3, #4ecdc4); color: var(--accent-3, #4ecdc4);
text-decoration: underline; 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;
}

View File

@ -27,7 +27,7 @@ function LoginForm() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const goAfterAuth = (msg: string) => { const goAfterAuth = (msg: string) => {
toast(msg); toast(msg, "success", { href: meta.path, label: meta.label });
setTimeout(() => { router.replace(meta.path); }, 400); setTimeout(() => { router.replace(meta.path); }, 400);
}; };
@ -36,11 +36,11 @@ function LoginForm() {
setError(""); setError("");
if (mode === "register") { if (mode === "register") {
if (!name.trim() || name.trim().length < 2) { if (!name.trim() || name.trim().length < 2) {
setError("昵称至少 2 个字符"); setError(t.login.nameMin);
return; return;
} }
if (password.length < 6) { if (password.length < 6) {
setError("密码至少 6 位"); setError(t.login.pwdMin);
return; return;
} }
} }
@ -122,19 +122,19 @@ function LoginForm() {
minLength={6} minLength={6}
autoComplete={mode === "login" ? "current-password" : "new-password"} autoComplete={mode === "login" ? "current-password" : "new-password"}
/> />
<button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label="显示密码"> <button type="button" className="auth-pwd-toggle" onClick={() => setShowPwd(!showPwd)} aria-label={t.login.showPwd}>
{showPwd ? "🙈" : "👁️"} {showPwd ? "🙈" : "👁️"}
</button> </button>
</div> </div>
{mode === "register" && ( {mode === "register" && (
<p className="auth-pwd-hint"> <p className="auth-pwd-hint">
{password.length === 0 {password.length === 0
? "至少 6 位,建议包含字母与数字" ? t.login.pwdHint
: password.length < 6 : password.length < 6
? `还差 ${6 - password.length} 位` ? t.login.pwdNeed.replace("{n}", String(6 - password.length))
: password.length < 10 : password.length < 10
? "可用 · 再长一点更安全" ? t.login.pwdOk
: "强度不错"} : t.login.pwdStrong}
</p> </p>
)} )}
</div> </div>

View File

@ -5,14 +5,21 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Destination, ProfileStats, TripItem } from "@/lib/types"; import type { Destination, Meetup, ProfileStats, TripItem } from "@/lib/types";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import FavoriteButton from "@/components/FavoriteButton"; import FavoriteButton from "@/components/FavoriteButton";
import RecentlyViewed from "@/components/RecentlyViewed";
import NomadDigest from "@/components/NomadDigest";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
import { useI18n } from "@/lib/i18n";
import type { Dict } from "@/lib/i18n";
import { loadTrip } from "@/lib/tripStorage"; import { loadTrip } from "@/lib/tripStorage";
import { loadPlanMeta, MOVE_CHECKLIST, type PlanMeta } from "@/lib/planMeta"; import { loadPlanMeta, MOVE_CHECKLIST, type PlanMeta } from "@/lib/planMeta";
import { PLAN_SYNC_EVENT } from "@/lib/planSync"; import { PLAN_SYNC_EVENT } from "@/lib/planSync";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { buildMeetupIcs, downloadMeetupIcs } from "@/lib/meetupIcs";
interface Badge { interface Badge {
emoji: string; emoji: string;
@ -21,15 +28,26 @@ interface Badge {
earned: boolean; earned: boolean;
} }
function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: number, readiness: number): Badge[] { function getBadges(
const tripMonths = trip.reduce((s, t) => s + t.months, 0); p: Dict["profile"],
stats: ProfileStats | null,
trip: TripItem[],
favCount: number,
readiness: number
): Badge[] {
const tripMonths = trip.reduce((s, item) => s + item.months, 0);
return [ return [
{ emoji: "🌱", label: "初出茅庐", desc: "加入 nomadro", earned: true }, { emoji: "🌱", label: p.badgeNewbie, desc: p.badgeNewbieDesc, earned: true },
{ emoji: "❤️", label: "收藏家", desc: "收藏 3+ 目的地", earned: favCount >= 3 }, { emoji: "❤️", label: p.badgeCollector, desc: p.badgeCollectorDesc, earned: favCount >= 3 },
{ emoji: "🗺️", label: "行程达人", desc: "规划 3+ 城市行程", earned: trip.length >= 3 }, { emoji: "🗺️", label: p.badgePlanner, desc: p.badgePlannerDesc, earned: trip.length >= 3 },
{ emoji: "📅", label: "长期旅居", desc: "行程总计 6+ 个月", earned: tripMonths >= 6 }, { emoji: "📅", label: p.badgeLongStay, desc: p.badgeLongStayDesc, earned: tripMonths >= 6 },
{ emoji: "✅", label: "出发就绪", desc: "就绪清单 ≥ 50%", earned: readiness >= 50 }, { emoji: "✅", label: p.badgeReady, desc: p.badgeReadyDesc, earned: readiness >= 50 },
{ emoji: "🌍", label: "环球游民", desc: "探索 5+ 城市", earned: (stats?.destinations_explored ?? 0) >= 5 }, {
emoji: "🌍",
label: p.badgeGlobal,
desc: p.badgeGlobalDesc,
earned: (stats?.destinations_explored ?? 0) >= 5,
},
]; ];
} }
@ -37,8 +55,11 @@ export default function ProfilePage() {
const { user, token, favorites, logout, planSyncStatus, refreshUser } = useAuth(); const { user, token, favorites, logout, planSyncStatus, refreshUser } = useAuth();
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps();
const [stats, setStats] = useState<ProfileStats | null>(null); const [stats, setStats] = useState<ProfileStats | null>(null);
const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]); const [favoriteDests, setFavoriteDests] = useState<Destination[]>([]);
const [rsvps, setRsvps] = useState<Meetup[]>([]);
const [trip, setTrip] = useState<TripItem[]>([]); const [trip, setTrip] = useState<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta | null>(null); const [meta, setMeta] = useState<PlanMeta | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -50,7 +71,7 @@ export default function ProfilePage() {
e.target.value = ""; e.target.value = "";
if (!file || !token) return; if (!file || !token) return;
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
toast("图片请小于 5MB", "error"); toast(t.profile.avatarTooBig, "error");
return; return;
} }
setUploadingAvatar(true); setUploadingAvatar(true);
@ -58,9 +79,9 @@ export default function ProfilePage() {
const uploaded = await api.uploadMedia(token, file, "avatar"); const uploaded = await api.uploadMedia(token, file, "avatar");
await api.updateProfile(token, { avatar: uploaded.url }); await api.updateProfile(token, { avatar: uploaded.url });
await refreshUser?.(); await refreshUser?.();
toast("头像已更新", "success"); toast(t.profile.avatarOk, "success");
} catch (err) { } catch (err) {
toast(err instanceof Error ? err.message : "上传失败", "error"); toast(err instanceof Error ? err.message : t.profile.avatarFail, "error");
} finally { } finally {
setUploadingAvatar(false); setUploadingAvatar(false);
} }
@ -81,10 +102,18 @@ export default function ProfilePage() {
api.getProfileStats(token), api.getProfileStats(token),
favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]), favorites.length > 0 ? api.getFavoriteDestinations(token) : Promise.resolve([]),
api.getVipStatus(token).catch(() => ({ vip: false, expires_at: 0 })), api.getVipStatus(token).catch(() => ({ vip: false, expires_at: 0 })),
]).then(([s, f, v]) => { Promise.all([
api.getMyRsvps(token).catch(() => ({ ids: [] as string[] })),
api.getMeetups().catch(() => [] as Meetup[]),
]).then(([mine, all]) => {
const idSet = new Set(mine.ids || []);
return all.filter((m) => idSet.has(m.id)).slice(0, 6);
}),
]).then(([s, f, v, myMeetups]) => {
setStats(s); setStats(s);
setFavoriteDests(f); setFavoriteDests(f);
setVip(Boolean(v.vip)); setVip(Boolean(v.vip));
setRsvps(myMeetups);
}).catch(() => {}).finally(() => setLoading(false)); }).catch(() => {}).finally(() => setLoading(false));
refreshPlan(); refreshPlan();
@ -92,31 +121,37 @@ export default function ProfilePage() {
return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan); return () => window.removeEventListener(PLAN_SYNC_EVENT, refreshPlan);
}, [token, favorites, router]); }, [token, favorites, router]);
const totalMonths = trip.reduce((s, t) => s + t.months, 0); const totalMonths = trip.reduce((s, item) => s + item.months, 0);
const totalCost = trip.reduce((s, t) => s + t.cost * t.months, 0); const totalCost = trip.reduce((s, item) => s + item.cost * item.months, 0);
const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0; const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0;
const checked = MOVE_CHECKLIST.filter((c) => meta?.checklist[c.id]).length; const checked = MOVE_CHECKLIST.filter((c) => meta?.checklist[c.id]).length;
const readiness = Math.round((checked / MOVE_CHECKLIST.length) * 100); const readiness = Math.round((checked / MOVE_CHECKLIST.length) * 100);
const budgetOk = !meta || meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget; const budgetOk = !meta || meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget;
const badges = useMemo( const badges = useMemo(
() => getBadges(stats, trip, favoriteDests.length, readiness), () => getBadges(t.profile, stats, trip, favoriteDests.length, readiness),
[stats, trip, favoriteDests.length, readiness] [t.profile, stats, trip, favoriteDests.length, readiness]
); );
const earnedCount = badges.filter((b) => b.earned).length; const earnedCount = badges.filter((b) => b.earned).length;
const favsNotInTrip = favoriteDests.filter((d) => !trip.some((t) => t.slug === d.slug)); const favsNotInTrip = favoriteDests.filter((d) => !trip.some((item) => item.slug === d.slug));
const addFavsToPlan = () => { const addFavsToPlan = () => {
if (favsNotInTrip.length === 0) { if (favsNotInTrip.length === 0) {
toast("收藏城市都已在计划中", "info"); toast(t.profile.favsAlready, "info", { href: "/plan", label: t.profile.openPlan });
return; return;
} }
const { added } = mergeDestinationsIntoTrip(favsNotInTrip, 1); const { added } = mergeDestinationsIntoTrip(favsNotInTrip, 1);
refreshPlan(); refreshPlan();
toast(`已将 ${added} 座收藏城市写入计划`); toast(t.profile.favsAdded.replace("{n}", String(added)), "success", {
href: "/plan",
label: t.profile.openPlan,
});
}; };
const planTitle =
meta?.title && meta.title !== "我的旅居计划" ? meta.title : t.plan.defaultTitle;
if (!user) return null; if (!user) return null;
return ( return (
@ -131,7 +166,7 @@ export default function ProfilePage() {
<div className="profile-avatar">{user.avatar || "🧑‍💻"}</div> <div className="profile-avatar">{user.avatar || "🧑‍💻"}</div>
)} )}
<label className="profile-avatar-upload"> <label className="profile-avatar-upload">
{uploadingAvatar ? "上传中…" : "更换头像"} {uploadingAvatar ? t.profile.uploading : t.profile.changeAvatar}
<input <input
type="file" type="file"
accept="image/jpeg,image/png,image/webp,image/gif" accept="image/jpeg,image/png,image/webp,image/gif"
@ -149,14 +184,14 @@ export default function ProfilePage() {
{vip && <span className="profile-vip-badge">✨ VIP</span>} {vip && <span className="profile-vip-badge">✨ VIP</span>}
</div> </div>
<span className={`plan-sync-pill ${planSyncStatus}`} style={{ marginTop: 8, display: "inline-flex" }}> <span className={`plan-sync-pill ${planSyncStatus}`} style={{ marginTop: 8, display: "inline-flex" }}>
{planSyncStatus === "syncing" && "计划同步中…"} {planSyncStatus === "syncing" && t.profile.syncing}
{planSyncStatus === "synced" && "✓ 计划已云端同步"} {planSyncStatus === "synced" && t.profile.synced}
{planSyncStatus === "offline" && "计划仅本机"} {planSyncStatus === "offline" && t.profile.offline}
{planSyncStatus === "idle" && "账号已登录"} {planSyncStatus === "idle" && t.profile.loggedIn}
</span> </span>
</div> </div>
<button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}> <button className="btn btn-ghost" onClick={() => { logout(); router.push("/"); }}>
退出登录 {t.profile.logout}
</button> </button>
</div> </div>
@ -165,82 +200,99 @@ export default function ProfilePage() {
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">❤️</span> <span className="stat-emoji">❤️</span>
<strong>{stats.favorites_count}</strong> <strong>{stats.favorites_count}</strong>
<span>收藏目的地</span> <span>{t.profile.statFavs}</span>
</div> </div>
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">🗓️</span> <span className="stat-emoji">🗓️</span>
<strong>{trip.length}</strong> <strong>{trip.length}</strong>
<span>计划城市</span> <span>{t.profile.statCities}</span>
</div> </div>
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">✅</span> <span className="stat-emoji">✅</span>
<strong>{readiness}%</strong> <strong>{readiness}%</strong>
<span>出发就绪</span> <span>{t.profile.statReady}</span>
</div> </div>
<div className="profile-stat-card"> <div className="profile-stat-card">
<span className="stat-emoji">🏅</span> <span className="stat-emoji">🏅</span>
<strong>{earnedCount}/{badges.length}</strong> <strong>{earnedCount}/{badges.length}</strong>
<span>成就徽章</span> <span>{t.profile.statBadges}</span>
</div> </div>
</div> </div>
)} )}
<NomadDigest rsvpCount={rsvps.length} upcoming={rsvps} />
<section className="profile-trip"> <section className="profile-trip">
<div className="profile-section-head"> <div className="profile-section-head">
<h2>🗓️ {meta?.title || "我的旅居计划"}</h2> <h2>🗓️ {planTitle}</h2>
<Link href="/plan" className="btn btn-primary btn-sm">打开计划中心</Link> <Link href="/plan" className="btn btn-primary btn-sm">{t.profile.openPlan}</Link>
</div> </div>
{trip.length === 0 ? ( {trip.length === 0 ? (
<div className="profile-empty"> <div className="profile-empty">
<span style={{ fontSize: "2.5rem" }}>🗺️</span> <span style={{ fontSize: "2.5rem" }}>🗺️</span>
<p>还没有规划行程</p> <p>{t.profile.tripEmpty}</p>
<Link href="/plan" className="btn btn-primary">去规划 →</Link> <Link href="/plan" className="btn btn-primary">{t.profile.goPlan}</Link>
</div> </div>
) : ( ) : (
<div className="profile-trip-list"> <div className="profile-trip-list">
{(meta?.startMonth || meta?.monthlyBudget) && ( {(meta?.startMonth || meta?.monthlyBudget) && (
<div className="profile-plan-meta"> <div className="profile-plan-meta">
{meta?.startMonth && <span>出发 {meta.startMonth}</span>} {meta?.startMonth && (
<span>{t.profile.startLabel.replace("{month}", meta.startMonth)}</span>
)}
{meta && meta.monthlyBudget > 0 && ( {meta && meta.monthlyBudget > 0 && (
<span className={!budgetOk ? "plan-warn-text" : undefined}> <span className={!budgetOk ? "plan-warn-text" : undefined}>
月预算 ¥{meta.monthlyBudget.toLocaleString()} {t.profile.budgetLabel.replace("{budget}", meta.monthlyBudget.toLocaleString())}
{avgMonth > 0 && ` · 均月 ¥${avgMonth.toLocaleString()}`} {avgMonth > 0 &&
t.profile.avgMonthLabel.replace("{avg}", avgMonth.toLocaleString())}
</span> </span>
)} )}
<span>就绪 {checked}/{MOVE_CHECKLIST.length}</span> <span>
{t.profile.readyCount
.replace("{checked}", String(checked))
.replace("{total}", String(MOVE_CHECKLIST.length))}
</span>
</div> </div>
)} )}
{trip.map((t, i) => ( {trip.map((item, i) => (
<div key={t.slug} className="profile-trip-item"> <div key={item.slug} className="profile-trip-item">
<span className="trip-item-num">{i + 1}</span> <span className="trip-item-num">{i + 1}</span>
<span>{t.emoji}</span> <span>{item.emoji}</span>
<div> <div>
<strong>{t.name}, {t.country}</strong> <strong>{item.name}, {item.country}</strong>
<span>{t.months} 个月 · ¥{(t.cost * t.months).toLocaleString()}</span> <span>
{t.profile.monthsCost
.replace("{months}", String(item.months))
.replace("{cost}", (item.cost * item.months).toLocaleString())}
</span>
</div> </div>
</div> </div>
))} ))}
<div className="profile-trip-total"> <div className="profile-trip-total">
总计 <strong>{totalMonths} 个月</strong> · {t.profile.totalLine}{" "}
<strong>
{totalMonths} {t.profile.monthsUnit}
</strong>{" "}
·
<strong className="gradient-text"> ¥{totalCost.toLocaleString()}</strong> <strong className="gradient-text"> ¥{totalCost.toLocaleString()}</strong>
</div> </div>
<div className="profile-trip-actions"> <div className="profile-trip-actions">
{trip.length >= 2 && ( {trip.length >= 2 && (
<Link <Link
href={`/compare?cities=${trip.map((t) => t.slug).slice(0, 4).join(",")}`} href={`/compare?cities=${trip.map((item) => item.slug).slice(0, 4).join(",")}`}
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
> >
对比行程城市 {t.profile.compareTrip}
</Link> </Link>
)} )}
<Link href="/plan" className="btn btn-ghost btn-sm">继续编辑 →</Link> <Link href="/plan" className="btn btn-ghost btn-sm">{t.profile.keepEditing}</Link>
</div> </div>
</div> </div>
)} )}
</section> </section>
<section className="profile-badges"> <section className="profile-badges">
<h2>🏅 游民成就</h2> <h2>{t.profile.achievements}</h2>
<div className="badge-grid"> <div className="badge-grid">
{badges.map((b) => ( {badges.map((b) => (
<div key={b.label} className={`badge-card${b.earned ? " earned" : " locked"}`} title={b.desc}> <div key={b.label} className={`badge-card${b.earned ? " earned" : " locked"}`} title={b.desc}>
@ -254,20 +306,20 @@ export default function ProfilePage() {
<section className="profile-favorites"> <section className="profile-favorites">
<div className="profile-section-head"> <div className="profile-section-head">
<h2>❤️ 我的收藏</h2> <h2>{t.profile.myFavs}</h2>
{favsNotInTrip.length > 0 && ( {favsNotInTrip.length > 0 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={addFavsToPlan}> <button type="button" className="btn btn-ghost btn-sm" onClick={addFavsToPlan}>
收藏写入计划({favsNotInTrip.length}) {t.profile.favsToPlan.replace("{n}", String(favsNotInTrip.length))}
</button> </button>
)} )}
</div> </div>
{loading ? ( {loading ? (
<p className="profile-loading">加载中...</p> <p className="profile-loading">{t.profile.loading}</p>
) : favoriteDests.length === 0 ? ( ) : favoriteDests.length === 0 ? (
<div className="profile-empty"> <div className="profile-empty">
<span style={{ fontSize: "3rem" }}>🌍</span> <span style={{ fontSize: "3rem" }}>🌍</span>
<p>还没有收藏目的地</p> <p>{t.profile.favsEmpty}</p>
<Link href="/#destinations" className="btn btn-primary">去探索 →</Link> <Link href="/#destinations" className="btn btn-primary">{t.profile.goExplore}</Link>
</div> </div>
) : ( ) : (
<div className="profile-fav-grid"> <div className="profile-fav-grid">
@ -276,7 +328,10 @@ export default function ProfilePage() {
<Link href={`/destinations/${d.slug}`}> <Link href={`/destinations/${d.slug}`}>
<span className="fav-emoji">{d.emoji}</span> <span className="fav-emoji">{d.emoji}</span>
<h3>{d.name}, {d.country}</h3> <h3>{d.name}, {d.country}</h3>
<p>💰 ¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</p> <p>
💰 ¥{d.cost.toLocaleString()}
{t.profile.perMonth} · ⭐ {d.rating}
</p>
</Link> </Link>
<FavoriteButton slug={d.slug} /> <FavoriteButton slug={d.slug} />
</div> </div>
@ -285,22 +340,83 @@ export default function ProfilePage() {
)} )}
</section> </section>
<section className="profile-rsvps">
<div className="profile-section-head">
<h2>🎉 {t.profile.myRsvps}</h2>
<div className="profile-section-actions">
{rsvps.length > 0 && (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
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}
</button>
)}
<Link href="/meetups" className="btn btn-ghost btn-sm">{t.nav.meetups}</Link>
</div>
</div>
{rsvps.length === 0 ? (
<div className="profile-empty">
<p>{t.profile.rsvpEmpty}</p>
<Link href="/meetups" className="btn btn-primary btn-sm">{t.profile.goMeetups}</Link>
</div>
) : (
<div className="profile-rsvp-list">
{rsvps.map((m) => (
<div key={m.id} className="profile-rsvp-card">
<span aria-hidden>{m.emoji}</span>
<div>
<strong>{m.title}</strong>
<p>
{m.city} · {m.date} {m.time}
</p>
</div>
{(m.mode === "online" || m.mode === "hybrid") && (
<Link href={`/meetups/${m.id}/live`} className="btn btn-sm btn-primary">
{t.profile.openLive}
</Link>
)}
</div>
))}
</div>
)}
</section>
<RecentlyViewed compact />
<section className="profile-quick"> <section className="profile-quick">
<h2>🚀 快捷入口</h2> <h2>{t.profile.quickLinks}</h2>
<div className="profile-quick-grid"> <div className="profile-quick-grid">
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link> <Link href="/#destinations" className="quick-card">{t.profile.qDest}</Link>
<Link href="/plan" className="quick-card">🗓️ 旅居计划</Link> <Link href="/plan" className="quick-card">{t.profile.qPlan}</Link>
<Link href="/compare" className="quick-card">⚖️ 城市对比</Link> <Link href="/compare" className="quick-card">{t.profile.qCompare}</Link>
<Link href="/next-stop" className="quick-card">🧭 智能下一站</Link> <Link href="/next-stop" className="quick-card">{t.profile.qNext}</Link>
<Link href="/meetups" className="quick-card">🎉 游民活动</Link> <Link href="/meetups" className="quick-card">{t.profile.qMeetups}</Link>
<Link href="/dating" className="quick-card">💕 游民匹配</Link> <Link href="/dating" className="quick-card">{t.profile.qDating}</Link>
<Link href="/chat" className="quick-card">✉️ 私信</Link> <Link href="/chat" className="quick-card">{t.profile.qChat}</Link>
<Link href="/notifications" className="quick-card">🔔 通知</Link> <Link href="/notifications" className="quick-card">{t.profile.qNotif}</Link>
<Link href={vip ? "/join" : "/join"} className="quick-card">{vip ? "✨ VIP 会员" : "✨ 开通 VIP"}</Link> <Link href="/join" className="quick-card">{vip ? t.profile.qVipMember : t.profile.qVip}</Link>
<Link href="/digital" className="quick-card">🎓 游民学院</Link> <Link href="/digital" className="quick-card">{t.profile.qDigital}</Link>
<Link href="/tools" className="quick-card">🛠️ 工具箱</Link> <Link href="/ai" className="quick-card">{t.profile.qAi}</Link>
<Link href="/tools" className="quick-card">{t.profile.qTools}</Link>
</div> </div>
</section> </section>
<RingNext
steps={[
...rings.afterDiscover,
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
].slice(0, 3)}
/>
</div> </div>
</SiteShell> </SiteShell>
); );

View File

@ -2,8 +2,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { localizedToolCategories, localizedToolLinks } from "@/lib/toolCopy";
import SiteShell from "@/components/SiteShell"; import SiteShell from "@/components/SiteShell";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; 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. */ /** Utility drawer — not a product mega-menu. */
export default function ToolsHubPage() { export default function ToolsHubPage() {
const { t } = useI18n(); const { t, locale } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const tools = useMemo(() => localizedToolLinks(locale), [locale]);
const categories = useMemo(() => localizedToolCategories(locale), [locale]);
const featured = useMemo( const featured = useMemo(
() => FEATURED.map((id) => TOOL_LINKS.find((tool) => tool.id === id)).filter((tool): tool is NonNullable<typeof tool> => !!tool), () => FEATURED.map((id) => tools.find((tool) => tool.id === id)).filter((tool): tool is NonNullable<typeof tool> => !!tool),
[] [tools]
); );
const filtered = useMemo(() => { 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()) { if (q.trim()) {
const s = q.toLowerCase(); const s = q.toLowerCase();
list = list.filter((tool) => tool.title.toLowerCase().includes(s) || tool.desc.toLowerCase().includes(s)); list = list.filter((tool) => tool.title.toLowerCase().includes(s) || tool.desc.toLowerCase().includes(s));
} }
return list; return list;
}, [filter, q]); }, [filter, q, tools]);
const visible = showAll || q.trim() || filter !== "all" ? filtered : filtered.slice(0, 12); const visible = showAll || q.trim() || filter !== "all" ? filtered : filtered.slice(0, 12);
@ -52,7 +55,7 @@ export default function ToolsHubPage() {
<div className="tools-featured"> <div className="tools-featured">
<div className="tools-featured-head"> <div className="tools-featured-head">
<strong>✨ {t.tools.tag}</strong> <strong>✨ {t.tools.tag}</strong>
<span>{featured.length} · 先用这几个</span> <span>{featured.length} · {t.tools.featuredHint}</span>
</div> </div>
<div className="tools-featured-grid tools-featured-grid-compact"> <div className="tools-featured-grid tools-featured-grid-compact">
{featured.map((tool) => ( {featured.map((tool) => (
@ -70,12 +73,12 @@ export default function ToolsHubPage() {
<div className="tools-hub-toolbar"> <div className="tools-hub-toolbar">
<input <input
className="tools-hub-search" className="tools-hub-search"
placeholder="搜索工具,如「签证」「机票」「笔记」…" placeholder={t.tools.searchPlaceholder}
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
/> />
<div className="tools-hub-filters"> <div className="tools-hub-filters">
{TOOL_CATEGORIES.map((c) => ( {categories.map((c) => (
<button <button
key={c.key} key={c.key}
className={`filter-btn${filter === c.key ? " active" : ""}`} className={`filter-btn${filter === c.key ? " active" : ""}`}
@ -93,20 +96,20 @@ export default function ToolsHubPage() {
<span className="tools-hub-emoji">{tool.emoji}</span> <span className="tools-hub-emoji">{tool.emoji}</span>
<h3>{tool.title}</h3> <h3>{tool.title}</h3>
<p>{tool.desc}</p> <p>{tool.desc}</p>
<span className="tools-hub-go">打开 →</span> <span className="tools-hub-go">{t.tools.open}</span>
</Link> </Link>
))} ))}
</div> </div>
{!showAll && !q.trim() && filter === "all" && filtered.length > 12 && ( {!showAll && !q.trim() && filter === "all" && filtered.length > 12 && (
<div className="tools-hub-more"> <div className="tools-hub-more">
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}> <button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
展开全部 {filtered.length} 个工具 {t.tools.showAll.replace("{n}", String(filtered.length))}
</button> </button>
</div> </div>
)} )}
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="notif-empty"> <div className="notif-empty">
<p className="dest-empty">没有匹配的工具,试试其他关键词</p> <p className="dest-empty">{t.tools.empty}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<button <button
type="button" type="button"
@ -117,10 +120,10 @@ export default function ToolsHubPage() {
setShowAll(false); setShowAll(false);
}} }}
> >
清除筛选 {t.tools.clearFilter}
</button> </button>
<Link href="/next-stop" className="btn btn-primary btn-sm"> <Link href="/next-stop" className="btn btn-primary btn-sm">
去下一站 {t.tools.goNextStop}
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -1,21 +1,19 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; 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 type { Destination } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
const SUGGESTIONS = [ const DRAFT_KEY = "nomadro-ai-compose-draft";
"预算 6000,想找网速好的东南亚城市",
"适合第一次远程办公的城市?",
"清迈和巴厘岛怎么选?",
];
interface Props { interface Props {
destinations: Destination[]; destinations: Destination[];
@ -30,11 +28,38 @@ export default function AiAssistantClient({ destinations }: Props) {
const [reply, setReply] = useState(""); const [reply, setReply] = useState("");
const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]); const [cities, setCities] = useState<{ slug: string; name: string; emoji: string }[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [history, setHistory] = useState<AiHistoryItem[]>([]);
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 ask = async (text?: string) => {
const q = (text ?? message).trim(); const q = (text ?? message).trim();
if (!q) { if (!q) {
toast("先写一个问题", "info"); toast(t.ai.needQuestion, "info");
return; return;
} }
setMessage(q); setMessage(q);
@ -42,7 +67,10 @@ export default function AiAssistantClient({ destinations }: Props) {
try { try {
const res = await api.askAssistant(q); const res = await api.askAssistant(q);
setReply(res.reply); 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 { } catch {
setReply(t.ai.fail); setReply(t.ai.fail);
setCities([]); 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 addToPlan = () => {
const dests = cities const dests = cities
.map((c) => destinations.find((d) => d.slug === c.slug)) .map((c) => destinations.find((d) => d.slug === c.slug))
.filter((d): d is Destination => Boolean(d)); .filter((d): d is Destination => Boolean(d));
const planAction = { href: "/plan", label: t.strip.openPlan };
if (dests.length === 0) { if (dests.length === 0) {
toast("推荐城市暂无完整数据,请从详情页加入计划", "info"); toast(t.ai.noCityData, "info", planAction);
router.push("/plan"); router.push("/plan");
return; return;
} }
const { added, skipped } = mergeDestinationsIntoTrip(dests, 1); const { added, skipped } = mergeDestinationsIntoTrip(dests, 1);
if (added === 0) { if (added === 0) {
toast(skipped ? t.compare.alreadyInPlan : "未能写入计划", "info"); toast(skipped ? t.compare.alreadyInPlan : t.ai.writeFail, "info", planAction);
} else { } else {
toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}`); toast(`${t.compare.addedN} ${added} ${t.compare.citiesUnit}`, "success", planAction);
} }
router.push("/plan"); router.push("/plan");
}; };
@ -83,7 +118,7 @@ export default function AiAssistantClient({ destinations }: Props) {
<p>{t.ai.subtitle}</p> <p>{t.ai.subtitle}</p>
</div> </div>
<div className="ai-suggestions reveal"> <div className="ai-suggestions reveal">
{SUGGESTIONS.map((s) => ( {tips.map((s) => (
<button key={s} type="button" className="filter-btn" disabled={loading} onClick={() => void ask(s)}> <button key={s} type="button" className="filter-btn" disabled={loading} onClick={() => void ask(s)}>
{s} {s}
</button> </button>
@ -100,9 +135,9 @@ export default function AiAssistantClient({ destinations }: Props) {
}} }}
/> />
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void ask()}> <button type="button" className="btn btn-primary" disabled={loading} onClick={() => void ask()}>
{loading ? "思考中…" : t.ai.ask} {loading ? t.ai.thinking : t.ai.ask}
</button> </button>
<p className="section-desc">Ctrl/⌘ + Enter 发送</p> <p className="section-desc">{t.ai.sendHint} · {t.ai.draftHint}</p>
</div> </div>
{reply && ( {reply && (
<div className="ai-reply reveal"> <div className="ai-reply reveal">
@ -116,17 +151,60 @@ export default function AiAssistantClient({ destinations }: Props) {
<Link href="/next-stop" className="btn btn-sm"> <Link href="/next-stop" className="btn btn-sm">
{t.ai.nextStop} {t.ai.nextStop}
</Link> </Link>
<Link href="/compare" className="btn btn-sm"> <Link
城市对比 href={
cities.length
? `/compare?cities=${cities
.slice(0, 4)
.map((c) => c.slug)
.join(",")}`
: "/compare"
}
className="btn btn-sm"
>
{t.ai.compareCities}
</Link> </Link>
{cities.length > 0 && ( {cities.length > 0 && (
<button type="button" className="btn btn-sm btn-primary" onClick={addToPlan}> <button type="button" className="btn btn-sm btn-primary" onClick={addToPlan}>
写入计划 {t.ai.writePlan}
</button> </button>
)} )}
</div> </div>
</div> </div>
)} )}
{history.length > 0 && (
<section className="ai-history reveal">
<div className="ai-history-head">
<h2>{t.ai.history}</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
clearAiHistory();
setHistory([]);
}}
>
{t.ai.clearHistory}
</button>
</div>
<ul className="ai-history-list">
{history.map((h) => (
<li key={h.id}>
<button type="button" className="ai-history-item" onClick={() => restore(h)}>
<strong>{h.question}</strong>
<span>
{h.cities.length
? h.cities.map((c) => `${c.emoji} ${c.name}`).join(" · ")
: h.reply.slice(0, 72)}
</span>
</button>
</li>
))}
</ul>
</section>
)}
<RingNext steps={rings.afterDiscover} /> <RingNext steps={rings.afterDiscover} />
</div> </div>
</div> </div>

View File

@ -2,6 +2,9 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
import { meetupCityHref } from "@/lib/meetupLinks";
import Link from "next/link";
interface Task { interface Task {
id: string; id: string;
@ -110,6 +113,11 @@ export default function ArrivalChecklist({ destinations }: Props) {
<span className="packing-check">{checked[t.id] ? "✓" : ""}</span> <span className="packing-check">{checked[t.id] ? "✓" : ""}</span>
<span className="packing-emoji">{t.emoji}</span> <span className="packing-emoji">{t.emoji}</span>
<span className="packing-label">{t.label}</span> <span className="packing-label">{t.label}</span>
{t.id === "meetup" && dest && (
<Link href={meetupCityHref(dest.name)} className="arrival-meetup-link">
→
</Link>
)}
</label> </label>
</li> </li>
))} ))}
@ -117,6 +125,7 @@ export default function ArrivalChecklist({ destinations }: Props) {
</div> </div>
))} ))}
</div> </div>
{dest && <ToolCityExits dest={dest} />}
</div> </div>
</div> </div>
</section> </section>

View File

@ -1,18 +1,27 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api"; 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"; import type { CostResult, Destination } from "@/lib/types";
interface Props { destinations: Destination[] } interface Props { destinations: Destination[] }
export default function Calculator({ destinations }: Props) { export default function Calculator({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || ""); const [slug, setSlug] = useState(destinations[0]?.slug || "");
const [housing, setHousing] = useState("mid"); const [housing, setHousing] = useState("mid");
const [months, setMonths] = useState(3); const [months, setMonths] = useState(3);
const [result, setResult] = useState<CostResult | null>(null); const [result, setResult] = useState<CostResult | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const selected = destinations.find((d) => d.slug === slug);
const calculate = async () => { const calculate = async () => {
setLoading(true); setLoading(true);
try { 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 ( return (
<section className="section calculator-section" id="calculator"> <section className="section calculator-section" id="calculator">
<div className="container"> <div className="container">
@ -85,6 +109,19 @@ export default function Calculator({ destinations }: Props) {
<div><span>月均</span><strong>¥{result.per_month.toLocaleString()}</strong></div> <div><span>月均</span><strong>¥{result.per_month.toLocaleString()}</strong></div>
<div><span>总计</span><strong className="gradient-text">¥{result.total.toLocaleString()}</strong></div> <div><span>总计</span><strong className="gradient-text">¥{result.total.toLocaleString()}</strong></div>
</div> </div>
{selected && (
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
🗓️ {t.nav.plan}
</button>
<Link href={`/destinations/${selected.slug}`} className="btn btn-sm">
{t.common.detail}
</Link>
<Link href={meetupCityHref(selected.name)} className="btn btn-sm btn-ghost">
{t.common.cityMeetups}
</Link>
</div>
)}
</div> </div>
) : ( ) : (
<div className="result-placeholder"> <div className="result-placeholder">

View File

@ -46,7 +46,11 @@ export default function ChatClient() {
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>{t.chat.loginTitle}</h2> <h2>{t.chat.loginTitle}</h2>
<p>{t.chat.loginDesc}</p> <p>{t.chat.loginDesc}</p>
<Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link> <div className="dating-empty-actions">
<Link href="/login?next=/chat" className="btn btn-primary">{t.nav.login}</Link>
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
<Link href="/dating" className="btn btn-ghost">{t.chat.goDating}</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -64,7 +68,7 @@ export default function ChatClient() {
<h1>{t.chat.title}</h1> <h1>{t.chat.title}</h1>
</div> </div>
<div className="chat-list"> <div className="chat-list">
{loading && <p className="dest-empty">加载对话…</p>} {loading && <p className="dest-empty">{t.chat.loadingList}</p>}
{!loading && {!loading &&
items.map((c) => ( items.map((c) => (
<Link <Link
@ -74,7 +78,7 @@ export default function ChatClient() {
> >
<Avatar photo={c.peer?.photo} name={c.peer?.name} /> <Avatar photo={c.peer?.photo} name={c.peer?.name} />
<div> <div>
<strong>{c.peer?.name || "游民"}</strong> <strong>{c.peer?.name || t.chat.peerFallback}</strong>
<p>{c.lastMessagePreview || t.chat.noMessages}</p> <p>{c.lastMessagePreview || t.chat.noMessages}</p>
</div> </div>
{c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>} {c.unreadCount > 0 && <em className="chat-unread">{c.unreadCount}</em>}
@ -83,9 +87,17 @@ export default function ChatClient() {
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
<div className="notif-empty reveal"> <div className="notif-empty reveal">
<p className="dest-empty">{t.chat.empty}</p> <p className="dest-empty">{t.chat.empty}</p>
<Link href="/dating" className="btn btn-primary btn-sm"> <div className="dating-empty-actions">
去匹配 <Link href="/dating" className="btn btn-primary btn-sm">
</Link> {t.chat.goDating}
</Link>
<Link href="/meetups" className="btn btn-sm">
{t.dating.meetAtEvents}
</Link>
<Link href="/community" className="btn btn-sm">
{t.nav.community}
</Link>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -7,16 +7,22 @@ import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import type { ChatMessage } from "@/lib/types"; 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 }) { export default function ChatThreadClient({ convId }: { convId: string }) {
const { token, user } = useAuth(); const { token, user } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast(); const { toast } = useToast();
const rings = useRingSteps();
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [text, setText] = useState(""); const [text, setText] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(Boolean(token)); const [loading, setLoading] = useState(Boolean(token));
const [draftReady, setDraftReady] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const draftKey = `nomadro-chat-draft:${convId}`;
const load = () => { const load = () => {
if (!token) return; if (!token) return;
@ -38,6 +44,28 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, convId]); }, [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(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]); }, [messages]);
@ -48,9 +76,10 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
try { try {
await api.sendMessage(token, convId, text.trim()); await api.sendMessage(token, convId, text.trim());
setText(""); setText("");
clearDraft(draftKey);
load(); load();
} catch { } catch {
toast("发送失败,请稍后重试", "error"); toast(t.chat.sendFail, "error");
} finally { } finally {
setSending(false); setSending(false);
} }
@ -63,9 +92,17 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>{t.chat.loginTitle}</h2> <h2>{t.chat.loginTitle}</h2>
<p>{t.chat.loginDesc}</p> <p>{t.chat.loginDesc}</p>
<Link href={`/login?next=/chat/${convId}`} className="btn btn-primary"> <div className="dating-empty-actions">
{t.nav.login} <Link href={`/login?next=/chat/${convId}`} className="btn btn-primary">
</Link> {t.nav.login}
</Link>
<Link href="/meetups" className="btn btn-ghost">
{t.dating.meetAtEvents}
</Link>
<Link href="/dating" className="btn btn-ghost">
{t.nav.dating}
</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -77,11 +114,23 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
<div className="container chat-thread-wrap"> <div className="container chat-thread-wrap">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/chat">← {t.chat.back}</Link> <Link href="/chat">← {t.chat.back}</Link>
<span> · </span>
<Link href="/meetups">{t.nav.meetups}</Link>
</nav> </nav>
<div className="chat-thread-messages"> <div className="chat-thread-messages">
{loading && messages.length === 0 && <p className="dest-empty">加载消息…</p>} {loading && messages.length === 0 && <p className="dest-empty">{t.chat.loadingMessages}</p>}
{!loading && messages.length === 0 && ( {!loading && messages.length === 0 && (
<p className="dest-empty">打个招呼开始对话吧</p> <div className="notif-empty">
<p className="dest-empty">{t.chat.sayHi}</p>
<div className="dating-empty-actions">
<Link href="/meetups" className="btn btn-sm">
{t.dating.meetAtEvents}
</Link>
<Link href="/dating" className="btn btn-sm">
{t.nav.dating}
</Link>
</div>
</div>
)} )}
{messages.map((m) => ( {messages.map((m) => (
<div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}> <div key={m.id} className={`chat-bubble${m.mine ? " mine" : ""}`}>
@ -102,6 +151,7 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
{sending ? "…" : t.chat.send} {sending ? "…" : t.chat.send}
</button> </button>
</div> </div>
<RingNext steps={rings.afterDating} />
</div> </div>
</div> </div>
); );

View File

@ -1,8 +1,10 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
const STORAGE_KEY = "nomadro-city-notes"; const STORAGE_KEY = "nomadro-city-notes";
@ -11,6 +13,7 @@ interface Props {
} }
export default function CityNotes({ destinations }: Props) { export default function CityNotes({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast(); const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || "bali"); const [slug, setSlug] = useState(destinations[0]?.slug || "bali");
const [notes, setNotes] = useState<Record<string, string>>({}); const [notes, setNotes] = useState<Record<string, string>>({});
@ -22,7 +25,9 @@ export default function CityNotes({ destinations }: Props) {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (raw) setNotes(JSON.parse(raw)); if (raw) setNotes(JSON.parse(raw));
} catch { /* ignore */ } } catch {
/* ignore */
}
setReady(true); setReady(true);
}, []); }, []);
@ -35,7 +40,10 @@ export default function CityNotes({ destinations }: Props) {
const next = { ...notes, [slug]: text }; const next = { ...notes, [slug]: text };
setNotes(next); setNotes(next);
localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
toast(`${dest?.emoji || ""} 笔记已保存`); toast(`${dest?.emoji || ""} 笔记已保存`, "success", {
href: "/plan",
label: t.strip.openPlan,
});
}; };
const clear = () => { const clear = () => {
@ -66,7 +74,9 @@ export default function CityNotes({ destinations }: Props) {
className={`notes-city${slug === d.slug ? " active" : ""}`} className={`notes-city${slug === d.slug ? " active" : ""}`}
onClick={() => setSlug(d.slug)} onClick={() => setSlug(d.slug)}
> >
<span>{d.emoji} {d.name}</span> <span>
{d.emoji} {d.name}
</span>
{notes[d.slug]?.trim() && <span className="notes-dot" />} {notes[d.slug]?.trim() && <span className="notes-dot" />}
</button> </button>
))} ))}
@ -74,7 +84,9 @@ export default function CityNotes({ destinations }: Props) {
</div> </div>
<div className="notes-editor"> <div className="notes-editor">
<h3>{dest?.emoji} {dest?.name}</h3> <h3>
{dest?.emoji} {dest?.name}
</h3>
<textarea <textarea
className="notes-textarea" className="notes-textarea"
rows={10} rows={10}
@ -83,9 +95,14 @@ export default function CityNotes({ destinations }: Props) {
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
/> />
<div className="notes-actions"> <div className="notes-actions">
<button className="btn btn-primary" onClick={save}>💾 保存</button> <button className="btn btn-primary" onClick={save}>
<button className="btn btn-ghost" onClick={clear} disabled={!text.trim()}>清空</button> 💾 保存
</button>
<button className="btn btn-ghost" onClick={clear} disabled={!text.trim()}>
清空
</button>
</div> </div>
{dest && <ToolCityExits dest={dest} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,7 +7,7 @@ import type { Discussion, DiscussionDetail } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
const CATEGORIES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"]; const CATEGORY_VALUES = ["全部", "签证", "远程工作", "住宿", "安全", "社区"] as const;
interface Props { interface Props {
discussions: Discussion[]; discussions: Discussion[];
@ -20,6 +20,18 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
const [category, setCategory] = useState("全部"); const [category, setCategory] = useState("全部");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const catLabel = (value: string) => {
const map: Record<string, string> = {
全部: t.community.catAll,
签证: t.community.catVisa,
远程工作: t.community.catRemote,
住宿: t.community.catHousing,
安全: t.community.catSafety,
社区: t.community.catCommunity,
};
return map[value] || value;
};
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = discussions; let list = discussions;
if (category !== "全部") list = list.filter((d) => d.category === category); 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)} onChange={(e) => setQ(e.target.value)}
/> />
<div className="tools-hub-filters"> <div className="tools-hub-filters">
{CATEGORIES.map((c) => ( {CATEGORY_VALUES.map((c) => (
<button <button
key={c} key={c}
type="button" type="button"
className={`filter-btn${category === c ? " active" : ""}`} className={`filter-btn${category === c ? " active" : ""}`}
onClick={() => setCategory(c)} onClick={() => setCategory(c)}
> >
{c} {catLabel(c)}
</button> </button>
))} ))}
</div> </div>
@ -106,7 +118,7 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
<div> <div>
<h3>{d.title}</h3> <h3>{d.title}</h3>
<p className="community-meta"> <p className="community-meta">
{d.author} · {d.category} · {d.created_at} {d.author} · {catLabel(d.category)} · {d.created_at}
</p> </p>
</div> </div>
</div> </div>
@ -135,7 +147,7 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
setQ(""); setQ("");
}} }}
> >
{t.meetups.all} {t.community.catAll}
</button> </button>
)} )}
<Link href="/community/new" className="btn btn-primary btn-sm"> <Link href="/community/new" className="btn btn-primary btn-sm">
@ -145,7 +157,7 @@ export default function CommunityHubClient({ discussions, featured }: Props) {
</div> </div>
)} )}
<RingNext steps={rings.afterCommunity} /> <RingNext steps={rings.afterCommunity()} />
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -9,6 +9,13 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; 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() { export default function CommunityNewClient() {
const { token } = useAuth(); const { token } = useAuth();
@ -20,6 +27,38 @@ export default function CommunityNewClient() {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [category, setCategory] = useState("社区"); const [category, setCategory] = useState("社区");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [draftReady, setDraftReady] = useState(false);
const [cities, setCities] = useState<Destination[]>([]);
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) { if (!token) {
return ( return (
@ -29,8 +68,12 @@ export default function CommunityNewClient() {
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>{t.community.newTitle}</h2> <h2>{t.community.newTitle}</h2>
<p>{t.community.loginDesc}</p> <p>{t.community.loginDesc}</p>
<Link href="/login?next=/community/new" className="btn btn-primary">{t.nav.login}</Link> <div className="dating-empty-actions">
<Link href="/login?next=/community/new" className="btn btn-primary">{t.nav.login}</Link>
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
</div>
</div> </div>
<RingNext steps={rings.afterCommunity()} />
</div> </div>
</div> </div>
); );
@ -44,7 +87,7 @@ export default function CommunityNewClient() {
const tTitle = title.trim(); const tTitle = title.trim();
const body = content.trim(); const body = content.trim();
if (!tTitle || body.length < 10) { if (!tTitle || body.length < 10) {
toast("标题必填,正文至少 10 字", "info"); toast(t.community.draftNeed, "info");
return; return;
} }
setBusy(true); setBusy(true);
@ -56,7 +99,15 @@ export default function CommunityNewClient() {
excerpt, excerpt,
category, 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; const id = res.discussion?.id;
router.push(id ? `/community/${id}` : "/community"); router.push(id ? `/community/${id}` : "/community");
} catch { } catch {
@ -72,23 +123,34 @@ export default function CommunityNewClient() {
<nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav> <nav className="detail-nav"><Link href="/community">← {t.community.back}</Link></nav>
<div className="section-header reveal"> <div className="section-header reveal">
<h1>{t.community.newTitle}</h1> <h1>{t.community.newTitle}</h1>
<p className="section-desc">{t.community.draftHint}</p>
</div> </div>
<div className="join-card reveal"> <div className="join-card reveal">
<label>{t.community.newSubject}</label> <label>{t.community.newSubject}</label>
<input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} /> <input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} />
<label>{t.community.newCategory}</label> <label>{t.community.newCategory}</label>
<select value={category} onChange={(e) => setCategory(e.target.value)}> <select value={category} onChange={(e) => setCategory(e.target.value)}>
{["签证", "远程工作", "住宿", "安全", "社区"].map((c) => ( {(
<option key={c}>{c}</option> [
["签证", t.community.catVisa],
["远程工作", t.community.catRemote],
["住宿", t.community.catHousing],
["安全", t.community.catSafety],
["社区", t.community.catCommunity],
] as const
).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))} ))}
</select> </select>
<label>{t.community.newBody}</label> <label>{t.community.newBody}</label>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} maxLength={4000} /> <textarea value={content} onChange={(e) => setContent(e.target.value)} rows={6} maxLength={4000} />
<button type="button" className="btn btn-primary" disabled={busy} onClick={submit}> <button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>
{busy ? "发布中…" : t.community.newSubmit} {busy ? t.meetups.hosting : t.community.newSubmit}
</button> </button>
</div> </div>
<RingNext steps={rings.afterCommunity} /> <RingNext steps={rings.afterCommunity()} />
</div> </div>
</div> </div>
); );

View File

@ -11,11 +11,18 @@ import {
overallScore, overallScore,
parseCompareSlugs, parseCompareSlugs,
} from "@/lib/compareScore"; } from "@/lib/compareScore";
import {
clearCompareHistory,
loadCompareHistory,
pushCompareHistory,
type ComparePreset,
} from "@/lib/compareHistory";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { meetupCityHref } from "@/lib/meetupLinks";
interface Props { interface Props {
destinations: Destination[]; destinations: Destination[];
@ -28,6 +35,11 @@ export default function CompareClient({ destinations }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [picked, setPicked] = useState<string[]>([]); const [picked, setPicked] = useState<string[]>([]);
const [history, setHistory] = useState<ComparePreset[]>([]);
useEffect(() => {
setHistory(loadCompareHistory());
}, []);
useEffect(() => { useEffect(() => {
const fromUrl = parseCompareSlugs(searchParams.get("cities")); const fromUrl = parseCompareSlugs(searchParams.get("cities"));
@ -46,6 +58,16 @@ export default function CompareClient({ destinations }: Props) {
const scores = selected.map(overallScore); const scores = selected.map(overallScore);
const winnerIdx = scores.length ? scores.indexOf(Math.max(...scores)) : -1; 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 syncUrl = (slugs: string[]) => {
const href = compareUrl(slugs); const href = compareUrl(slugs);
router.replace(href, { scroll: false }); router.replace(href, { scroll: false });
@ -67,12 +89,17 @@ export default function CompareClient({ destinations }: Props) {
const addAll = () => { const addAll = () => {
if (selected.length < 1) return; if (selected.length < 1) return;
const { added, skipped } = mergeDestinationsIntoTrip(selected, 1); const { added, skipped } = mergeDestinationsIntoTrip(selected, 1);
const planAction = { href: "/plan", label: t.strip.openPlan };
if (added === 0) { 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"); router.push("/plan");
return; 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"); router.push("/plan");
}; };
@ -80,7 +107,11 @@ export default function CompareClient({ destinations }: Props) {
if (winnerIdx < 0) return; if (winnerIdx < 0) return;
const d = selected[winnerIdx]; const d = selected[winnerIdx];
const { added } = mergeDestinationsIntoTrip([d], 1); 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"); if (added) router.push("/plan");
}; };
@ -94,6 +125,30 @@ export default function CompareClient({ destinations }: Props) {
toast(t.compare.linkCopied); 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 ( return (
<div className="compare-page"> <div className="compare-page">
<div className="container"> <div className="container">
@ -109,17 +164,49 @@ export default function CompareClient({ destinations }: Props) {
<p>{t.compare.subtitle}</p> <p>{t.compare.subtitle}</p>
</header> </header>
{history.length > 0 && (
<section className="compare-history reveal">
<div className="compare-history-head">
<h2>{t.compare.history}</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
clearCompareHistory();
setHistory([]);
}}
>
{t.compare.clearHistory}
</button>
</div>
<div className="compare-history-list">
{history.map((h) => (
<button
key={`${h.at}-${h.slugs.join("-")}`}
type="button"
className="compare-history-chip"
onClick={() => restorePreset(h)}
>
<span>{h.labels.join(" vs ")}</span>
<strong>{t.compare.restore}</strong>
</button>
))}
</div>
</section>
)}
<section className="compare-pick plan-panel"> <section className="compare-pick plan-panel">
<div className="plan-panel-head"> <div className="plan-panel-head">
<h2>{t.compare.pick}({picked.length}/4)</h2> <h2>{t.compare.pick}({picked.length}/4)</h2>
<div className="plan-actions"> <div className="plan-actions">
<button type="button" className="btn btn-ghost btn-sm" disabled={picked.length === 0} onClick={() => { setPicked([]); syncUrl([]); }}>{t.compare.clear}</button> <button type="button" className="btn btn-ghost btn-sm" disabled={picked.length === 0} onClick={() => { setPicked([]); syncUrl([]); }}>{t.compare.clear}</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>{t.compare.copyLink}</button> <button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={share}>{t.compare.copyLink}</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={selected.length < 2} onClick={() => void copySnapshot()}>{t.compare.snapshot}</button>
</div> </div>
</div> </div>
<div className="compare-pick-grid"> <div className="compare-pick-grid">
{destinations.length === 0 ? ( {destinations.length === 0 ? (
<p className="dest-empty">目的地数据加载中或暂不可用</p> <p className="dest-empty">{t.compare.loadingDest}</p>
) : ( ) : (
destinations.map((d) => { destinations.map((d) => {
const on = picked.includes(d.slug); const on = picked.includes(d.slug);
@ -215,12 +302,26 @@ export default function CompareClient({ destinations }: Props) {
</div> </div>
<div className="compare-page-actions"> <div className="compare-page-actions">
<button type="button" className="btn btn-ghost" onClick={() => void copySnapshot()}>
{t.compare.snapshot}
</button>
<button type="button" className="btn btn-ghost" onClick={addWinner} disabled={winnerIdx < 0}> <button type="button" className="btn btn-ghost" onClick={addWinner} disabled={winnerIdx < 0}>
{t.compare.addWinner} {t.compare.addWinner}
</button> </button>
<button type="button" className="btn btn-primary" onClick={addAll}> <button type="button" className="btn btn-primary" onClick={addAll}>
{t.compare.addAll} {t.compare.addAll}
</button> </button>
<Link href="/plan" className="btn btn-ghost">
{t.compare.openPlan}
</Link>
{winnerIdx >= 0 && selected[winnerIdx] && (
<Link
href={meetupCityHref(selected[winnerIdx].name)}
className="btn btn-ghost"
>
{t.compare.winnerMeetups.replace("{city}", selected[winnerIdx].name)}
</Link>
)}
</div> </div>
</section> </section>
@ -236,7 +337,12 @@ export default function CompareClient({ destinations }: Props) {
</> </>
)} )}
<RingNext steps={rings.afterCity} /> <RingNext
steps={rings.afterCompare(
picked,
winnerIdx >= 0 && selected[winnerIdx] ? selected[winnerIdx].name : undefined
)}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,15 +1,19 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; 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() { export default function ContentSubmitClient() {
const { token } = useAuth(); const { token } = useAuth();
@ -23,6 +27,30 @@ export default function ContentSubmitClient() {
const [contentType, setContentType] = useState("article"); const [contentType, setContentType] = useState("article");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const draft = loadDraft<SubmitDraft>(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 () => { const submit = async () => {
if (!token) { if (!token) {
@ -30,11 +58,11 @@ export default function ContentSubmitClient() {
return; return;
} }
if (!title.trim() || title.trim().length < 4) { if (!title.trim() || title.trim().length < 4) {
toast("请填写更完整的标题", "info"); toast(t.submit.needTitle, "info");
return; return;
} }
if ((contentType === "article" || contentType === "video") && !/^https?:\/\/.+/.test(url.trim())) { if ((contentType === "article" || contentType === "video") && !/^https?:\/\/.+/.test(url.trim())) {
toast("文章/视频请填写有效链接(http/https)", "info"); toast(t.submit.needUrl, "info");
return; return;
} }
setBusy(true); setBusy(true);
@ -49,7 +77,11 @@ export default function ContentSubmitClient() {
}, },
token token
); );
toast(t.submit.ok, "success"); clearDraft(DRAFT_KEY);
toast(t.submit.ok, "success", {
href: "/community",
label: t.submit.goCommunity,
});
setTitle(""); setTitle("");
setUrl(""); setUrl("");
setNotes(""); setNotes("");
@ -71,17 +103,18 @@ export default function ContentSubmitClient() {
<span className="section-tag">{t.submit.tag}</span> <span className="section-tag">{t.submit.tag}</span>
<h1>{t.submit.title}</h1> <h1>{t.submit.title}</h1>
<p>{t.submit.subtitle}</p> <p>{t.submit.subtitle}</p>
{!done && <p className="section-desc">{t.submit.draftHint}</p>}
</div> </div>
{done ? ( {done ? (
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>投稿已收到</h2> <h2>{t.submit.thanksTitle}</h2>
<p>审核通过后会出现在博客或视频区</p> <p>{t.submit.thanksDesc}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<button type="button" className="btn btn-primary" onClick={() => setDone(false)}> <button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
再投一篇 {t.submit.writeAgain}
</button> </button>
<Link href="/community" className="btn"> <Link href="/community" className="btn">
去社区 {t.submit.goCommunity}
</Link> </Link>
</div> </div>
</div> </div>
@ -112,11 +145,11 @@ export default function ContentSubmitClient() {
maxLength={2000} maxLength={2000}
/> />
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}> <button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>
{busy ? "提交中…" : t.submit.send} {busy ? t.submit.submitting : t.submit.send}
</button> </button>
{!token && ( {!token && (
<p className="section-desc"> <p className="section-desc">
<Link href="/login?next=/submit">{t.nav.login}</Link> 后可提交 <Link href="/login?next=/submit">{t.nav.login}</Link> · {t.gigs.loginFirst}
</p> </p>
)} )}
</div> </div>

View File

@ -2,8 +2,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n";
export default function CookieConsent() { export default function CookieConsent() {
const { t } = useI18n();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
useEffect(() => { useEffect(() => {
@ -21,19 +23,16 @@ export default function CookieConsent() {
if (!visible) return null; if (!visible) return null;
return ( return (
<div className="cookie-consent"> <div className="cookie-consent" role="dialog" aria-live="polite">
<div className="cookie-consent-inner"> <p>
<span className="cookie-emoji">🍪</span> {t.cookies.body}{" "}
<p> <Link href="/privacy">{t.cookies.privacy}</Link>
我们使用本地存储保存主题、行程与登录会话,不做广告追踪。 {" · "}
<Link href="/privacy">隐私政策</Link> <Link href="/cookies">{t.cookies.cookies}</Link>
{" · "} </p>
<Link href="/cookies">Cookie 说明</Link> <button type="button" className="btn btn-primary btn-sm" onClick={accept}>
</p> {t.cookies.ok}
<button type="button" className="btn btn-primary btn-sm" onClick={accept}> </button>
知道了 ✓
</button>
</div>
</div> </div>
); );
} }

View File

@ -1,7 +1,12 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types"; 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 = [ const HOME_CITIES = [
{ name: "上海", cost: 12000, emoji: "🏙️" }, { name: "上海", cost: 12000, emoji: "🏙️" },
@ -16,6 +21,8 @@ interface Props {
} }
export default function CostCompare({ destinations }: Props) { export default function CostCompare({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const [homeIdx, setHomeIdx] = useState(0); const [homeIdx, setHomeIdx] = useState(0);
const [destSlug, setDestSlug] = useState(destinations[0]?.slug || ""); 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 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 ( return (
<section className="section cost-compare-section" id="cost-compare"> <section className="section cost-compare-section" id="cost-compare">
<div className="container"> <div className="container">
@ -112,6 +128,17 @@ export default function CostCompare({ destinations }: Props) {
</> </>
)} )}
</div> </div>
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
🗓️ {t.nav.plan}
</button>
<Link href={`/destinations/${dest.slug}`} className="btn btn-sm">
{t.common.detail}
</Link>
<Link href={meetupCityHref(dest.name)} className="btn btn-sm btn-ghost">
{t.common.cityMeetups}
</Link>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -11,17 +11,6 @@ import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; 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() { export default function DatingClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
const router = useRouter(); const router = useRouter();
@ -34,44 +23,89 @@ export default function DatingClient() {
const [quota, setQuota] = useState<MatchQuota | null>(null); const [quota, setQuota] = useState<MatchQuota | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [joined, setJoined] = useState(false); const [joined, setJoined] = useState(false);
const [error, setError] = useState("");
const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null); 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; if (!token) return;
setLoading(true);
try { 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([ const [candidates, q] = await Promise.all([
api.getMatchCandidates(token, { intent }), api.getMatchCandidates(token, { intent }),
api.getMatchQuota(token), api.getMatchQuota(token),
]); ]);
setDeck(candidates); setDeck(Array.isArray(candidates) ? candidates : []);
setQuota(q); setQuota(q);
setJoined(true); setJoined(true);
} catch { } catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
setJoined(false); setJoined(false);
setDeck([]); setDeck([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token, intent]); }, [token, intent, ensureJoined]);
useEffect(() => { void load(); }, [load]); useEffect(() => {
void load();
}, [load]);
const quickJoin = async () => { const quickJoin = async () => {
if (!token) { if (!token) {
router.push("/login?next=/dating"); router.push("/login?next=/dating");
return; return;
} }
setLoading(true);
setError("");
try { try {
await api.socialJoin(token, { await api.socialJoin(token, {
city: "全球", city: t.dating.defaultCity,
lookingFor: [intent, "explore"], lookingFor: [intent, "explore"],
bio: "nomadro 游民", bio: t.dating.defaultBio,
});
toast(t.dating.joinOk, "success", {
href: "/meetups",
label: t.dating.meetAtEvents,
}); });
toast("资料已完善,开始匹配", "success");
await load(); await load();
} catch { } catch (err) {
toast("加入失败", "error"); 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); const q = await api.getMatchQuota(token);
setQuota(q); setQuota(q);
} catch { } catch {
toast("滑动失败,请检查登录或配额", "error"); toast(t.dating.swipeFail, "error");
} }
}; };
@ -100,7 +134,11 @@ export default function DatingClient() {
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>{t.dating.loginTitle}</h2> <h2>{t.dating.loginTitle}</h2>
<p>{t.dating.loginDesc}</p> <p>{t.dating.loginDesc}</p>
<Link href="/login?next=/dating" className="btn btn-primary">{t.nav.login}</Link> <div className="dating-empty-actions">
<Link href="/login?next=/dating" className="btn btn-primary">{t.nav.login}</Link>
<Link href="/meetups" className="btn btn-ghost">{t.dating.meetAtEvents}</Link>
<Link href="/community" className="btn btn-ghost">{t.nav.community}</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -116,6 +154,8 @@ export default function DatingClient() {
<Link href="/chat">{t.nav.chat}</Link> <Link href="/chat">{t.nav.chat}</Link>
<span> · </span> <span> · </span>
<Link href="/dating/likes">{t.dating.likesTitle}</Link> <Link href="/dating/likes">{t.dating.likesTitle}</Link>
<span> · </span>
<Link href="/meetups">{t.nav.meetups}</Link>
</nav> </nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.dating.tag}</span> <span className="section-tag">{t.dating.tag}</span>
@ -124,7 +164,7 @@ export default function DatingClient() {
</div> </div>
<div className="dating-intents reveal"> <div className="dating-intents reveal">
{(moreIntents ? INTENTS : PRIMARY_INTENTS).map((i) => ( {(moreIntents ? intents : primary).map((i) => (
<button <button
key={i.key} key={i.key}
type="button" type="button"
@ -136,49 +176,97 @@ export default function DatingClient() {
))} ))}
{!moreIntents && ( {!moreIntents && (
<button type="button" className="filter-btn" onClick={() => setMoreIntents(true)}> <button type="button" className="filter-btn" onClick={() => setMoreIntents(true)}>
更多意图… {t.dating.moreIntents}
</button> </button>
)} )}
</div> </div>
{quota && ( {quota && (
<p className="dating-quota reveal"> <p className="dating-quota reveal">
{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 && ( {!quota.vip && quota.remaining <= 0 && (
<> <>
{" · "} {" · "}
<Link href="/join">开通 VIP 无限匹配</Link> <Link href="/join">{t.dating.vipUnlock}</Link>
</> </>
)} )}
</p> </p>
)} )}
{loading && (
<p className="dest-empty reveal">
{joined ? t.dating.loadingCandidates : t.dating.loadingProfile}
</p>
)}
{!joined && !loading && ( {!joined && !loading && (
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<p>{t.dating.joinFirst}</p> <p>{t.dating.joinFirst}</p>
<button type="button" className="btn btn-primary" onClick={quickJoin}>{t.dating.joinBtn}</button> {error && <p className="dest-empty" style={{ marginTop: "0.5rem" }}>{error}</p>}
<div className="dating-empty-actions">
<button type="button" className="btn btn-primary" onClick={() => void quickJoin()}>
{t.dating.joinBtn}
</button>
<button type="button" className="btn btn-ghost" onClick={() => void load()}>
{t.dating.refresh}
</button>
</div>
</div> </div>
)} )}
{loading && (
<p className="dest-empty reveal">{joined ? "加载候选人…" : "加载匹配资料…"}</p>
)}
{top && ( {top && (
<div className="dating-card reveal"> <div className="dating-card reveal">
<div className="dating-card-photo">{top.photo || "🧑‍💻"}</div> <div className="dating-card-photo">{top.photo || "🧑‍💻"}</div>
<h2>{top.name}</h2> <h2>{top.name}</h2>
<p className="dating-meta">{top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}</p> <p className="dating-meta">
{top.location} · {top.gender} {top.single ? `· ${top.single}` : ""}
</p>
<p>{top.bio}</p> <p>{top.bio}</p>
<div className="dating-tags"> <div className="dating-tags">
{(top.tags || []).map((tag) => <span key={tag} className="meetup-tag">{tag}</span>)} {(top.tags || []).map((tag) => (
<span key={tag} className="meetup-tag">
{tag}
</span>
))}
</div> </div>
<div className="dating-actions"> <div className="dating-actions">
<button type="button" className="dating-btn pass" onClick={() => swipe("dislike")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>✕</button> <button
<button type="button" className="dating-btn super" onClick={() => swipe("superlike")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>⭐</button> type="button"
<button type="button" className="dating-btn like" onClick={() => swipe("like")} disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}>♥</button> className="dating-btn pass"
onClick={() => void swipe("dislike")}
disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}
>
✕
</button>
<button
type="button"
className="dating-btn super"
onClick={() => void swipe("superlike")}
disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}
>
⭐
</button>
<button
type="button"
className="dating-btn like"
onClick={() => void swipe("like")}
disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)}
>
♥
</button>
{token && ( {token && (
<button type="button" className="btn btn-sm" title={t.dating.undo} onClick={() => api.undoSwipe(token).then(() => load())}>↩</button> <button
type="button"
className="btn btn-sm"
title={t.dating.undo}
onClick={() => void api.undoSwipe(token).then(() => load())}
>
↩
</button>
)} )}
</div> </div>
</div> </div>
@ -188,10 +276,23 @@ export default function DatingClient() {
<div className="dating-empty reveal"> <div className="dating-empty reveal">
<p className="dest-empty">{t.dating.empty}</p> <p className="dest-empty">{t.dating.empty}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<button type="button" className="btn btn-sm" onClick={() => void load()}>刷新</button> <button type="button" className="btn btn-sm" onClick={() => void load()}>
<Link href="/meetups" className="btn btn-sm">去活动认识人</Link> {t.dating.refresh}
<Link href="/chat" className="btn btn-sm">查看对话</Link> </button>
{quota && !quota.vip && <Link href="/join" className="btn btn-primary btn-sm">开通 VIP</Link>} <Link href="/meetups" className="btn btn-sm">
{t.dating.meetAtEvents}
</Link>
<Link href="/community" className="btn btn-sm">
{t.nav.community}
</Link>
<Link href="/chat" className="btn btn-sm">
{t.dating.viewChats}
</Link>
{quota && !quota.vip && (
<Link href="/join" className="btn btn-primary btn-sm">
{t.dating.openVip}
</Link>
)}
</div> </div>
</div> </div>
)} )}
@ -202,12 +303,21 @@ export default function DatingClient() {
{matchModal && ( {matchModal && (
<div className="dating-match-modal" role="dialog"> <div className="dating-match-modal" role="dialog">
<div className="dating-match-box"> <div className="dating-match-box">
<h3>🎉 {t.dating.matched} {matchModal.name}!</h3> <h3>
🎉 {t.dating.matched} {matchModal.name}!
</h3>
<div className="dating-match-actions"> <div className="dating-match-actions">
<button type="button" className="btn" onClick={() => setMatchModal(null)}>{t.dating.keepSwiping}</button> <button type="button" className="btn" onClick={() => setMatchModal(null)}>
{t.dating.keepSwiping}
</button>
{matchModal.conversationId && ( {matchModal.conversationId && (
<Link href={`/chat/${matchModal.conversationId}`} className="btn btn-primary">{t.dating.chatNow}</Link> <Link href={`/chat/${matchModal.conversationId}`} className="btn btn-primary">
{t.dating.chatNow}
</Link>
)} )}
<Link href="/meetups" className="btn btn-ghost" onClick={() => setMatchModal(null)}>
{t.dating.meetAtEvents}
</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,10 +6,13 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { MatchProfile } from "@/lib/types"; import type { MatchProfile } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function DatingLikesClient() { export default function DatingLikesClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [likes, setLikes] = useState<MatchProfile[]>([]); const [likes, setLikes] = useState<MatchProfile[]>([]);
const [loading, setLoading] = useState(Boolean(token)); const [loading, setLoading] = useState(Boolean(token));
@ -52,13 +55,18 @@ export default function DatingLikesClient() {
<h1>{t.dating.likesTitle}</h1> <h1>{t.dating.likesTitle}</h1>
<p>{t.dating.likesSubtitle}</p> <p>{t.dating.likesSubtitle}</p>
</div> </div>
{loading && <p className="dest-empty">加载中…</p>} {loading && <p className="dest-empty">{t.common.loading}</p>}
{!loading && likes.length === 0 && ( {!loading && likes.length === 0 && (
<div className="notif-empty reveal"> <div className="notif-empty reveal">
<p className="dest-empty">{t.dating.likesEmpty}</p> <p className="dest-empty">{t.dating.likesEmpty}</p>
<Link href="/dating" className="btn btn-primary btn-sm"> <div className="dating-empty-actions">
去滑动匹配 <Link href="/dating" className="btn btn-primary btn-sm">
</Link> {t.dating.goSwipe}
</Link>
<Link href="/meetups" className="btn btn-sm">
{t.dating.meetAtEvents}
</Link>
</div>
</div> </div>
)} )}
<div className="dating-likes-grid"> <div className="dating-likes-grid">
@ -77,11 +85,12 @@ export default function DatingLikesClient() {
)} )}
</div> </div>
<strong>{p.name}</strong> <strong>{p.name}</strong>
<p className="dating-meta">{p.location || "全球"}</p> <p className="dating-meta">{p.location || t.dating.globalNomad}</p>
</Link> </Link>
); );
})} })}
</div> </div>
<RingNext steps={rings.afterDating} />
</div> </div>
</div> </div>
); );

View File

@ -6,10 +6,13 @@ import { useToast } from "@/lib/toast";
import { loadTrip } from "@/lib/tripStorage"; import { loadTrip } from "@/lib/tripStorage";
import { loadPlanMeta, stayHint } from "@/lib/planMeta"; import { loadPlanMeta, stayHint } from "@/lib/planMeta";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { trackRecentDestination } from "@/lib/recentDestinations";
import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api"; import { api, type CityContentItem, type CityDetailPayload } from "@/lib/api";
import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types"; import type { Destination, Discussion, Meetup, TripItem } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { useI18n } from "@/lib/i18n";
import { meetupCityHref, meetupEventHref } from "@/lib/meetupLinks";
const TZ_LABELS: Record<string, string> = { const TZ_LABELS: Record<string, string> = {
bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7", bali: "UTC+8", lisbon: "UTC+0", chiangmai: "UTC+7",
@ -21,19 +24,9 @@ interface Props {
allDestinations: Destination[]; 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) { export default function DestinationDetailClient({ dest, allDestinations }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [added, setAdded] = useState(false); const [added, setAdded] = useState(false);
const [months, setMonths] = useState(1); const [months, setMonths] = useState(1);
@ -42,7 +35,18 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
const [meetups, setMeetups] = useState<Meetup[]>([]); const [meetups, setMeetups] = useState<Meetup[]>([]);
const [discussions, setDiscussions] = useState<Discussion[]>([]); const [discussions, setDiscussions] = useState<Discussion[]>([]);
const [depthLoading, setDepthLoading] = useState(true); 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 visa = useMemo(() => stayHint(dest.country, months), [dest.country, months]);
const budget = useMemo(() => loadPlanMeta().monthlyBudget, []); const budget = useMemo(() => loadPlanMeta().monthlyBudget, []);
const overBudget = budget > 0 && dest.cost > budget; 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) .filter((d) => d.region === dest.region && d.slug !== dest.slug)
.slice(0, 3); .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(() => { useEffect(() => {
setDepthLoading(true); setDepthLoading(true);
api api
@ -72,8 +87,8 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
const addToTrip = () => { const addToTrip = () => {
const trip = loadTrip<TripItem>(); const trip = loadTrip<TripItem>();
if (trip.some((t) => t.slug === dest.slug)) { if (trip.some((item) => item.slug === dest.slug)) {
toast("该城市已在行程中", "info"); toast(t.common.alreadyInTrip, "info", { href: "/plan", label: t.strip.openPlan });
setAdded(true); setAdded(true);
return; return;
} }
@ -81,9 +96,13 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
setAdded(true); setAdded(true);
toast( toast(
n > 0 n > 0
? `${dest.emoji} ${dest.name} 已加入旅居计划(${months} 个月)` ? t.common.addedToPlanMonths
: "该城市已在计划中", .replace("{emoji}", dest.emoji)
n > 0 ? "success" : "info" .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 }); await navigator.share({ title: `${dest.name} - nomadro`, url });
} else { } else {
await navigator.clipboard.writeText(url); 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(() => { useEffect(() => {
const trip = loadTrip<TripItem>(); const trip = loadTrip<TripItem>();
setAdded(trip.some((t) => t.slug === dest.slug)); setAdded(trip.some((item) => item.slug === dest.slug));
}, [dest.slug]); }, [dest.slug]);
const costBreak = detail?.cost?.breakdown || []; const costBreak = detail?.cost?.breakdown || [];
@ -110,11 +152,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
<> <>
<div className="dest-detail-actions"> <div className="dest-detail-actions">
{added ? ( {added ? (
<Link href="/plan" className="btn btn-primary">✓ 已在计划中 · 打开 →</Link> <Link href="/plan" className="btn btn-primary">{t.common.inPlanOpen}</Link>
) : ( ) : (
<> <>
<label className="dest-months-pick"> <label className="dest-months-pick">
停留 {t.common.stayLabel}
<input <input
type="number" type="number"
min={1} min={1}
@ -122,18 +164,21 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
value={months} value={months}
onChange={(e) => setMonths(Math.max(1, Math.min(24, +e.target.value || 1)))} onChange={(e) => setMonths(Math.max(1, Math.min(24, +e.target.value || 1)))}
/> />
月 {t.common.monthsUnit}
</label> </label>
<button type="button" className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button> <button type="button" className="btn btn-primary" onClick={addToTrip}>{t.common.addToPlan}</button>
</> </>
)} )}
<Link href={`/compare?cities=${dest.slug}`} className="btn btn-ghost">⚖️ 去对比</Link> <Link href={`/compare?cities=${dest.slug}`} className="btn btn-ghost">{t.common.goCompare}</Link>
<Link href="/next-stop" className="btn btn-ghost">🧭 找下一站</Link> <Link href="/next-stop" className="btn btn-ghost">{t.common.findNext}</Link>
<Link href="/meetups" className="btn btn-ghost">🍹 同城活动</Link> <Link href={meetupCityHref(dest.name)} className="btn btn-ghost">{t.common.cityMeetups}</Link>
<button type="button" className="btn btn-ghost" onClick={share}>📤 分享</button> <button type="button" className="btn btn-ghost" onClick={share}>{t.common.shareBtn}</button>
<button type="button" className="btn btn-ghost" onClick={() => void copySnapshot()}>
{t.profile.snapshotBtn}
</button>
</div> </div>
{depthLoading && <p className="dest-depth-loading">加载城市深度信息…</p>} {depthLoading && <p className="dest-depth-loading">{t.common.loadingDepth}</p>}
{(visa || overBudget) && ( {(visa || overBudget) && (
<div className="dest-plan-hints"> <div className="dest-plan-hints">
@ -142,7 +187,9 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
)} )}
{overBudget && ( {overBudget && (
<p className="plan-visa warn"> <p className="plan-visa warn">
💰 月生活费 ¥{dest.cost.toLocaleString()} 高于你的计划月预算 ¥{budget.toLocaleString()} {t.common.cityOverBudget
.replace("{cost}", dest.cost.toLocaleString())
.replace("{budget}", budget.toLocaleString())}
</p> </p>
)} )}
</div> </div>
@ -150,11 +197,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{guide?.summary && ( {guide?.summary && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>🧭 落地指南</h3> <h3>{t.destDetail.guide}</h3>
<p>{guide.summary}</p> <p>{guide.summary}</p>
{guide.workSetup && <p className="dest-depth-muted">{guide.workSetup}</p>} {guide.workSetup && <p className="dest-depth-muted">{guide.workSetup}</p>}
{!!guide.bestFor?.length && ( {!!guide.bestFor?.length && (
<p className="dest-depth-tags">适合:{guide.bestFor.join(" · ")}</p> <p className="dest-depth-tags">{t.destDetail.bestFor}{guide.bestFor.join(" · ")}</p>
)} )}
{!!guide.arrivalChecklist?.length && ( {!!guide.arrivalChecklist?.length && (
<ul className="dest-depth-list"> <ul className="dest-depth-list">
@ -166,7 +213,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
<div className="dest-detail-extras"> <div className="dest-detail-extras">
<div className="dest-radar-card"> <div className="dest-radar-card">
<h3>📊 城市画像</h3> <h3>{t.destDetail.radar}</h3>
<div className="dest-radar-bars"> <div className="dest-radar-bars">
{scores.map((s) => ( {scores.map((s) => (
<div key={s.label} className="dest-radar-row"> <div key={s.label} className="dest-radar-row">
@ -184,24 +231,26 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
<div className="dest-info-mini"> <div className="dest-info-mini">
<span>🕐</span> <span>🕐</span>
<div> <div>
<strong>时区</strong> <strong>{t.destDetail.timezone}</strong>
<span>{TZ_LABELS[dest.slug] || "查看时区看板"}</span> <span>{TZ_LABELS[dest.slug] || t.common.viewTimezone}</span>
</div> </div>
</div> </div>
<div className="dest-info-mini"> <div className="dest-info-mini">
<span>👥</span> <span>👥</span>
<div> <div>
<strong>游民社区</strong> <strong>{t.destDetail.nomadCommunity}</strong>
<span>{detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} 活跃</span> <span>{detail?.people?.nomadsNow?.toLocaleString() || dest.nomads_count} {t.destDetail.active}</span>
</div> </div>
</div> </div>
<div className="dest-info-mini"> <div className="dest-info-mini">
<span>🌡️</span> <span>🌡️</span>
<div> <div>
<strong>气候</strong> <strong>{t.destDetail.climate}</strong>
<span> <span>
{detail?.weather?.temperature ?? dest.temperature}°C {detail?.weather?.temperature ?? dest.temperature}°C
{detail?.weather?.bestMonths ? ` · 宜居 ${detail.weather.bestMonths.join("/")}` : ""} {detail?.weather?.bestMonths
? ` · ${t.destDetail.livable} ${detail.weather.bestMonths.join("/")}`
: ""}
</span> </span>
</div> </div>
</div> </div>
@ -210,7 +259,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!costBreak.length && ( {!!costBreak.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>💸 月度成本拆解</h3> <h3>{t.destDetail.costBreak}</h3>
<div className="dest-cost-grid"> <div className="dest-cost-grid">
{costBreak.map((row) => ( {costBreak.map((row) => (
<div key={row.label} className="dest-cost-row"> <div key={row.label} className="dest-cost-row">
@ -226,11 +275,11 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{(pros?.pros || pros?.cons) && ( {(pros?.pros || pros?.cons) && (
<section className="dest-depth-block dest-pros-cons"> <section className="dest-depth-block dest-pros-cons">
<div> <div>
<h3>👍 优势</h3> <h3>{t.destDetail.pros}</h3>
<ul>{(pros?.pros || []).map((p) => <li key={p}>{p}</li>)}</ul> <ul>{(pros?.pros || []).map((p) => <li key={p}>{p}</li>)}</ul>
</div> </div>
<div> <div>
<h3>👎 注意</h3> <h3>{t.destDetail.cons}</h3>
<ul>{(pros?.cons || []).map((p) => <li key={p}>{p}</li>)}</ul> <ul>{(pros?.cons || []).map((p) => <li key={p}>{p}</li>)}</ul>
</div> </div>
</section> </section>
@ -238,7 +287,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!detail?.reviews?.items?.length && ( {!!detail?.reviews?.items?.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>💬 游民评价 · {detail.reviews.rating}/10</h3> <h3>{t.destDetail.reviews} · {detail.reviews.rating}/10</h3>
<div className="dest-review-grid"> <div className="dest-review-grid">
{detail.reviews.items.map((r) => ( {detail.reviews.items.map((r) => (
<blockquote key={`${r.author}-${r.text.slice(0, 12)}`}> <blockquote key={`${r.author}-${r.text.slice(0, 12)}`}>
@ -252,24 +301,26 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!detail?.chat?.channels?.length && ( {!!detail?.chat?.channels?.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>💬 同城社群</h3> <h3>{t.destDetail.localChat}</h3>
<ul className="dest-depth-list"> <ul className="dest-depth-list">
{detail.chat.channels.map((c) => ( {detail.chat.channels.map((c) => (
<li key={c.name}>{c.name} · {c.members} 人 · {c.status}</li> <li key={c.name}>{c.name} · {c.members} {t.destDetail.peopleUnit} · {c.status}</li>
))} ))}
</ul> </ul>
{!!detail.chat.latestTopics?.length && ( {!!detail.chat.latestTopics?.length && (
<p className="dest-depth-muted">近期话题:{detail.chat.latestTopics.join(" · ")}</p> <p className="dest-depth-muted">
{t.destDetail.recentTopics}{detail.chat.latestTopics.join(" · ")}
</p>
)} )}
</section> </section>
)} )}
{!!meetups.length && ( {!!meetups.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>🍹 相关活动</h3> <h3>{t.destDetail.relatedMeetups}</h3>
<div className="dest-related-grid"> <div className="dest-related-grid">
{meetups.slice(0, 4).map((m) => ( {meetups.slice(0, 4).map((m) => (
<Link key={m.id} href="/meetups" className="dest-related-card"> <Link key={m.id} href={meetupEventHref(m)} className="dest-related-card">
<span>{m.emoji || "🎉"}</span> <span>{m.emoji || "🎉"}</span>
<div> <div>
<strong>{m.title}</strong> <strong>{m.title}</strong>
@ -283,7 +334,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!discussions.length && ( {!!discussions.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>🧵 相关讨论</h3> <h3>{t.destDetail.relatedDiscussions}</h3>
<ul className="dest-depth-list"> <ul className="dest-depth-list">
{discussions.slice(0, 5).map((d) => ( {discussions.slice(0, 5).map((d) => (
<li key={d.id}> <li key={d.id}>
@ -296,14 +347,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{!!content.length && ( {!!content.length && (
<section className="dest-depth-block"> <section className="dest-depth-block">
<h3>📚 相关内容</h3> <h3>{t.destDetail.relatedContent}</h3>
<div className="dest-related-grid"> <div className="dest-related-grid">
{content.map((c) => ( {content.map((c) => (
<Link key={c.slug} href={c.targetUrl || "/digital"} className="dest-related-card"> <Link key={c.slug} href={c.targetUrl || "/digital"} className="dest-related-card">
<span>📄</span> <span>📄</span>
<div> <div>
<strong>{c.title}</strong> <strong>{c.title}</strong>
<span>{c.subtitle || c.ctaLabel || "查看"}</span> <span>{c.subtitle || c.ctaLabel || t.destDetail.viewFallback}</span>
</div> </div>
</Link> </Link>
))} ))}
@ -313,14 +364,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
{related.length > 0 && ( {related.length > 0 && (
<div className="dest-related"> <div className="dest-related">
<h3>🔗 同区域推荐</h3> <h3>{t.destDetail.relatedRegion}</h3>
<div className="dest-related-grid"> <div className="dest-related-grid">
{related.map((d) => ( {related.map((d) => (
<Link key={d.slug} href={`/destinations/${d.slug}`} className="dest-related-card"> <Link key={d.slug} href={`/destinations/${d.slug}`} className="dest-related-card">
<span>{d.emoji}</span> <span>{d.emoji}</span>
<div> <div>
<strong>{d.name}</strong> <strong>{d.name}</strong>
<span>¥{d.cost.toLocaleString()}/月 · ⭐ {d.rating}</span> <span>¥{d.cost.toLocaleString()}{t.destDetail.perMonth} · ⭐ {d.rating}</span>
</div> </div>
</Link> </Link>
))} ))}
@ -328,7 +379,7 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
</div> </div>
)} )}
<RingNext steps={rings.afterCity} /> <RingNext steps={rings.afterCity(dest.name)} />
</> </>
); );
} }

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import { compareUrl } from "@/lib/compareScore"; import { compareUrl } from "@/lib/compareScore";
import { import {
EMPTY_MATCHER_PREFS, EMPTY_MATCHER_PREFS,
@ -26,38 +27,7 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
const STEPS = [ const STEP_KEYS = ["budget", "climate", "priority", "region"] as const;
{ 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: "安全高效现代" },
],
};
function parseNomads(n: string): number { function parseNomads(n: string): number {
return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0; return parseInt(n.replace(/[^0-9]/g, ""), 10) || 0;
@ -92,23 +62,75 @@ function scoreDestination(
export default function DestinationMatcher({ destinations, open, onClose }: Props) { export default function DestinationMatcher({ destinations, open, onClose }: Props) {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [prefs, setPrefs] = useState<MatcherPrefs>(EMPTY_MATCHER_PREFS); const [prefs, setPrefs] = useState<MatcherPrefs>(EMPTY_MATCHER_PREFS);
const [direction, setDirection] = useState<"forward" | "back">("forward"); const [direction, setDirection] = useState<"forward" | "back">("forward");
const [hydrated, setHydrated] = useState(false); 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(() => { useEffect(() => {
if (!open) return; if (!open) {
setHydrated(false);
return;
}
const saved = loadMatcherPrefs(); const saved = loadMatcherPrefs();
setPrefs(saved); setPrefs(saved);
if (matcherPrefsComplete(saved)) setStep(STEPS.length); if (matcherPrefsComplete(saved)) setStep(STEP_KEYS.length);
else { else {
const filled = STEPS.findIndex((s) => !saved[s.key]); const filled = STEP_KEYS.findIndex((k) => !saved[k]);
setStep(filled === -1 ? 0 : filled); setStep(filled === -1 ? 0 : filled);
} }
setHydrated(true); setHydrated(true);
}, [open]); }, [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(() => { const results = useMemo(() => {
if (!matcherPrefsComplete(prefs)) return []; if (!matcherPrefsComplete(prefs)) return [];
return [...destinations] 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 top3 = results.slice(0, 3).map((r) => r.dest);
const currentKey = STEPS[step]?.key; const currentKey = steps[step]?.key;
const isComplete = step >= STEPS.length; const isComplete = step >= STEP_KEYS.length;
const progress = isComplete ? 100 : ((step + 1) / STEPS.length) * 100; const progress = isComplete ? 100 : ((step + 1) / STEP_KEYS.length) * 100;
const select = (value: string) => { const select = (value: string) => {
if (!currentKey) return; if (!currentKey) return;
@ -152,11 +174,19 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
const { added, skipped } = mergeDestinationsIntoTrip(top3, 1); const { added, skipped } = mergeDestinationsIntoTrip(top3, 1);
onClose(); onClose();
if (added === 0) { if (added === 0) {
toast(skipped ? "这几座城已在计划中" : "未能加入计划", "info"); toast(skipped ? t.matcher.alreadyAll : t.matcher.addFail, "info", {
href: "/plan",
label: t.strip.openPlan,
});
router.push("/plan"); router.push("/plan");
return; 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"); router.push("/plan");
}; };
@ -164,30 +194,52 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const { added } = mergeDestinationsIntoTrip([dest], 1); 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 ( return (
<div className="modal-overlay open matcher-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}> <div
className="modal-overlay open matcher-overlay"
role="dialog"
aria-modal="true"
aria-label={t.matcher.tag.replace("{n}", "1").replace("{total}", String(STEP_KEYS.length))}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
{!hydrated ? (
<div className="modal matcher-modal">
<button className="modal-close" onClick={onClose} aria-label={t.matcher.close}>
✕
</button>
<p className="dest-empty">{t.common.loading}</p>
</div>
) : (
<div className="modal matcher-modal"> <div className="modal matcher-modal">
<button className="modal-close" onClick={onClose} aria-label="关闭">✕</button> <button className="modal-close" onClick={onClose} aria-label={t.matcher.close}>✕</button>
<div className="matcher-progress"> <div className="matcher-progress">
<div className="matcher-progress-bar" style={{ width: `${progress}%` }} /> <div className="matcher-progress-bar" style={{ width: `${progress}%` }} />
</div> </div>
<div className="matcher-body"> <div className="matcher-body">
{!isComplete ? ( {!isComplete && currentKey ? (
<div className={`matcher-step matcher-${direction}`} key={step}> <div className={`matcher-step matcher-${direction}`} key={step}>
<div className="matcher-step-meta"> <div className="matcher-step-meta">
<span className="section-tag">🎯 MATCHER · {step + 1}/{STEPS.length}</span> <span className="section-tag">
<h2>{STEPS[step].title}</h2> {t.matcher.tag
<p>{STEPS[step].subtitle}</p> .replace("{n}", String(step + 1))
.replace("{total}", String(STEP_KEYS.length))}
</span>
<h2>{steps[step].title}</h2>
<p>{steps[step].subtitle}</p>
</div> </div>
<div className="matcher-options"> <div className="matcher-options">
{OPTIONS[currentKey].map((opt) => ( {options[currentKey].map((opt) => (
<button <button
key={opt.value} key={opt.value}
className={`matcher-option${prefs[currentKey] === opt.value ? " selected" : ""}`} className={`matcher-option${prefs[currentKey] === opt.value ? " selected" : ""}`}
@ -205,81 +257,87 @@ export default function DestinationMatcher({ destinations, open, onClose }: Prop
))} ))}
</div> </div>
<button className="matcher-back" onClick={goBack}> <button className="matcher-back" onClick={goBack}>
{step === 0 ? "取消" : "← 上一步"} {step === 0 ? t.matcher.cancel : t.matcher.back}
</button> </button>
</div> </div>
) : ( ) : (
<div className="matcher-results matcher-forward"> <div className="matcher-results matcher-forward">
<div className="matcher-step-meta"> <div className="matcher-step-meta">
<span className="section-tag">✨ YOUR MATCHES</span> <span className="section-tag">{t.matcher.resultsTag}</span>
<h2>为你推荐的目的地</h2> <h2>{t.matcher.resultsTitle}</h2>
<p>偏好已保存。可一键写入旅居计划,或先对比再决定。</p> <p>{t.matcher.resultsSub}</p>
</div> </div>
{results.length === 0 ? ( {results.length === 0 ? (
<div className="notif-empty"> <div className="notif-empty">
<p className="dest-empty">暂无匹配结果,目的地数据可能未加载</p> <p className="dest-empty">{t.matcher.empty}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<button type="button" className="btn btn-ghost" onClick={reset}> <button type="button" className="btn btn-ghost" onClick={reset}>
重新测试 {t.matcher.retry}
</button> </button>
<Link href="/next-stop" className="btn btn-primary" onClick={onClose}> <Link href="/next-stop" className="btn btn-primary" onClick={onClose}>
去智能下一站 {t.matcher.goNextStop}
</Link> </Link>
</div> </div>
</div> </div>
) : ( ) : (
<> <>
<div className="matcher-result-list"> <div className="matcher-result-list">
{results.slice(0, 8).map(({ dest, score }, i) => ( {results.slice(0, 8).map(({ dest, score }, i) => (
<div key={dest.slug} className={`matcher-result-card${i === 0 ? " top" : ""}`}> <div key={dest.slug} className={`matcher-result-card${i === 0 ? " top" : ""}`}>
<Link href={`/destinations/${dest.slug}`} className="matcher-result-main" onClick={onClose}> <Link href={`/destinations/${dest.slug}`} className="matcher-result-main" onClick={onClose}>
<div className="matcher-result-rank"> <div className="matcher-result-rank">
{i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`} {i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `#${i + 1}`}
</div>
<span className="matcher-result-emoji">{dest.emoji}</span>
<div className="matcher-result-info">
<strong>{dest.name}</strong>
<span>{dest.country} · {dest.tag}</span>
</div>
<div className="matcher-result-score">
<div className="matcher-score-bar">
<div className="matcher-score-fill" style={{ width: `${Math.min((score / (results[0]?.score || 1)) * 100, 100)}%` }} />
</div>
<span>
{t.matcher.matchPct.replace(
"{n}",
String(Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100))
)}
</span>
</div>
<div className="matcher-result-tags">
<span>💰 ¥{dest.cost.toLocaleString()}</span>
<span>📶 {dest.speed}Mbps</span>
<span>⭐ {dest.rating}</span>
</div>
</Link>
<button type="button" className="matcher-add-btn" onClick={(e) => addOne(dest, e)}>
{t.matcher.addPlan}
</button>
</div> </div>
<span className="matcher-result-emoji">{dest.emoji}</span> ))}
<div className="matcher-result-info"> </div>
<strong>{dest.name}</strong> <div className="matcher-actions">
<span>{dest.country} · {dest.tag}</span> <button type="button" className="btn btn-ghost" onClick={reset}>{t.matcher.retryBtn}</button>
</div> {top3.length >= 2 && (
<div className="matcher-result-score"> <Link
<div className="matcher-score-bar"> href={compareUrl(top3.map((d) => d.slug))}
<div className="matcher-score-fill" style={{ width: `${Math.min((score / (results[0]?.score || 1)) * 100, 100)}%` }} /> className="btn btn-ghost"
</div> onClick={onClose}
<span>{Math.min(Math.round((score / (results[0]?.score || 1)) * 100), 100)}% 匹配</span> >
</div> {t.matcher.compareTop.replace("{n}", String(top3.length))}
<div className="matcher-result-tags"> </Link>
<span>💰 ¥{dest.cost.toLocaleString()}</span> )}
<span>📶 {dest.speed}Mbps</span> <button type="button" className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}>
<span>⭐ {dest.rating}</span> {t.matcher.writeTop.replace("{n}", String(Math.min(3, top3.length)))}
</div>
</Link>
<button type="button" className="matcher-add-btn" onClick={(e) => addOne(dest, e)}>
+ 计划
</button> </button>
</div> </div>
))}
</div>
<div className="matcher-actions">
<button type="button" className="btn btn-ghost" onClick={reset}>🔄 重新测试</button>
{top3.length >= 2 && (
<Link
href={compareUrl(top3.map((d) => d.slug))}
className="btn btn-ghost"
onClick={onClose}
>
⚖️ 对比 Top {top3.length}
</Link>
)}
<button type="button" className="btn btn-primary" onClick={buildPlanFromTop} disabled={top3.length === 0}>
把 Top {Math.min(3, top3.length)} 写入计划 →
</button>
</div>
</> </>
)} )}
</div> </div>
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
); );
} }

View File

@ -6,10 +6,13 @@ import { useI18n } from "@/lib/i18n";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { DigitalCourse } from "@/lib/types"; import type { DigitalCourse } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function DigitalCourseClient({ course }: { course: DigitalCourse }) { export default function DigitalCourseClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n(); const { t } = useI18n();
const { token } = useAuth(); const { token } = useAuth();
const rings = useRingSteps();
const [vip, setVip] = useState(false); const [vip, setVip] = useState(false);
useEffect(() => { useEffect(() => {
@ -30,17 +33,16 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
<span className="section-tag">{t.digital.courseTag}</span> <span className="section-tag">{t.digital.courseTag}</span>
<h1>{t.digital.course}</h1> <h1>{t.digital.course}</h1>
{vip ? ( {vip ? (
<p className="join-vip-badge">✨ VIP 已解锁付费课时</p> <p className="join-vip-badge">{t.digital.vipUnlocked}</p>
) : ( ) : (
<p className="section-desc"> <p className="section-desc">
免费课时可直接学 · VIP 课时需{" "} {t.digital.vipHint} · <Link href="/join">{t.nav.join}</Link>
<Link href="/join">开通会员</Link>
</p> </p>
)} )}
</div> </div>
{course.modules.length === 0 ? ( {course.modules.length === 0 ? (
<div className="notif-empty reveal"> <div className="notif-empty reveal">
<p className="dest-empty">课程内容准备中</p> <p className="dest-empty">{t.digital.courseEmpty}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<Link href="/digital" className="btn">{t.digital.back}</Link> <Link href="/digital" className="btn">{t.digital.back}</Link>
<Link href="/gigs" className="btn btn-ghost">{t.nav.gigs}</Link> <Link href="/gigs" className="btn btn-ghost">{t.nav.gigs}</Link>
@ -61,7 +63,7 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
<Link href="/join" className="digital-lesson-locked"> <Link href="/join" className="digital-lesson-locked">
<span>🔒 {lesson.title}</span> <span>🔒 {lesson.title}</span>
<span className="digital-lesson-meta"> <span className="digital-lesson-meta">
{lesson.duration} · VIP · 开通解锁 {lesson.duration} · VIP · {t.digital.unlockVip}
</span> </span>
</Link> </Link>
) : ( ) : (
@ -81,6 +83,7 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
))} ))}
</div> </div>
)} )}
<RingNext steps={rings.afterGigs} />
</div> </div>
</div> </div>
); );

View File

@ -4,9 +4,11 @@ import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { DigitalCourse } from "@/lib/types"; import type { DigitalCourse } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function DigitalHomeClient({ course }: { course: DigitalCourse }) { export default function DigitalHomeClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const lessonCount = course.modules.reduce((n, m) => n + m.lessons.length, 0); const lessonCount = course.modules.reduce((n, m) => n + m.lessons.length, 0);
return ( return (
@ -45,17 +47,12 @@ export default function DigitalHomeClient({ course }: { course: DigitalCourse })
</Link> </Link>
<Link href="/digital/day/1" className="digital-card"> <Link href="/digital/day/1" className="digital-card">
<span className="digital-card-emoji">📅</span> <span className="digital-card-emoji">📅</span>
<h3>每日指南</h3> <h3>{t.digital.dayGuide}</h3>
<p>从税务居民到落地验网,三天起步清单</p> <p>{t.digital.dayGuideDesc}</p>
</Link> </Link>
</div> </div>
<RingNext <RingNext steps={rings.afterDigital} />
steps={[
{ href: "/gigs", emoji: "💼", label: t.nav.gigs, desc: t.ring.communityWork },
{ href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityDesc },
]}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,14 +1,70 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import type { DigitalJob } from "@/lib/types"; import type { DigitalJob } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import {
clearSavedJobs,
loadSavedJobs,
toggleSavedJob,
type SavedJob,
} from "@/lib/savedJobs";
export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) { export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast();
const rings = useRingSteps(); const rings = useRingSteps();
const [saved, setSaved] = useState<SavedJob[]>([]);
const [q, setQ] = useState("");
const [tag, setTag] = useState("all");
useEffect(() => {
setSaved(loadSavedJobs());
}, []);
const tags = useMemo(() => {
const set = new Set<string>();
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 ( return (
<div className="digital-page"> <div className="digital-page">
@ -22,31 +78,111 @@ export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
<p>{t.digital.jobsSubtitle}</p> <p>{t.digital.jobsSubtitle}</p>
<Link href="/gigs" className="btn btn-sm">{t.nav.gigs}</Link> <Link href="/gigs" className="btn btn-sm">{t.nav.gigs}</Link>
</div> </div>
{saved.length > 0 && (
<section className="saved-jobs-panel reveal">
<div className="saved-jobs-head">
<h2>
{t.digital.jobsSavedList} · {saved.length}
</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
clearSavedJobs();
setSaved([]);
}}
>
{t.digital.jobsClearSaved}
</button>
</div>
<div className="saved-jobs-list">
{saved.map((j) => (
<a key={j.id} href={j.url} target="_blank" rel="noopener noreferrer" className="saved-job-chip">
<strong>{j.title}</strong>
<small>
{j.company} · {j.location}
</small>
</a>
))}
</div>
</section>
)}
{jobs.length > 0 && (
<div className="jobs-toolbar reveal">
<input
className="tools-hub-search"
placeholder={t.digital.jobsSearch}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="tools-hub-filters">
<button
type="button"
className={`filter-btn${tag === "all" ? " active" : ""}`}
onClick={() => setTag("all")}
>
{t.digital.jobsFilterAll}
</button>
{tags.map((x) => (
<button
key={x}
type="button"
className={`filter-btn${tag === x ? " active" : ""}`}
onClick={() => setTag(x)}
>
{x}
</button>
))}
</div>
</div>
)}
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<div className="notif-empty reveal"> <div className="notif-empty reveal">
<p className="dest-empty">{t.digital.jobsEmpty}</p> <p className="dest-empty">{t.digital.jobsEmpty}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<Link href="/gigs" className="btn btn-primary btn-sm">去赏金任务</Link> <Link href="/gigs" className="btn btn-primary btn-sm">{t.digital.jobsGoGigs}</Link>
<Link href="/community" className="btn btn-sm">社区求助</Link> <Link href="/community" className="btn btn-sm">{t.digital.jobsGoCommunity}</Link>
</div> </div>
</div> </div>
) : filtered.length === 0 ? (
<div className="notif-empty reveal">
<p className="dest-empty">{t.digital.jobsEmpty}</p>
<button type="button" className="btn btn-sm" onClick={() => { setQ(""); setTag("all"); }}>
{t.common.clear}
</button>
</div>
) : ( ) : (
<div className="digital-jobs-grid reveal"> <div className="digital-jobs-grid reveal">
{jobs.map((job) => ( {filtered.map((job) => {
<article key={job.id} className="digital-job-card"> const isSaved = saved.some((s) => s.id === job.id);
<h3>{job.title}</h3> return (
<p className="digital-job-company">{job.company} · {job.location}</p> <article key={job.id} className="digital-job-card">
<p className="digital-job-meta">{job.type} · {job.salary}</p> <div className="gig-card-top">
<div className="meetup-tags"> <h3>{job.title}</h3>
{job.tags.map((tag) => ( <button
<span key={tag} className="meetup-tag">{tag}</span> type="button"
))} className={`btn btn-sm${isSaved ? " btn-primary" : " btn-ghost"}`}
</div> onClick={() => onToggle(job)}
<a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm"> >
{t.digital.apply} {isSaved ? `★ ${t.digital.jobsSaved}` : `☆ ${t.digital.jobsSave}`}
</a> </button>
</article> </div>
))} <p className="digital-job-company">{job.company} · {job.location}</p>
<p className="digital-job-meta">{job.type} · {job.salary}</p>
<div className="meetup-tags">
{job.tags.map((item) => (
<span key={item} className="meetup-tag">{item}</span>
))}
</div>
<a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
{t.digital.apply}
</a>
</article>
);
})}
</div> </div>
)} )}
<RingNext steps={rings.afterGigs} /> <RingNext steps={rings.afterGigs} />

View File

@ -6,6 +6,8 @@ import { useI18n } from "@/lib/i18n";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { DigitalCourse, DigitalLesson } from "@/lib/types"; import type { DigitalCourse, DigitalLesson } from "@/lib/types";
import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings";
export default function DigitalLessonClient({ export default function DigitalLessonClient({
moduleIndex, moduleIndex,
@ -16,6 +18,7 @@ export default function DigitalLessonClient({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { token } = useAuth(); const { token } = useAuth();
const rings = useRingSteps();
const [lesson, setLesson] = useState<DigitalLesson | null>(null); const [lesson, setLesson] = useState<DigitalLesson | null>(null);
const [locked, setLocked] = useState(false); const [locked, setLocked] = useState(false);
const [missing, setMissing] = useState(false); const [missing, setMissing] = useState(false);
@ -73,7 +76,7 @@ export default function DigitalLessonClient({
return ( return (
<div className="digital-page"> <div className="digital-page">
<div className="container"> <div className="container">
<p className="dest-empty">加载课时…</p> <p className="dest-empty">{t.digital.loadingLesson}</p>
</div> </div>
</div> </div>
); );
@ -84,7 +87,7 @@ export default function DigitalLessonClient({
<div className="digital-page"> <div className="digital-page">
<div className="container"> <div className="container">
<div className="digital-gate reveal"> <div className="digital-gate reveal">
<h2>课时不存在</h2> <h2>{t.digital.missingLesson}</h2>
<Link href="/digital/course" className="btn btn-primary">{t.digital.backCourse}</Link> <Link href="/digital/course" className="btn btn-primary">{t.digital.backCourse}</Link>
</div> </div>
</div> </div>
@ -109,6 +112,7 @@ export default function DigitalLessonClient({
<Link href="/digital/course" className="btn">{t.digital.backCourse}</Link> <Link href="/digital/course" className="btn">{t.digital.backCourse}</Link>
</div> </div>
</div> </div>
<RingNext steps={rings.afterGigs} />
</div> </div>
</div> </div>
); );
@ -142,12 +146,21 @@ export default function DigitalLessonClient({
{t.digital.next} → {t.digital.next} →
</Link> </Link>
) : ( ) : (
<Link href="/digital/course" className="btn btn-primary"> <div className="dating-empty-actions">
返回课程目录 <Link href="/digital/course" className="btn btn-primary">
</Link> {t.digital.backCatalog}
</Link>
<Link href="/gigs" className="btn">
{t.nav.gigs}
</Link>
<Link href="/meetups" className="btn btn-ghost">
{t.nav.meetups}
</Link>
</div>
)} )}
</footer> </footer>
</article> </article>
<RingNext steps={next ? rings.afterGigs : rings.afterDigital} />
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -9,6 +9,8 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
import { inferMeetupCity, meetupCityHref } from "@/lib/meetupLinks";
export default function DiscussionDetailClient({ export default function DiscussionDetailClient({
discussionId, discussionId,
@ -26,6 +28,49 @@ export default function DiscussionDetailClient({
const [reply, setReply] = useState(""); const [reply, setReply] = useState("");
const [likes, setLikes] = useState(discussion.like_count); const [likes, setLikes] = useState(discussion.like_count);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [draftReady, setDraftReady] = useState(false);
const [cityNames, setCityNames] = useState<string[]>([]);
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 refresh = async () => {
const d = await api.getDiscussion(discussionId); const d = await api.getDiscussion(discussionId);
@ -39,15 +84,19 @@ export default function DiscussionDetailClient({
return; return;
} }
if (!reply.trim()) { if (!reply.trim()) {
toast("请先写一点回复内容", "info"); toast(t.community.replyNeed, "info");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
await api.postDiscussionReply(token, discussionId, reply.trim()); await api.postDiscussionReply(token, discussionId, reply.trim());
setReply(""); setReply("");
clearDraft(draftKey);
await refresh(); await refresh();
toast(t.community.replyOk, "success"); toast(t.community.replyOk, "success", {
href: meetupsHref,
label: inferredCity ? t.common.cityMeetups : t.dating.meetAtEvents,
});
} catch { } catch {
toast(t.community.replyFail, "error"); toast(t.community.replyFail, "error");
} finally { } finally {
@ -68,13 +117,15 @@ export default function DiscussionDetailClient({
} }
}; };
const body = discussion.excerpt || "";
return ( return (
<div className="community-detail-page"> <div className="community-detail-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/community">← {t.community.back}</Link> <Link href="/community">← {t.community.back}</Link>
<span> · </span>
<Link href={meetupsHref}>
{inferredCity ? `${t.common.cityMeetups} · ${inferredCity}` : t.nav.meetups}
</Link>
</nav> </nav>
<article className="community-detail reveal"> <article className="community-detail reveal">
@ -105,7 +156,7 @@ export default function DiscussionDetailClient({
{t.community.replies} ({discussion.replies.length}) {t.community.replies} ({discussion.replies.length})
</h2> </h2>
{discussion.replies.length === 0 && ( {discussion.replies.length === 0 && (
<p className="dest-empty">还没有回复,来做第一个吧</p> <p className="dest-empty">{t.community.noRepliesYet}</p>
)} )}
{discussion.replies.map((r) => ( {discussion.replies.map((r) => (
<div key={r.id} className="community-reply"> <div key={r.id} className="community-reply">
@ -129,20 +180,25 @@ export default function DiscussionDetailClient({
placeholder={t.community.replyPlaceholder} placeholder={t.community.replyPlaceholder}
maxLength={2000} maxLength={2000}
/> />
<button type="button" className="btn btn-primary" disabled={loading} onClick={submitReply}> <button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submitReply()}>
{loading ? "发送中…" : t.community.replySend} {loading ? t.community.replySending : t.community.replySend}
</button> </button>
</div> </div>
) : ( ) : (
<div className="community-reply-hint reveal"> <div className="community-reply-hint reveal">
<p>{t.community.replyHint}</p> <p>{t.community.replyHint}</p>
<Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary"> <div className="dating-empty-actions">
{t.nav.login} <Link href={`/login?next=/community/${discussionId}`} className="btn btn-primary">
</Link> {t.nav.login}
</Link>
<Link href={meetupsHref} className="btn btn-ghost">
{t.dating.meetAtEvents}
</Link>
</div>
</div> </div>
)} )}
<RingNext steps={rings.afterCommunity} /> <RingNext steps={rings.afterCommunity(inferredCity)} />
</div> </div>
</div> </div>
); );

View File

@ -10,7 +10,7 @@ interface Props {
} }
export default function FAQSection({ faqs, limit }: Props) { export default function FAQSection({ faqs, limit }: Props) {
const { t, locale } = useI18n(); const { t } = useI18n();
const [active, setActive] = useState<string | null>(null); const [active, setActive] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [showAll, setShowAll] = useState(!limit); const [showAll, setShowAll] = useState(!limit);
@ -64,16 +64,16 @@ export default function FAQSection({ faqs, limit }: Props) {
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="notif-empty reveal"> <div className="notif-empty reveal">
<p className="dest-empty">{locale === "en" ? "No FAQ matched" : "没有匹配的问题"}</p> <p className="dest-empty">{t.faqUi.empty}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<button type="button" className="btn btn-sm" onClick={() => setSearch("")}> <button type="button" className="btn btn-sm" onClick={() => setSearch("")}>
{locale === "en" ? "Clear search" : "清除搜索"} {t.faqUi.clear}
</button> </button>
<a href="/help" className="btn btn-sm btn-ghost"> <a href="/help" className="btn btn-sm btn-ghost">
{locale === "en" ? "Help center" : "帮助中心"} {t.faqUi.help}
</a> </a>
<a href="/ai" className="btn btn-sm btn-ghost"> <a href="/ai" className="btn btn-sm btn-ghost">
{locale === "en" ? "Ask AI" : "问 AI"} {t.faqUi.askAi}
</a> </a>
</div> </div>
</div> </div>
@ -82,18 +82,18 @@ export default function FAQSection({ faqs, limit }: Props) {
{limit && !showAll && sorted.length > limit && ( {limit && !showAll && sorted.length > limit && (
<div className="dest-expand-wrap reveal"> <div className="dest-expand-wrap reveal">
<button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}> <button type="button" className="btn btn-ghost" onClick={() => setShowAll(true)}>
{locale === "en" ? `Show all ${sorted.length} questions` : `展开全部 ${sorted.length} 个问题`} {t.faqUi.showAll.replace("{n}", String(sorted.length))}
</button> </button>
</div> </div>
)} )}
<p className="faq-plan-hint reveal"> <p className="faq-plan-hint reveal">
{locale === "en" ? "Still exploring? " : "还在选城?"} {t.faqUi.still}
<a href="#destinations">{locale === "en" ? "Browse destinations" : "继续看目的地"}</a> <a href="#destinations">{t.faqUi.browse}</a>
{" · "} {" · "}
<a href="/help">{locale === "en" ? "Help center" : "帮助中心"}</a> <a href="/help">{t.faqUi.help}</a>
{" · "} {" · "}
<a href="/ai">{locale === "en" ? "Ask AI" : "问 AI"}</a> <a href="/ai">{t.faqUi.askAi}</a>
</p> </p>
</div> </div>
</section> </section>

View File

@ -4,9 +4,13 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { loginNextFrom } from "@/lib/authNext"; import { loginNextFrom } from "@/lib/authNext";
import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
export default function FavoriteButton({ slug }: { slug: string }) { export default function FavoriteButton({ slug }: { slug: string }) {
const { user, isFavorite, toggleFavorite } = useAuth(); const { user, isFavorite, toggleFavorite } = useAuth();
const { t } = useI18n();
const { toast } = useToast();
const pathname = usePathname(); const pathname = usePathname();
const next = loginNextFrom(pathname || `/destinations/${slug}`); const next = loginNextFrom(pathname || `/destinations/${slug}`);
@ -17,7 +21,7 @@ export default function FavoriteButton({ slug }: { slug: string }) {
className="btn btn-ghost" className="btn btn-ghost"
style={{ fontSize: "0.85rem" }} style={{ fontSize: "0.85rem" }}
> >
🤍 登录收藏 {t.common.loginFavorite}
</Link> </Link>
); );
} }
@ -27,11 +31,21 @@ export default function FavoriteButton({ slug }: { slug: string }) {
<button <button
type="button" type="button"
className={`btn ${fav ? "btn-primary" : "btn-ghost"}`} className={`btn ${fav ? "btn-primary" : "btn-ghost"}`}
onClick={() => 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" }} style={{ fontSize: "0.85rem" }}
aria-pressed={fav} aria-pressed={fav}
> >
{fav ? "❤️ 已收藏" : "🤍 收藏"} {fav ? t.common.favorited : t.common.favorite}
</button> </button>
); );
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
@ -8,6 +8,11 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; 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() { export default function FeedbackClient() {
const { token } = useAuth(); const { token } = useAuth();
@ -18,16 +23,46 @@ export default function FeedbackClient() {
const [category, setCategory] = useState("general"); const [category, setCategory] = useState("general");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const draft = loadDraft<FeedbackDraft>(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 () => { const submit = async () => {
if (!content.trim() || content.trim().length < 5) { if (!content.trim() || content.trim().length < 5) {
toast("请至少写 5 个字", "info"); toast(t.feedback.minLength, "info");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
await api.submitFeedback(content.trim(), token || undefined, category); 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(""); setContent("");
setDone(true); setDone(true);
} catch { } catch {
@ -50,19 +85,20 @@ export default function FeedbackClient() {
</div> </div>
{done ? ( {done ? (
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>已收到,谢谢</h2> <h2>{t.feedback.thanksTitle}</h2>
<p>我们会认真阅读每一条反馈</p> <p>{t.feedback.thanksDesc}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<button type="button" className="btn btn-primary" onClick={() => setDone(false)}> <button type="button" className="btn btn-primary" onClick={() => setDone(false)}>
再写一条 {t.feedback.writeAgain}
</button> </button>
<Link href="/community" className="btn"> <Link href="/community" className="btn">
去社区 {t.feedback.goCommunity}
</Link> </Link>
</div> </div>
</div> </div>
) : ( ) : (
<div className="community-compose reveal"> <div className="community-compose reveal">
<p className="section-desc">{t.feedback.draftHint}</p>
<select value={category} onChange={(e) => setCategory(e.target.value)}> <select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="general">{t.feedback.catGeneral}</option> <option value="general">{t.feedback.catGeneral}</option>
<option value="bug">{t.feedback.catBug}</option> <option value="bug">{t.feedback.catBug}</option>
@ -76,13 +112,13 @@ export default function FeedbackClient() {
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
maxLength={2000} maxLength={2000}
/> />
<button type="button" className="btn btn-primary" disabled={loading} onClick={submit}> <button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submit()}>
{loading ? "提交中…" : t.feedback.submit} {loading ? t.feedback.submitting : t.feedback.submit}
</button> </button>
{!token && <p className="section-desc">未登录也可提交;登录后便于我们回复你</p>} {!token && <p className="section-desc">{t.feedback.guestHint}</p>}
</div> </div>
)} )}
<RingNext steps={rings.afterGigs} /> <RingNext steps={rings.afterDiscover} />
</div> </div>
</div> </div>
); );

View File

@ -1,35 +1,71 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import { clearDraft, loadDraft, saveDraft } from "@/lib/localDraft";
const TYPES = [ const DRAFT_KEY = "nomadro-feedback-draft";
{ key: "feature", label: "💡 想法" },
{ key: "bug", label: "🐛 问题" },
{ key: "general", label: "❤️ 喜欢" },
];
export default function FeedbackWidget() { export default function FeedbackWidget() {
const { token } = useAuth(); const { token } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [type, setType] = useState("feature"); const [type, setType] = useState("feature");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [sending, setSending] = useState(false); 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) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!msg.trim() || msg.trim().length < 5) { if (!msg.trim() || msg.trim().length < 5) {
toast("请至少写 5 个字", "info"); toast(t.feedback.minLength, "info");
return; return;
} }
setSending(true); setSending(true);
try { try {
await api.submitFeedback(msg.trim(), token || undefined, type); await api.submitFeedback(msg.trim(), token || undefined, type);
// Keep a local copy for the user as well
try { try {
const key = "nomadro-feedback"; const key = "nomadro-feedback";
const prev = JSON.parse(localStorage.getItem(key) || "[]"); const prev = JSON.parse(localStorage.getItem(key) || "[]");
@ -38,11 +74,15 @@ export default function FeedbackWidget() {
} catch { } catch {
/* ignore */ /* ignore */
} }
toast("感谢反馈!我们会认真看的 🙏"); clearDraft(DRAFT_KEY);
toast(t.feedback.ok, "success", {
href: "/community",
label: t.feedback.goCommunity,
});
setMsg(""); setMsg("");
setOpen(false); setOpen(false);
} catch { } catch {
toast("发送失败,请稍后重试或去 /feedback", "error"); toast(t.feedback.failRetry, "error");
} finally { } finally {
setSending(false); setSending(false);
} }
@ -53,39 +93,45 @@ export default function FeedbackWidget() {
<button <button
className="feedback-fab" className="feedback-fab"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="反馈" aria-label={t.feedback.fabAria}
title="反馈建议" title={t.feedback.fabTitle}
> >
💬 💬
</button> </button>
{open && ( {open && (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && setOpen(false)}> <div
className="modal-overlay open"
role="dialog"
aria-modal="true"
aria-label={t.feedback.widgetTitle}
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
>
<div className="modal feedback-modal"> <div className="modal feedback-modal">
<button className="modal-close" onClick={() => setOpen(false)} aria-label="关闭"> <button className="modal-close" onClick={() => setOpen(false)} aria-label={t.feedback.close}>
✕ ✕
</button> </button>
<div className="feedback-body"> <div className="feedback-body">
<span className="section-tag">💬 FEEDBACK</span> <span className="section-tag">{t.feedback.tag}</span>
<h2>给 nomadro 提建议</h2> <h2>{t.feedback.widgetTitle}</h2>
<p>功能想法、体验问题,或只是想说喜欢</p> <p>{t.feedback.widgetSubtitle}</p>
<form onSubmit={submit}> <form onSubmit={(e) => void submit(e)}>
<div className="feedback-types"> <div className="feedback-types">
{TYPES.map((t) => ( {types.map((item) => (
<button <button
key={t.key} key={item.key}
type="button" type="button"
className={`filter-btn${type === t.key ? " active" : ""}`} className={`filter-btn${type === item.key ? " active" : ""}`}
onClick={() => setType(t.key)} onClick={() => setType(item.key)}
> >
{t.label} {item.label}
</button> </button>
))} ))}
</div> </div>
<textarea <textarea
className="feedback-textarea" className="feedback-textarea"
rows={4} rows={4}
placeholder="写下你的想法…" placeholder={t.feedback.placeholder}
value={msg} value={msg}
onChange={(e) => setMsg(e.target.value)} onChange={(e) => setMsg(e.target.value)}
required required
@ -97,11 +143,11 @@ export default function FeedbackWidget() {
disabled={sending || !msg.trim()} disabled={sending || !msg.trim()}
style={{ width: "100%" }} style={{ width: "100%" }}
> >
{sending ? "发送中…" : "发送反馈 🚀"} {sending ? t.feedback.submitting : t.feedback.sendBtn}
</button> </button>
<p className="feedback-more-link"> <p className="feedback-more-link">
<Link href="/feedback" onClick={() => setOpen(false)}> <Link href="/feedback" onClick={() => setOpen(false)}>
打开完整反馈页 {t.feedback.openFull}
</Link> </Link>
</p> </p>
</form> </form>

View File

@ -1,7 +1,12 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types"; 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. */ /** First-month one-time + rent estimates in CNY. */
const LANDING: Record<string, { rent: number; depositMult: number; sim: number; transfer: number; misc: number }> = { const LANDING: Record<string, { rent: number; depositMult: number; sim: number; transfer: number; misc: number }> = {
@ -18,6 +23,8 @@ interface Props {
} }
export default function FirstMonthCost({ destinations }: Props) { export default function FirstMonthCost({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai"); const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
const [housing, setHousing] = useState<"budget" | "mid" | "nice">("mid"); const [housing, setHousing] = useState<"budget" | "mid" | "nice">("mid");
const [includeDeposit, setIncludeDeposit] = useState(true); 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 total = breakdown.find((b) => b.key === "total")!.amount;
const maxBar = Math.max(...breakdown.filter((b) => b.key !== "total").map((b) => b.amount), 1); 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 ( return (
<section className="section firstmonth-section" id="first-month"> <section className="section firstmonth-section" id="first-month">
<div className="container"> <div className="container">
@ -108,6 +125,19 @@ export default function FirstMonthCost({ destinations }: Props) {
</div> </div>
))} ))}
</div> </div>
{dest && (
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
🗓️ {t.nav.plan}
</button>
<Link href={`/destinations/${dest.slug}`} className="btn btn-sm">
{t.common.detail}
</Link>
<Link href={meetupCityHref(dest.name)} className="btn btn-sm btn-ghost">
{t.common.cityMeetups}
</Link>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
/** 粗略往返机票估算(CNY),基于城市对距离感 */ /** 粗略往返机票估算(CNY),基于城市对距离感 */
const FLIGHT_BASE: Record<string, number> = { const FLIGHT_BASE: Record<string, number> = {
@ -120,6 +121,7 @@ export default function FlightCost({ destinations }: Props) {
</div> </div>
</div> </div>
<p className="flight-note">* 估算仅供参考,实际票价随航司与预订时间波动较大</p> <p className="flight-note">* 估算仅供参考,实际票价随航司与预订时间波动较大</p>
{dest && <ToolCityExits dest={dest} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -27,7 +27,7 @@ function BrandLogo() {
/** Footer mirrors the three rings — essentials only, not a second sitemap. */ /** Footer mirrors the three rings — essentials only, not a second sitemap. */
export default function Footer() { export default function Footer() {
const { t, locale } = useI18n(); const { t } = useI18n();
const pathname = usePathname(); const pathname = usePathname();
const isHome = pathname === "/"; const isHome = pathname === "/";
const loginNext = loginNextFrom(pathname); const loginNext = loginNextFrom(pathname);
@ -38,9 +38,7 @@ export default function Footer() {
<div className="footer-brand"> <div className="footer-brand">
<BrandLogo /> <BrandLogo />
<p> <p>
{locale === "en" {t.footer.tagline}
? "Work and live freely anywhere on this planet 🌏"
: "让每个人都能自由地工作和生活在这个星球上 🌏"}
</p> </p>
</div> </div>
<div className="footer-links"> <div className="footer-links">

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -9,6 +9,11 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; 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() { export default function GigPostClient() {
const { token } = useAuth(); const { token } = useAuth();
@ -21,6 +26,30 @@ export default function GigPostClient() {
const [budget, setBudget] = useState(""); const [budget, setBudget] = useState("");
const [deadline, setDeadline] = useState(""); const [deadline, setDeadline] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const draft = loadDraft<GigDraft>(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) { if (!token) {
return ( return (
@ -43,7 +72,7 @@ export default function GigPostClient() {
return; return;
} }
if (!title.trim() || !description.trim()) { if (!title.trim() || !description.trim()) {
toast("请填写标题和描述", "info"); toast(t.gigs.postNeed, "info");
return; return;
} }
setBusy(true); setBusy(true);
@ -51,10 +80,14 @@ export default function GigPostClient() {
await api.createGig(token, { await api.createGig(token, {
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
budget: budget.trim() || "面议", budget: budget.trim() || t.gigs.budgetNegotiable,
deadline: deadline || "", deadline: deadline || "",
}); });
toast(t.gigs.postOk, "success"); clearDraft(DRAFT_KEY);
toast(t.gigs.postOk, "success", {
href: "/digital",
label: t.nav.digital,
});
router.push("/gigs"); router.push("/gigs");
} catch { } catch {
toast(t.gigs.postFail, "error"); toast(t.gigs.postFail, "error");
@ -70,14 +103,15 @@ export default function GigPostClient() {
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.gigs.tag}</span> <span className="section-tag">{t.gigs.tag}</span>
<h1>{t.gigs.postTitle}</h1> <h1>{t.gigs.postTitle}</h1>
<p className="section-desc">{t.gigs.draftHint}</p>
</div> </div>
<div className="community-compose reveal"> <div className="community-compose reveal">
<input placeholder={t.gigs.postName} value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} /> <input placeholder={t.gigs.postName} value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} />
<textarea rows={5} placeholder={t.gigs.postDesc} value={description} onChange={(e) => setDescription(e.target.value)} maxLength={2000} /> <textarea rows={5} placeholder={t.gigs.postDesc} value={description} onChange={(e) => setDescription(e.target.value)} maxLength={2000} />
<input placeholder={t.gigs.postBudget} value={budget} onChange={(e) => setBudget(e.target.value)} /> <input placeholder={t.gigs.postBudget} value={budget} onChange={(e) => setBudget(e.target.value)} />
<input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} /> <input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} />
<button type="button" className="btn btn-primary" disabled={busy} onClick={submit}> <button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>
{busy ? "发布中…" : t.gigs.postSubmit} {busy ? t.gigs.posting : t.gigs.postSubmit}
</button> </button>
</div> </div>
<RingNext steps={rings.afterGigs} /> <RingNext steps={rings.afterGigs} />

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -10,6 +10,15 @@ import { useI18n } from "@/lib/i18n";
import type { GigItem } from "@/lib/types"; import type { GigItem } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import {
clearApplyDraft,
clearSavedGigs,
loadApplyDrafts,
loadSavedGigs,
saveApplyDraft,
toggleSavedGig,
type SavedGig,
} from "@/lib/savedGigs";
export default function GigsClient({ gigs }: { gigs: GigItem[] }) { export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
const { token } = useAuth(); const { token } = useAuth();
@ -20,6 +29,24 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
const [msg, setMsg] = useState<Record<string, string>>({}); const [msg, setMsg] = useState<Record<string, string>>({});
const [applied, setApplied] = useState<Record<string, boolean>>({}); const [applied, setApplied] = useState<Record<string, boolean>>({});
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [saved, setSaved] = useState<SavedGig[]>([]);
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) => { const apply = async (id: string) => {
if (!token) { if (!token) {
@ -28,14 +55,23 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
} }
const message = msg[id]?.trim(); const message = msg[id]?.trim();
if (!message) { if (!message) {
toast("请先填写申请说明", "info"); toast(t.gigs.applyNeed, "info");
return; return;
} }
setBusyId(id); setBusyId(id);
try { try {
await api.applyGig(token, id, message); await api.applyGig(token, id, message);
setApplied((a) => ({ ...a, [id]: true })); 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 { } catch {
toast(t.gigs.applyFail, "error"); toast(t.gigs.applyFail, "error");
} finally { } finally {
@ -53,10 +89,41 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
<p>{t.gigs.subtitle}</p> <p>{t.gigs.subtitle}</p>
<Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link> <Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link>
</div> </div>
{saved.length > 0 && (
<section className="saved-gigs-panel reveal">
<div className="saved-gigs-head">
<h2>
{t.gigs.savedList} · {saved.length}
</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
clearSavedGigs();
setSaved([]);
}}
>
{t.gigs.clearSaved}
</button>
</div>
<div className="saved-gigs-list">
{saved.map((g) => (
<a key={g.id} href={`#gig-${g.id}`} className="saved-gig-chip">
<strong>{g.title}</strong>
<small>
{g.budget} · {g.deadline}
</small>
</a>
))}
</div>
</section>
)}
{gigs.length === 0 ? ( {gigs.length === 0 ? (
<div className="gigs-empty reveal"> <div className="gigs-empty reveal">
<p className="dest-empty">暂时没有开放任务</p> <p className="dest-empty">{t.gigs.empty}</p>
<p className="section-desc">你可以发布一条赏金,或先去学院看看远程岗位</p> <p className="section-desc">{t.gigs.emptyHint}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link> <Link href="/gigs/post" className="btn btn-primary btn-sm">{t.gigs.postTitle}</Link>
<Link href="/digital" className="btn btn-sm">{t.nav.digital}</Link> <Link href="/digital" className="btn btn-sm">{t.nav.digital}</Link>
@ -69,37 +136,53 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
<Link href="/login?next=/gigs">{t.nav.login}</Link> · {t.gigs.loginFirst} <Link href="/login?next=/gigs">{t.nav.login}</Link> · {t.gigs.loginFirst}
</p> </p>
)} )}
{gigs.map((g) => ( {gigs.map((g) => {
<article key={g.id} className="digital-job-card"> const isSaved = saved.some((s) => s.id === g.id);
<h3>{g.title}</h3> return (
<p>{g.description}</p> <article key={g.id} id={`gig-${g.id}`} className="digital-job-card">
<p className="digital-job-meta">{g.poster} · {g.budget} · {g.deadline}</p> <div className="gig-card-top">
{applied[g.id] ? ( <h3>{g.title}</h3>
<p className="gigs-applied">✓ 已申请</p>
) : !token ? (
<Link href="/login?next=/gigs" className="btn btn-primary btn-sm">
{t.gigs.apply}
</Link>
) : (
<>
<textarea
rows={2}
placeholder={t.gigs.applyPlaceholder}
value={msg[g.id] || ""}
onChange={(e) => setMsg({ ...msg, [g.id]: e.target.value })}
/>
<button <button
type="button" type="button"
className="btn btn-primary btn-sm" className={`btn btn-sm${isSaved ? " btn-primary" : " btn-ghost"}`}
disabled={busyId === g.id} onClick={() => onToggleSave(g)}
onClick={() => apply(g.id)}
> >
{busyId === g.id ? "提交中…" : t.gigs.apply} {isSaved ? `★ ${t.gigs.saved}` : `☆ ${t.gigs.save}`}
</button> </button>
</> </div>
)} <p>{g.description}</p>
</article> <p className="digital-job-meta">{g.poster} · {g.budget} · {g.deadline}</p>
))} {applied[g.id] ? (
<p className="gigs-applied">{t.gigs.applied}</p>
) : !token ? (
<Link href="/login?next=/gigs" className="btn btn-primary btn-sm">
{t.gigs.apply}
</Link>
) : (
<>
<textarea
rows={2}
placeholder={t.gigs.applyPlaceholder}
value={msg[g.id] || ""}
onChange={(e) => {
const value = e.target.value;
setMsg({ ...msg, [g.id]: value });
saveApplyDraft(g.id, value);
}}
/>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busyId === g.id}
onClick={() => void apply(g.id)}
>
{busyId === g.id ? t.gigs.applying : t.gigs.apply}
</button>
</>
)}
</article>
);
})}
</div> </div>
)} )}
<RingNext steps={rings.afterGigs} /> <RingNext steps={rings.afterGigs} />

View File

@ -1,66 +1,76 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, useMemo } from "react";
import { useRouter, usePathname } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { TOOL_LINKS } from "@/lib/tools"; import { loadRecentDestinations } from "@/lib/recentDestinations";
import type { SearchResult } from "@/lib/types"; 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<string, string> = { function typeLabels(s: Dict["search"]): Record<string, string> {
destination: "目的地", return {
blog: "博客", destination: s.typeDestination,
visa: "签证", blog: s.typeBlog,
faq: "FAQ", visa: s.typeVisa,
tool: "工具", faq: s.typeFaq,
page: "页面", tool: s.typeTool,
}; page: s.typePage,
};
}
/** Empty-state shortcuts — three rings only, not the whole sitemap. */ function buildPages(s: Dict["search"]): { ring: SearchResult[]; core: SearchResult[]; extra: SearchResult[] } {
const RING_SHORTCUTS: SearchResult[] = [ const ring: SearchResult[] = [
{ type: "page", title: "智能发现", subtitle: "匹配偏好,锁定下一座城市", emoji: "🎯", url: "/#path" }, { type: "page", title: s.ringDiscover, subtitle: s.ringDiscoverSub, emoji: "🎯", url: "/#path" },
{ type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" }, { type: "page", title: s.ringNext, subtitle: s.ringNextSub, emoji: "🧭", url: "/next-stop" },
{ type: "page", title: "游民活动", subtitle: "先遇见人,再深入聊", emoji: "🎉", url: "/meetups" }, { type: "page", title: s.ringMeetups, subtitle: s.ringMeetupsSub, emoji: "🎉", url: "/meetups" },
{ type: "page", title: "社区讨论", subtitle: "签证远程住宿经验", emoji: "💬", url: "/community" }, { type: "page", title: s.ringCommunity, subtitle: s.ringCommunitySub, emoji: "💬", url: "/community" },
{ type: "page", title: "游民学院", subtitle: "课程与远程路径", emoji: "🎓", url: "/digital" }, { type: "page", title: s.ringDigital, subtitle: s.ringDigitalSub, emoji: "🎓", url: "/digital" },
{ type: "page", title: "实用工具", subtitle: "按需取用,不堆菜单", emoji: "🛠️", url: "/tools" }, { 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[] = [ function searchLocal(
{ type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" }, q: string,
{ type: "page", title: "城市对比台", subtitle: "并排对比费用网速气候,写入计划", emoji: "⚖️", url: "/compare" }, hidePlanCompare: boolean,
{ type: "page", title: "下一站决策", subtitle: "预算网速气候智能推荐", emoji: "🧭", url: "/next-stop" }, core: SearchResult[],
{ type: "page", title: "游民活动", subtitle: "线上圆桌与线下聚会", emoji: "🎉", url: "/meetups" }, extra: SearchResult[],
{ type: "page", title: "社区讨论", subtitle: "签证远程住宿经验交流", emoji: "💬", url: "/community" }, toolLinks: ReturnType<typeof localizedToolLinks>
{ type: "page", title: "游民匹配", subtitle: "滑动匹配同路游民", emoji: "💕", url: "/dating" }, ): SearchResult[] {
{ type: "page", title: "私信", subtitle: "与匹配成功的游民聊天", emoji: "✉️", url: "/chat" },
{ type: "page", title: "游民学院", subtitle: "课程电子书与远程岗位", emoji: "🎓", url: "/digital" },
{ type: "page", title: "赏金任务", subtitle: "远程小任务接单", emoji: "💼", url: "/gigs" },
{ type: "page", title: "工具箱", subtitle: "旅居小工具按需取用", emoji: "🛠️", url: "/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[] {
const s = q.toLowerCase(); 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) => t.title.toLowerCase().includes(s) || t.desc.toLowerCase().includes(s) || t.id.includes(s))
.filter((t) => !hidePlanCompare || (t.href !== "/plan" && t.href !== "/compare")) .filter((t) => !hidePlanCompare || (t.href !== "/plan" && t.href !== "/compare"))
.slice(0, 8) .slice(0, 8)
@ -75,7 +85,7 @@ function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
const matchPage = (p: SearchResult) => const matchPage = (p: SearchResult) =>
p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s); p.title.toLowerCase().includes(s) || p.subtitle.toLowerCase().includes(s);
const pages = [...CORE_PAGES, ...EXTRA_PAGES] const pages = [...core, ...extra]
.filter(matchPage) .filter(matchPage)
.filter((p) => !hidePlanCompare || (p.url !== "/plan" && p.url !== "/compare")); .filter((p) => !hidePlanCompare || (p.url !== "/plan" && p.url !== "/compare"));
@ -83,14 +93,33 @@ function searchLocal(q: string, hidePlanCompare: boolean): SearchResult[] {
} }
export default function GlobalSearch() { export default function GlobalSearch() {
const { t, locale } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [activeIdx, setActiveIdx] = useState(0); const [activeIdx, setActiveIdx] = useState(0);
const [recent, setRecent] = useState<SearchResult[]>([]);
const router = useRouter(); const router = useRouter();
const isHome = usePathname() === "/"; 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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") { if ((e.metaKey || e.ctrlKey) && e.key === "k") {
@ -109,7 +138,7 @@ export default function GlobalSearch() {
return; return;
} }
setLoading(true); setLoading(true);
const local = searchLocal(q, isHome); const local = searchLocal(q, isHome, pages.core, pages.extra, toolLinks);
try { try {
const data = await api.search(q); const data = await api.search(q);
const merged = [...local, ...data].slice(0, 16); const merged = [...local, ...data].slice(0, 16);
@ -121,7 +150,7 @@ export default function GlobalSearch() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isHome]); }, [isHome, pages.core, pages.extra, toolLinks]);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => doSearch(query), 250); const timer = setTimeout(() => doSearch(query), 250);
@ -154,17 +183,17 @@ export default function GlobalSearch() {
}; };
const emptyShortcuts = isHome const emptyShortcuts = isHome
? RING_SHORTCUTS.filter((p) => p.url !== "/plan" && p.url !== "/compare") ? pages.ring.filter((p) => p.url !== "/plan" && p.url !== "/compare")
: RING_SHORTCUTS; : pages.ring;
return ( return (
<> <>
<button className="search-trigger" onClick={() => setOpen(true)} aria-label="搜索"> <button className="search-trigger" onClick={() => setOpen(true)} aria-label={t.search.aria}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" /> <circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" /> <path d="M21 21l-4.35-4.35" />
</svg> </svg>
<span>搜索...</span> <span>{t.search.trigger}</span>
<kbd>⌘K</kbd> <kbd>⌘K</kbd>
</button> </button>
@ -178,7 +207,7 @@ export default function GlobalSearch() {
</svg> </svg>
<input <input
autoFocus autoFocus
placeholder="搜城市、活动、工具… 或从下方路径进入" placeholder={t.search.placeholder}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -186,9 +215,9 @@ export default function GlobalSearch() {
<button onClick={() => setOpen(false)}>ESC</button> <button onClick={() => setOpen(false)}>ESC</button>
</div> </div>
<div className="search-results"> <div className="search-results">
{loading && <p className="search-empty">搜索中...</p>} {loading && <p className="search-empty">{t.search.loading}</p>}
{!loading && query && results.length === 0 && ( {!loading && query && results.length === 0 && (
<p className="search-empty">😢 没有找到「{query}」相关结果</p> <p className="search-empty">{t.search.empty.replace("{q}", query)}</p>
)} )}
{!loading && {!loading &&
results.map((r, i) => ( results.map((r, i) => (
@ -203,12 +232,34 @@ export default function GlobalSearch() {
<strong>{r.title}</strong> <strong>{r.title}</strong>
<span>{r.subtitle}</span> <span>{r.subtitle}</span>
</div> </div>
<span className="search-result-type">{TYPE_LABELS[r.type] || r.type}</span> <span className="search-result-type">{labels[r.type] || r.type}</span>
</button> </button>
))} ))}
{!query && ( {!query && (
<div className="search-ring-hints"> <div className="search-ring-hints">
<p className="search-ring-label">从一条路径开始</p> {recent.length > 0 && (
<>
<p className="search-ring-label">{t.recent.searchLabel}</p>
<div className="search-results search-recent-inline">
{recent.map((r) => (
<button
key={r.url}
type="button"
className="search-result"
onClick={() => navigate(r.url)}
>
<span className="search-result-emoji">{r.emoji}</span>
<div className="search-result-text">
<strong>{r.title}</strong>
<span>{r.subtitle}</span>
</div>
<span className="search-result-type">{labels.destination}</span>
</button>
))}
</div>
</>
)}
<p className="search-ring-label">{t.search.ringLabel}</p>
<div className="search-ring-grid"> <div className="search-ring-grid">
{emptyShortcuts.map((p) => ( {emptyShortcuts.map((p) => (
<button key={p.url} type="button" className="search-ring-chip" onClick={() => navigate(p.url)}> <button key={p.url} type="button" className="search-ring-chip" onClick={() => navigate(p.url)}>
@ -217,7 +268,7 @@ export default function GlobalSearch() {
</button> </button>
))} ))}
</div> </div>
<p className="search-hints-kbd">⌨️ ↑↓ 选择 · Enter 跳转 · Esc 关闭</p> <p className="search-hints-kbd">{t.search.kbdHint}</p>
</div> </div>
)} )}
</div> </div>

View File

@ -24,10 +24,23 @@ import { useI18n } from "@/lib/i18n";
const lazy = <P extends object>(loader: () => Promise<{ default: import("react").ComponentType<P> }>) => const lazy = <P extends object>(loader: () => Promise<{ default: import("react").ComponentType<P> }>) =>
dynamic(loader, { ssr: false, loading: () => null }); dynamic(loader, { ssr: false, loading: () => null });
function MatcherLoadingShell() {
return (
<div className="modal-overlay open matcher-overlay" role="dialog" aria-modal="true" aria-busy="true">
<div className="modal matcher-modal">
<p className="dest-empty">…</p>
</div>
</div>
);
}
const VisaSection = lazy(() => import("./VisaSection")); const VisaSection = lazy(() => import("./VisaSection"));
const BlogSection = lazy(() => import("./BlogSection")); const BlogSection = lazy(() => import("./BlogSection"));
const FAQSection = lazy(() => import("./FAQSection")); const FAQSection = lazy(() => import("./FAQSection"));
const DestinationMatcher = lazy(() => import("./DestinationMatcher")); const DestinationMatcher = dynamic(() => import("./DestinationMatcher"), {
ssr: false,
loading: () => <MatcherLoadingShell />,
});
const CookieConsent = lazy(() => import("./CookieConsent")); const CookieConsent = lazy(() => import("./CookieConsent"));
const NomadTips = lazy(() => import("./NomadTips")); const NomadTips = lazy(() => import("./NomadTips"));
const FeedbackWidget = lazy(() => import("./FeedbackWidget")); const FeedbackWidget = lazy(() => import("./FeedbackWidget"));
@ -35,6 +48,8 @@ const OnboardingTour = lazy(() => import("./OnboardingTour"));
const ToolsStrip = lazy(() => import("./ToolsStrip")); const ToolsStrip = lazy(() => import("./ToolsStrip"));
const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts")); const KeyboardShortcuts = lazy(() => import("./KeyboardShortcuts"));
const PwaInstallHint = lazy(() => import("./PwaInstallHint")); const PwaInstallHint = lazy(() => import("./PwaInstallHint"));
const RecentlyViewed = lazy(() => import("./RecentlyViewed"));
const NomadDigest = lazy(() => import("./NomadDigest"));
interface Props { interface Props {
stats: Stats; stats: Stats;
@ -56,7 +71,7 @@ export default function HomeClient(props: Props) {
useEffect(() => { useEffect(() => {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
if (!hash) return; if (!hash || hash === "matcher") return;
const scrollToHash = () => { const scrollToHash = () => {
const el = document.getElementById(hash); const el = document.getElementById(hash);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
@ -67,8 +82,16 @@ export default function HomeClient(props: Props) {
useEffect(() => { useEffect(() => {
const onMatcher = () => setMatcherOpen(true); const onMatcher = () => setMatcherOpen(true);
const openFromHash = () => {
if (window.location.hash === "#matcher") setMatcherOpen(true);
};
openFromHash();
window.addEventListener("open-matcher", onMatcher); 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(() => { 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); const openMatcher = () => setMatcherOpen(true);
return ( return (
@ -119,9 +150,18 @@ export default function HomeClient(props: Props) {
<Hero stats={props.stats} onOpenMatcher={openMatcher} /> <Hero stats={props.stats} onOpenMatcher={openMatcher} />
<JourneyStrip onOpenMatcher={openMatcher} /> <JourneyStrip onOpenMatcher={openMatcher} />
<WhenVisible minHeight={140}>
<div className="container">
<NomadDigest compact />
</div>
</WhenVisible>
<WorldMap destinations={props.destinations} /> <WorldMap destinations={props.destinations} />
<Destinations destinations={props.destinations} onOpenMatcher={openMatcher} /> <Destinations destinations={props.destinations} onOpenMatcher={openMatcher} />
<WhenVisible minHeight={100}>
<RecentlyViewed />
</WhenVisible>
<WhenVisible minHeight={120}> <WhenVisible minHeight={120}>
<ToolsStrip /> <ToolsStrip />
</WhenVisible> </WhenVisible>
@ -148,7 +188,12 @@ export default function HomeClient(props: Props) {
<DestinationMatcher <DestinationMatcher
destinations={props.destinations} destinations={props.destinations}
open={matcherOpen} open={matcherOpen}
onClose={() => setMatcherOpen(false)} onClose={() => {
setMatcherOpen(false);
if (window.location.hash === "#matcher") {
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
}
}}
/> />
)} )}

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
interface HousingOption { interface HousingOption {
type: string; type: string;
@ -77,7 +77,7 @@ export default function HousingGuide({ destinations }: Props) {
<div className="housing-header reveal"> <div className="housing-header reveal">
<h3>{dest?.emoji} {dest?.name} · 住宿方案</h3> <h3>{dest?.emoji} {dest?.name} · 住宿方案</h3>
<Link href={`/destinations/${slug}`} className="btn btn-ghost btn-sm">城市详情 →</Link> {dest && <ToolCityExits dest={dest} />}
</div> </div>
<div className="housing-grid"> <div className="housing-grid">

View File

@ -9,6 +9,12 @@ import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; 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() { export default function JoinClient() {
const { user, token } = useAuth(); const { user, token } = useAuth();
@ -16,11 +22,29 @@ export default function JoinClient() {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [city, setCity] = useState("清迈"); const [city, setCity] = useState("");
const [bio, setBio] = useState(""); const [bio, setBio] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [vip, setVip] = useState(false); const [vip, setVip] = useState(false);
const [checkingVip, setCheckingVip] = useState(Boolean(token)); const [checkingVip, setCheckingVip] = useState(Boolean(token));
const [ready, setReady] = useState(false);
useEffect(() => {
const draft = loadDraft<JoinDraft>(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(() => { useEffect(() => {
if (!token) { if (!token) {
@ -44,7 +68,12 @@ export default function JoinClient() {
setLoading(true); setLoading(true);
try { try {
await api.socialJoin(token, { city, bio, lookingFor: ["friends", "explore"] }); 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 { } catch {
toast(t.join.profileFail, "error"); toast(t.join.profileFail, "error");
} finally { } finally {
@ -91,19 +120,20 @@ export default function JoinClient() {
<h1>{t.join.title}</h1> <h1>{t.join.title}</h1>
<p>{t.join.subtitle}</p> <p>{t.join.subtitle}</p>
{!checkingVip && vip && ( {!checkingVip && vip && (
<p className="join-vip-badge" role="status">✨ 你已是 VIP · 权益已生效</p> <p className="join-vip-badge" role="status">{t.join.vipActive}</p>
)} )}
</div> </div>
<div className="join-grid reveal"> <div className="join-grid reveal">
<div className="join-card"> <div className="join-card">
<h3>{t.join.step1}</h3> <h3>{t.join.step1}</h3>
<p className="section-desc">{t.join.draftHint}</p>
<label>{t.join.city}</label> <label>{t.join.city}</label>
<input value={city} onChange={(e) => setCity(e.target.value)} /> <input value={city} onChange={(e) => setCity(e.target.value)} />
<label>{t.join.bio}</label> <label>{t.join.bio}</label>
<textarea value={bio} onChange={(e) => setBio(e.target.value)} rows={3} /> <textarea value={bio} onChange={(e) => setBio(e.target.value)} rows={3} />
<button type="button" className="btn btn-primary" disabled={loading} onClick={submit}> <button type="button" className="btn btn-primary" disabled={loading} onClick={() => void submit()}>
{t.join.saveProfile} {loading ? t.join.saving : t.join.saveProfile}
</button> </button>
</div> </div>
<div className="join-card join-card-vip"> <div className="join-card join-card-vip">
@ -115,7 +145,7 @@ export default function JoinClient() {
<li>🎙️ {t.join.perk3}</li> <li>🎙️ {t.join.perk3}</li>
<li>📚 {t.join.perk4}</li> <li>📚 {t.join.perk4}</li>
</ul> </ul>
<button type="button" className="btn btn-primary" disabled={loading || checkingVip} onClick={pay}> <button type="button" className="btn btn-primary" disabled={loading || checkingVip} onClick={() => void pay()}>
{vip ? t.join.goDating : t.join.payBtn} {vip ? t.join.goDating : t.join.payBtn}
</button> </button>
{!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>} {!user && <Link href="/login?next=/join" className="join-login-hint">{t.nav.login}</Link>}

View File

@ -6,6 +6,7 @@ import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
export default function JoinPaidClient() { export default function JoinPaidClient() {
const { token } = useAuth(); const { token } = useAuth();
@ -71,8 +72,15 @@ export default function JoinPaidClient() {
<div className="join-paid-actions"> <div className="join-paid-actions">
<Link href="/dating" className="btn btn-primary">{t.join.goDating}</Link> <Link href="/dating" className="btn btn-primary">{t.join.goDating}</Link>
<Link href="/digital" className="btn">{t.join.goDigital}</Link> <Link href="/digital" className="btn">{t.join.goDigital}</Link>
<Link href="/meetups" className="btn btn-ghost">去活动</Link> <Link href="/meetups" className="btn btn-ghost">{t.nav.meetups}</Link>
</div> </div>
<RingNext
steps={[
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
{ href: "/digital", emoji: "🎓", label: t.ring.digital, desc: t.ring.digitalDesc },
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
]}
/>
</> </>
)} )}
{status === "fail" && ( {status === "fail" && (

View File

@ -1,26 +1,31 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useI18n } from "@/lib/i18n";
interface Props { interface Props {
onOpenMatcher?: () => void; 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) { export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const [open, setOpen] = useState(false); 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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName; const tag = (e.target as HTMLElement)?.tagName;
@ -59,18 +64,16 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
className="modal-overlay open shortcuts-overlay" className="modal-overlay open shortcuts-overlay"
onClick={(e) => e.target === e.currentTarget && setOpen(false)} onClick={(e) => e.target === e.currentTarget && setOpen(false)}
> >
<div className="modal shortcuts-modal" role="dialog" aria-label="键盘快捷键"> <div className="modal shortcuts-modal" role="dialog" aria-label={t.shortcuts.aria}>
<button type="button" className="modal-close" onClick={() => setOpen(false)} aria-label="关闭"> <button type="button" className="modal-close" onClick={() => setOpen(false)} aria-label={t.shortcuts.close}>
✕ ✕
</button> </button>
<div className="shortcuts-body"> <div className="shortcuts-body">
<span className="section-tag">⌨️ SHORTCUTS</span> <span className="section-tag">⌨️ SHORTCUTS</span>
<h2>键盘快捷键</h2> <h2>{t.shortcuts.title}</h2>
<p> <p>{t.shortcuts.subtitle}</p>
提升浏览效率,按 <kbd>?</kbd> 随时唤起
</p>
<div className="shortcuts-grid"> <div className="shortcuts-grid">
{SHORTCUTS.map((s) => ( {shortcuts.map((s) => (
<div key={s.label} className="shortcut-item"> <div key={s.label} className="shortcut-item">
<span className="shortcut-icon">{s.icon}</span> <span className="shortcut-icon">{s.icon}</span>
<span className="shortcut-label">{s.label}</span> <span className="shortcut-label">{s.label}</span>

View File

@ -6,10 +6,13 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { MeetupSession } from "@/lib/types"; 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 }) { export default function MeetupLiveClient({ meetupId, title }: { meetupId: string; title: string }) {
const { token } = useAuth(); const { token } = useAuth();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [session, setSession] = useState<MeetupSession | null>(null); const [session, setSession] = useState<MeetupSession | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [layout, setLayout] = useState<"split" | "video" | "chat">("split"); const [layout, setLayout] = useState<"split" | "video" | "chat">("split");
@ -31,8 +34,8 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
<div className="live-page"> <div className="live-page">
<div className="container"> <div className="container">
<div className="live-gate reveal"> <div className="live-gate reveal">
<h2>无法打开直播间</h2> <h2>{t.live.unavailable}</h2>
<p>活动可能不存在,或暂时不可用</p> <p>{t.live.unavailableDesc}</p>
<Link href="/meetups" className="btn btn-primary"> <Link href="/meetups" className="btn btn-primary">
{t.live.back} {t.live.back}
</Link> </Link>
@ -58,9 +61,9 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
reason === "vip_required" reason === "vip_required"
? t.live.vipRequired ? t.live.vipRequired
: reason === "host_required" : reason === "host_required"
? "仅主办方可进入此房间" ? t.live.hostRequired
: reason === "offline" : reason === "offline"
? "此活动为线下场次,没有在线直播间" ? t.live.offlineOnly
: t.live.loginRequired; : t.live.loginRequired;
const href = const href =
reason === "vip_required" reason === "vip_required"
@ -104,7 +107,7 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
className={`filter-btn${layout === m ? " active" : ""}`} className={`filter-btn${layout === m ? " active" : ""}`}
onClick={() => setLayout(m)} onClick={() => setLayout(m)}
> >
{m === "split" ? "分屏" : m === "video" ? "视频" : "聊天"} {m === "split" ? t.live.layoutSplit : m === "video" ? t.live.layoutVideo : t.live.layoutChat}
</button> </button>
))} ))}
</div> </div>
@ -123,7 +126,7 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
)} )}
{!session.videoUrl && !session.chatUrl && ( {!session.videoUrl && !session.chatUrl && (
<div className="notif-empty"> <div className="notif-empty">
<p className="dest-empty">直播链接尚未配置</p> <p className="dest-empty">{t.live.linksMissing}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<Link href="/meetups" className="btn btn-primary"> <Link href="/meetups" className="btn btn-primary">
{t.live.back} {t.live.back}
@ -135,6 +138,9 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string
</div> </div>
)} )}
</div> </div>
<div className="container live-exit">
<RingNext steps={rings.afterMeetups} />
</div>
</div> </div>
); );
} }

View File

@ -7,16 +7,12 @@ import { api } from "@/lib/api";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { Meetup } from "@/lib/types"; import type { MatchProfile, Meetup } from "@/lib/types";
import NewsletterSubscribe from "@/components/NewsletterSubscribe"; import NewsletterSubscribe from "@/components/NewsletterSubscribe";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { buildMeetupIcs, downloadMeetupIcs } from "@/lib/meetupIcs";
const MODE_LABELS: Record<string, string> = { import { meetupEventHref } from "@/lib/meetupLinks";
online: "💻 线上",
offline: "📍 线下",
hybrid: "🔀 混合",
};
interface Props { interface Props {
meetups: Meetup[]; meetups: Meetup[];
@ -33,6 +29,15 @@ export default function MeetupsClient({ meetups }: Props) {
const [mode, setMode] = useState<"all" | Meetup["mode"]>("all"); const [mode, setMode] = useState<"all" | Meetup["mode"]>("all");
const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set()); const [rsvpIds, setRsvpIds] = useState<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [peersFor, setPeersFor] = useState<string | null>(null);
const [peers, setPeers] = useState<MatchProfile[]>([]);
const [peersLoading, setPeersLoading] = useState(false);
const modeLabels: Record<string, string> = {
online: `💻 ${t.meetups.modeOnline}`,
offline: `📍 ${t.meetups.modeOffline}`,
hybrid: `🔀 ${t.meetups.modeHybrid}`,
};
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@ -56,9 +61,47 @@ export default function MeetupsClient({ meetups }: Props) {
return list; return list;
}, [meetups, mode, cityFilter]); }, [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) => { const handleRsvp = async (meetup: Meetup) => {
if (!token) { if (!token) {
toast("登录后报名,名额才会保留", "info"); toast(t.meetups.loginToast, "info");
router.push(`/login?next=${encodeURIComponent("/meetups")}`); router.push(`/login?next=${encodeURIComponent("/meetups")}`);
return; return;
} }
@ -69,7 +112,11 @@ export default function MeetupsClient({ meetups }: Props) {
const next = new Set(rsvpIds); const next = new Set(rsvpIds);
next.delete(meetup.id); next.delete(meetup.id);
setRsvpIds(next); setRsvpIds(next);
toast("已取消报名", "success"); if (peersFor === meetup.id) {
setPeersFor(null);
setPeers([]);
}
toast(t.meetups.cancelOk, "success");
} catch { } catch {
toast(t.meetups.rsvpFail, "error"); toast(t.meetups.rsvpFail, "error");
} finally { } finally {
@ -81,7 +128,11 @@ export default function MeetupsClient({ meetups }: Props) {
try { try {
const res = await api.rsvpMeetup(meetup.id, undefined, token); const res = await api.rsvpMeetup(meetup.id, undefined, token);
setRsvpIds(new Set(rsvpIds).add(meetup.id)); 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 { } catch {
toast(t.meetups.rsvpFail, "error"); toast(t.meetups.rsvpFail, "error");
} finally { } finally {
@ -104,13 +155,18 @@ export default function MeetupsClient({ meetups }: Props) {
<p>{t.meetups.subtitle}</p> <p>{t.meetups.subtitle}</p>
{!token && ( {!token && (
<p className="meetups-login-hint"> <p className="meetups-login-hint">
<Link href="/login?next=/meetups">登录</Link> 后报名,名额会同步到你的账号 <Link href="/login?next=/meetups">{t.nav.login}</Link> · {t.meetups.loginHint}
</p> </p>
)} )}
</div> </div>
<div className="meetups-toolbar reveal"> <div className="meetups-toolbar reveal">
<Link href="/meetups/host" className="btn btn-primary btn-sm">{t.meetups.hostBtn}</Link> <Link href="/meetups/host" className="btn btn-primary btn-sm">{t.meetups.hostBtn}</Link>
{myMeetups.length > 0 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={exportMyCalendar}>
📅 {t.meetups.exportCal}
</button>
)}
{cityFilter && ( {cityFilter && (
<button <button
type="button" type="button"
@ -127,7 +183,7 @@ export default function MeetupsClient({ meetups }: Props) {
className={`filter-btn${mode === m ? " active" : ""}`} className={`filter-btn${mode === m ? " active" : ""}`}
onClick={() => setMode(m)} onClick={() => setMode(m)}
> >
{m === "all" ? t.meetups.all : MODE_LABELS[m]} {m === "all" ? t.meetups.all : modeLabels[m]}
</button> </button>
))} ))}
</div> </div>
@ -142,7 +198,7 @@ export default function MeetupsClient({ meetups }: Props) {
<div> <div>
<h3>{m.title}</h3> <h3>{m.title}</h3>
<p className="meetup-meta"> <p className="meetup-meta">
{MODE_LABELS[m.mode]} · {m.city} · {m.date} {m.time} {modeLabels[m.mode]} · {m.city} · {m.date} {m.time}
</p> </p>
</div> </div>
</div> </div>
@ -164,13 +220,18 @@ export default function MeetupsClient({ meetups }: Props) {
{t.meetups.liveBtn} {t.meetups.liveBtn}
</Link> </Link>
)} )}
{joined && (
<button type="button" className="btn btn-sm btn-ghost" onClick={() => exportOne(m)}>
{t.meetups.addToCal}
</button>
)}
<button <button
type="button" type="button"
className={`btn btn-sm${joined ? " btn-ghost is-done" : " btn-primary"}`} className={`btn btn-sm${joined ? " btn-ghost is-done" : " btn-primary"}`}
onClick={() => handleRsvp(m)} onClick={() => handleRsvp(m)}
disabled={busyId === m.id} disabled={busyId === m.id}
> >
{joined ? "取消报名" : t.meetups.rsvpBtn} {joined ? t.meetups.cancelRsvp : t.meetups.rsvpBtn}
</button> </button>
</div> </div>
</footer> </footer>
@ -179,6 +240,38 @@ export default function MeetupsClient({ meetups }: Props) {
{t.meetups.viewCity} → {t.meetups.viewCity} →
</Link> </Link>
)} )}
{joined && peersFor === m.id && (
<div className="meetup-peers">
<div className="meetup-peers-head">
<span>{t.meetups.meetPeers}</span>
<Link href="/dating" className="btn btn-ghost btn-sm">
{t.meetups.goDating}
</Link>
</div>
{peersLoading ? (
<p className="dest-empty">{t.common.loading}</p>
) : peers.length === 0 ? null : (
<ul className="meetup-peers-list">
{peers.map((p) => (
<li key={p.id}>
<Link href={`/members/${p.id}`}>
<strong>{p.name}</strong>
{p.location ? <span>{p.location}</span> : null}
</Link>
<Link href={`/members/${p.id}`} className="btn btn-sm btn-ghost">
{t.meetups.viewProfile}
</Link>
</li>
))}
</ul>
)}
</div>
)}
{(m.mode === "online" || m.mode === "hybrid") && joined && (
<Link href={meetupEventHref(m)} className="meetup-city-link">
{t.meetups.liveBtn} →
</Link>
)}
</article> </article>
); );
})} })}

View File

@ -1,33 +1,92 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; 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() { export default function MeetupsHostClient() {
const { token } = useAuth(); const { token } = useAuth();
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [form, setForm] = useState({ const [ready, setReady] = useState(false);
title: "", const [form, setForm] = useState<HostDraft>(EMPTY);
city: "线上",
date: "",
time: "19:00",
venue: "",
description: "",
mode: "online" as "online" | "offline" | "hybrid",
access_level: "public",
max_attendees: 30,
emoji: "🎉",
});
const set = (k: string, v: string | number) => setForm((f) => ({ ...f, [k]: v })); useEffect(() => {
const draft = loadDraft<Partial<HostDraft>>(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) { if (!token) {
return ( return (
@ -50,7 +109,7 @@ export default function MeetupsHostClient() {
return; return;
} }
if (!form.title.trim() || !form.date) { if (!form.title.trim() || !form.date) {
toast("请填写活动名称和日期", "info"); toast(t.meetups.hostNeedFields, "info");
return; return;
} }
setBusy(true); setBusy(true);
@ -58,9 +117,14 @@ export default function MeetupsHostClient() {
await api.createMeetup(token, { await api.createMeetup(token, {
...form, ...form,
title: form.title.trim(), title: form.title.trim(),
city: form.city.trim() || t.meetups.modeOnline,
description: form.description.trim(), 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"); router.push("/meetups");
} catch { } catch {
toast(t.meetups.hostFail, "error"); toast(t.meetups.hostFail, "error");
@ -76,6 +140,7 @@ export default function MeetupsHostClient() {
<div className="section-header reveal"> <div className="section-header reveal">
<h1>{t.meetups.hostTitle}</h1> <h1>{t.meetups.hostTitle}</h1>
<p>{t.meetups.hostSubtitle}</p> <p>{t.meetups.hostSubtitle}</p>
<p className="section-desc">{t.meetups.draftHint}</p>
</div> </div>
<div className="join-card reveal"> <div className="join-card reveal">
<label>{t.meetups.hostName}</label> <label>{t.meetups.hostName}</label>
@ -84,9 +149,9 @@ export default function MeetupsHostClient() {
<input value={form.city} onChange={(e) => set("city", e.target.value)} /> <input value={form.city} onChange={(e) => set("city", e.target.value)} />
<label>{t.meetups.hostMode}</label> <label>{t.meetups.hostMode}</label>
<select value={form.mode} onChange={(e) => set("mode", e.target.value)}> <select value={form.mode} onChange={(e) => set("mode", e.target.value)}>
<option value="online">线上</option> <option value="online">{t.meetups.modeOnline}</option>
<option value="offline">线下</option> <option value="offline">{t.meetups.modeOffline}</option>
<option value="hybrid">混合</option> <option value="hybrid">{t.meetups.modeHybrid}</option>
</select> </select>
<label>{t.meetups.hostDate}</label> <label>{t.meetups.hostDate}</label>
<input type="date" value={form.date} onChange={(e) => set("date", e.target.value)} /> <input type="date" value={form.date} onChange={(e) => set("date", e.target.value)} />
@ -100,10 +165,11 @@ export default function MeetupsHostClient() {
placeholder={t.meetups.hostDesc} placeholder={t.meetups.hostDesc}
maxLength={2000} maxLength={2000}
/> />
<button type="button" className="btn btn-primary" disabled={busy} onClick={submit}> <button type="button" className="btn btn-primary" disabled={busy} onClick={() => void submit()}>
{busy ? "创建中…" : t.meetups.hostSubmit} {busy ? t.meetups.hosting : t.meetups.hostSubmit}
</button> </button>
</div> </div>
<RingNext steps={rings.afterMeetups} />
</div> </div>
</div> </div>
); );

View File

@ -7,12 +7,13 @@ import { useI18n } from "@/lib/i18n";
import type { MemberMapItem } from "@/lib/types"; import type { MemberMapItem } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { meetupCityHref } from "@/lib/meetupLinks";
export default function MemberMapClient() { export default function MemberMapClient() {
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const [members, setMembers] = useState<MemberMapItem[]>([]); const [members, setMembers] = useState<MemberMapItem[]>([]);
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); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@ -28,6 +29,8 @@ export default function MemberMapClient() {
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link> <Link href="/">{t.common.backHome}</Link>
<span> · </span> <span> · </span>
<Link href="/meetups">{t.nav.meetups}</Link>
<span> · </span>
<Link href="/report">{t.report.title}</Link> <Link href="/report">{t.report.title}</Link>
</nav> </nav>
<div className="section-header reveal"> <div className="section-header reveal">
@ -35,16 +38,19 @@ export default function MemberMapClient() {
<h1>{t.mapPage.title}</h1> <h1>{t.mapPage.title}</h1>
<p>{t.mapPage.subtitle}</p> <p>{t.mapPage.subtitle}</p>
</div> </div>
{loading && <p className="dest-empty">加载地图…</p>} {loading && <p className="dest-empty">{t.mapPage.loading}</p>}
{!loading && members.length === 0 && ( {!loading && members.length === 0 && (
<div className="notif-empty reveal"> <div className="notif-empty reveal">
<p className="dest-empty">暂无公开会员分布</p> <p className="dest-empty">{t.mapPage.empty}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<Link href="/dating" className="btn btn-primary btn-sm"> <Link href="/dating" className="btn btn-primary btn-sm">
去匹配 {t.mapPage.goDating}
</Link> </Link>
<Link href="/meetups" className="btn btn-sm"> <Link href="/meetups" className="btn btn-sm">
去活动 {t.mapPage.goMeetups}
</Link>
<Link href="/community" className="btn btn-sm">
{t.nav.community}
</Link> </Link>
</div> </div>
</div> </div>
@ -55,7 +61,11 @@ export default function MemberMapClient() {
<span className="chat-avatar">{m.emoji}</span> <span className="chat-avatar">{m.emoji}</span>
<div> <div>
<strong>{m.name}</strong> <strong>{m.name}</strong>
<p>{m.city}</p> <p>
<Link href={meetupCityHref(m.city)} className="member-loc-link">
{m.city}
</Link>
</p>
</div> </div>
</div> </div>
))} ))}
@ -63,14 +73,18 @@ export default function MemberMapClient() {
<section className="weather-strip reveal"> <section className="weather-strip reveal">
<h3>{t.mapPage.weather}</h3> <h3>{t.mapPage.weather}</h3>
{!loading && weather.length === 0 ? ( {!loading && weather.length === 0 ? (
<p className="dest-empty">天气暂不可用</p> <p className="dest-empty">{t.mapPage.weatherEmpty}</p>
) : ( ) : (
<div className="weather-cards"> <div className="weather-cards">
{weather.map((w) => ( {weather.map((w) => (
<div key={w.name} className="weather-card"> <Link
key={w.name}
href={w.slug ? `/destinations/${w.slug}` : meetupCityHref(w.name)}
className="weather-card"
>
<strong>{w.name}</strong> <strong>{w.name}</strong>
<span>{w.temperature != null ? `${w.temperature}°C` : "—"}</span> <span>{w.temperature != null ? `${w.temperature}°C` : "—"}</span>
</div> </Link>
))} ))}
</div> </div>
)} )}

View File

@ -3,11 +3,23 @@
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { MemberProfile } from "@/lib/types"; 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 }) { export default function MemberProfileClient({ profile }: { profile: MemberProfile }) {
const { t } = useI18n(); const { t } = useI18n();
const rings = useRingSteps();
const photo = profile.photo || ""; const photo = profile.photo || "";
const isUrl = photo.startsWith("http"); const isUrl = photo.startsWith("http");
const city = cityFromLocation(profile.location);
const meetupsHref = city ? meetupCityHref(city) : "/meetups";
return ( return (
<div className="dating-page"> <div className="dating-page">
@ -16,6 +28,8 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
<Link href="/dating">← {t.nav.dating}</Link> <Link href="/dating">← {t.nav.dating}</Link>
<span> · </span> <span> · </span>
<Link href="/chat">{t.nav.chat}</Link> <Link href="/chat">{t.nav.chat}</Link>
<span> · </span>
<Link href={meetupsHref}>{t.nav.meetups}</Link>
</nav> </nav>
<div className="dating-card reveal" style={{ maxWidth: 480 }}> <div className="dating-card reveal" style={{ maxWidth: 480 }}>
<div className="dating-card-photo"> <div className="dating-card-photo">
@ -30,8 +44,16 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
{profile.name} {profile.name}
{profile.vip ? " ✨" : ""} {profile.vip ? " ✨" : ""}
</h2> </h2>
<p className="dating-meta">{profile.location || "全球游民"}</p> <p className="dating-meta">
<p>{profile.bio || "这位游民还没写自我介绍"}</p> {city ? (
<Link href={meetupsHref} className="member-loc-link">
{profile.location}
</Link>
) : (
profile.location || t.dating.globalNomad
)}
</p>
<p>{profile.bio || t.dating.noBio}</p>
<div className="dating-tags"> <div className="dating-tags">
{(profile.tags || []).map((tag) => ( {(profile.tags || []).map((tag) => (
<span key={tag} className="meetup-tag"> <span key={tag} className="meetup-tag">
@ -41,13 +63,17 @@ export default function MemberProfileClient({ profile }: { profile: MemberProfil
</div> </div>
<div className="dating-empty-actions" style={{ marginTop: "1.25rem" }}> <div className="dating-empty-actions" style={{ marginTop: "1.25rem" }}>
<Link href="/dating" className="btn btn-primary btn-sm"> <Link href="/dating" className="btn btn-primary btn-sm">
继续匹配 {t.dating.keepSwiping}
</Link> </Link>
<Link href="/meetups" className="btn btn-sm"> <Link href={meetupsHref} className="btn btn-sm">
去活动认识人 {city ? t.common.cityMeetups : t.dating.meetAtEvents}
</Link>
<Link href="/community" className="btn btn-sm">
{t.nav.community}
</Link> </Link>
</div> </div>
</div> </div>
<RingNext steps={rings.afterDating} />
</div> </div>
</div> </div>
); );

View File

@ -19,6 +19,7 @@ import { useI18n } from "@/lib/i18n";
import type { Destination, TripItem, Visa } from "@/lib/types"; import type { Destination, TripItem, Visa } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { meetupCityHref } from "@/lib/meetupLinks";
interface Props { interface Props {
destinations: Destination[]; destinations: Destination[];
@ -38,12 +39,12 @@ function scoreDest(d: Destination) {
export default function MovePlanClient({ destinations, visas = [] }: Props) { export default function MovePlanClient({ destinations, visas = [] }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t, locale } = useI18n();
const rings = useRingSteps(); const rings = useRingSteps();
const { token, user, planSyncStatus } = useAuth(); const { token, user, planSyncStatus } = useAuth();
const [trip, setTrip] = useState<TripItem[]>([]); const [trip, setTrip] = useState<TripItem[]>([]);
const [meta, setMeta] = useState<PlanMeta>(() => ({ const [meta, setMeta] = useState<PlanMeta>(() => ({
title: "我的旅居计划", title: "",
startMonth: "", startMonth: "",
monthlyBudget: 0, monthlyBudget: 0,
checklist: {}, checklist: {},
@ -68,9 +69,13 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
} else { } else {
setTrip(loadTrip<TripItem>()); setTrip(loadTrip<TripItem>());
} }
setMeta(loadPlanMeta()); const loaded = loadPlanMeta();
if (!loaded.title.trim() || loaded.title === "我的旅居计划") {
loaded.title = t.plan.defaultTitle;
}
setMeta(loaded);
setReady(true); setReady(true);
}, [toast, t.plan.importedShare]); }, [toast, t.plan.importedShare, t.plan.defaultTitle]);
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
@ -198,19 +203,37 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
const exportTrip = () => { const exportTrip = () => {
const lines = [ const lines = [
`# ${meta.title}`, `# ${meta.title}`,
meta.startMonth ? `出发月:${meta.startMonth}` : "", meta.startMonth ? t.plan.exportStartMonth.replace("{month}", meta.startMonth) : "",
"", "",
...timeline.map((t, i) => { ...timeline.map((stop, i) => {
const range = t.range ? `(${t.range})` : ""; const range = stop.range ? `(${stop.range})` : "";
const note = t.note ? `\n 备注:${t.note}` : ""; const note = stop.note ? `\n ${t.plan.exportNote}${stop.note}` : "";
const visaLine = t.visa ? `\n 签证:${t.visa.text}` : ""; const visaLine = stop.visa ? `\n ${t.plan.exportVisa}${stop.visa.text}` : "";
return `${i + 1}. ${t.emoji} **${t.name}, ${t.country}** — ${t.months} 个月 · ¥${(t.cost * t.months).toLocaleString()}${range}${note}${visaLine}`; 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()}`, t.plan.exportTotal
`> 出发就绪度 **${readiness}%**(${checkedCount}/${MOVE_CHECKLIST.length})`, .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); ].filter(Boolean);
const blob = new Blob([lines.join("\n")], { type: "text/markdown;charset=utf-8" }); const blob = new Blob([lines.join("\n")], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@ -250,9 +273,24 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
}; };
const copyPlain = async () => { const copyPlain = async () => {
const text = timeline const text =
.map((t, i) => `${i + 1}. ${t.emoji} ${t.name}, ${t.country} — ${t.months}个月 ¥${(t.cost * t.months).toLocaleString()}${t.range ? ` (${t.range})` : ""}`) timeline
.join("\n") + `\n\n总计: ${totalMonths}个月 · ¥${totalCost.toLocaleString()} · 就绪 ${readiness}%`; .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); await navigator.clipboard.writeText(text);
toast(t.plan.copied); toast(t.plan.copied);
}; };
@ -403,10 +441,10 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
<p>{t.plan.empty}</p> <p>{t.plan.empty}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<Link href="/next-stop" className="btn btn-primary"> <Link href="/next-stop" className="btn btn-primary">
智能下一站 {t.plan.emptyNextStop}
</Link> </Link>
<Link href="/compare" className="btn"> <Link href="/compare" className="btn">
城市对比 {t.plan.emptyCompare}
</Link> </Link>
<Link href="/#destinations" className="btn btn-ghost"> <Link href="/#destinations" className="btn btn-ghost">
{t.plan.goDest} {t.plan.goDest}
@ -431,11 +469,16 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
{stop.range && <span className="plan-stop-range">{stop.range}</span>} {stop.range && <span className="plan-stop-range">{stop.range}</span>}
</div> </div>
<div className="plan-stop-tools"> <div className="plan-stop-tools">
<button type="button" aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}>↑</button> <button type="button" aria-label={t.plan.moveUp} disabled={i === 0} onClick={() => move(i, -1)}>↑</button>
<button type="button" aria-label="下移" disabled={i === trip.length - 1} onClick={() => move(i, 1)}>↓</button> <button type="button" aria-label={t.plan.moveDown} disabled={i === trip.length - 1} onClick={() => move(i, 1)}>↓</button>
<button type="button" className="plan-remove" aria-label="移除" onClick={() => removeCity(stop.slug)}>✕</button> <button type="button" className="plan-remove" aria-label={t.plan.removeStop} onClick={() => removeCity(stop.slug)}>✕</button>
</div> </div>
</div> </div>
<div className="plan-stop-links">
<Link href={meetupCityHref(stop.name)} className="plan-city-meetup">
{t.meetups.tripEvents} · {stop.name} →
</Link>
</div>
<div className="plan-stop-controls"> <div className="plan-stop-controls">
<label> <label>
{t.plan.stay} {t.plan.stay}
@ -497,10 +540,10 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
</div> </div>
</header> </header>
<ul> <ul>
<li>时长 {v.duration}</li> <li>{t.plan.visaDuration} {v.duration}</li>
<li>收入要求 {v.income_req}</li> <li>{t.plan.visaIncome} {v.income_req}</li>
<li>审批 {v.approval_time}</li> <li>{t.plan.visaApproval} {v.approval_time}</li>
<li>难度 {v.difficulty_label}</li> <li>{t.plan.visaDifficulty} {v.difficulty_label}</li>
</ul> </ul>
{v.extra && <p>{v.extra}</p>} {v.extra && <p>{v.extra}</p>}
</article> </article>
@ -511,7 +554,10 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
)} )}
{missingVisaCountries.length > 0 && tripVisas.length > 0 && ( {missingVisaCountries.length > 0 && tripVisas.length > 0 && (
<p className="plan-visa-missing"> <p className="plan-visa-missing">
暂无收录:{missingVisaCountries.join("、")} — 请以官方政策为准,并确认免签天数。 {t.plan.visaMissingCountries.replace(
"{countries}",
missingVisaCountries.join(locale === "en" ? ", " : "、")
)}
</p> </p>
)} )}
</section> </section>
@ -621,7 +667,7 @@ export default function MovePlanClient({ destinations, visas = [] }: Props) {
<RingNext <RingNext
steps={[ steps={[
...rings.afterCity, ...rings.afterCity(trip[0]?.name),
{ href: "/#visa", emoji: "📋", label: t.nav.visa, desc: t.journey.step3Desc }, { href: "/#visa", emoji: "📋", label: t.nav.visa, desc: t.journey.step3Desc },
].slice(0, 3)} ].slice(0, 3)}
/> />

View File

@ -9,14 +9,11 @@ import { useI18n } from "@/lib/i18n";
import type { Destination, Meetup, NomadRoute, RecommendedDestination } from "@/lib/types"; import type { Destination, Meetup, NomadRoute, RecommendedDestination } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { meetupCityHref, meetupEventHref } from "@/lib/meetupLinks";
import { loadNextStopPrefs, saveNextStopPrefs } from "@/lib/nextStopPrefs";
const PRIMARY_TAGS = ["低成本", "高速网络", "温暖气候", "社区活跃"]; const PRIMARY_TAG_VALUES = ["低成本", "高速网络", "温暖气候", "社区活跃"] as const;
const EXTRA_TAGS = ["签证友好", "海滨", "美食"]; const EXTRA_TAG_VALUES = ["签证友好", "海滨", "美食"] as const;
const CLIMATE_OPTIONS = [
{ value: "mild", label: "🌤️ 温和" },
{ value: "warm", label: "☀️ 偏暖" },
{ value: "cool", label: "🍂 偏凉" },
];
interface Props { interface Props {
destinations: Destination[]; destinations: Destination[];
@ -24,7 +21,12 @@ interface Props {
initialRoutes: NomadRoute[]; 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; let score = d.rating * 10;
if (d.cost <= budget) score += 25; if (d.cost <= budget) score += 25;
if (d.speed >= internet) score += 20; if (d.speed >= internet) score += 20;
@ -32,8 +34,8 @@ function fallbackScore(d: Destination, budget: number, internet: number): Recomm
...d, ...d,
match_score: Math.round(score), match_score: Math.round(score),
match_reasons: [ match_reasons: [
d.cost <= budget ? "预算内" : "略超预算", d.cost <= budget ? reasons.inBudget : reasons.overBudget,
d.speed >= internet ? "网络达标" : "需确认网络", d.speed >= internet ? reasons.netOk : reasons.netCheck,
], ],
}; };
} }
@ -47,21 +49,79 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
const [climate, setClimate] = useState("mild"); const [climate, setClimate] = useState("mild");
const [selectedTags, setSelectedTags] = useState<string[]>(["社区活跃"]); const [selectedTags, setSelectedTags] = useState<string[]>(["社区活跃"]);
const [moreTags, setMoreTags] = useState(false); const [moreTags, setMoreTags] = useState(false);
const [prefsReady, setPrefsReady] = useState(false);
const [results, setResults] = useState<RecommendedDestination[]>([]); const [results, setResults] = useState<RecommendedDestination[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [meetups] = useState(initialMeetups); const [meetups] = useState(initialMeetups);
const [routes] = useState(initialRoutes); const [routes] = useState(initialRoutes);
const tagLabel = (value: string) => {
const map: Record<string, string> = {
低成本: 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<string, string> = {
预算内: t.nextStop.reasonInBudget,
略超预算: t.nextStop.reasonOverBudget,
网络达标: t.nextStop.reasonNetOk,
需确认网络: t.nextStop.reasonNetCheck,
};
return map[reason] || reason;
};
const localFallback = useMemo( const localFallback = useMemo(
() => () =>
[...destinations] [...destinations]
.map((d) => fallbackScore(d, budget, internet)) .map((d) => fallbackScore(d, budget, internet, reasonLabels))
.sort((a, b) => b.match_score - a.match_score) .sort((a, b) => b.match_score - a.match_score)
.slice(0, 12), .slice(0, 12),
[destinations, budget, internet] [destinations, budget, internet, reasonLabels]
); );
const fetchRecommendations = useCallback(async () => { const fetchRecommendations = useCallback(async () => {
if (!prefsReady) return;
setLoading(true); setLoading(true);
try { try {
const data = await api.getNextStop({ const data = await api.getNextStop({
@ -77,7 +137,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [budget, internet, climate, selectedTags, localFallback]); }, [prefsReady, budget, internet, climate, selectedTags, localFallback]);
useEffect(() => { useEffect(() => {
const timer = setTimeout(fetchRecommendations, 300); const timer = setTimeout(fetchRecommendations, 300);
@ -105,8 +165,14 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
}; };
const addToPlan = (dest: Destination) => { const addToPlan = (dest: Destination) => {
mergeDestinationsIntoTrip([dest]); const { added } = mergeDestinationsIntoTrip([dest]);
toast(t.nextStop.addedPlan.replace("{city}", dest.name), "success"); 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); const resolveStop = (slug: string) => destinations.find((d) => d.slug === slug);
@ -138,7 +204,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
value={budget} value={budget}
onChange={(e) => setBudget(Number(e.target.value))} onChange={(e) => setBudget(Number(e.target.value))}
/> />
<span className="next-stop-value">¥{budget.toLocaleString()}/月</span> <span className="next-stop-value">¥{budget.toLocaleString()}/{t.common.monthsUnit}</span>
</div> </div>
<div className="next-stop-filter-card"> <div className="next-stop-filter-card">
@ -157,7 +223,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
<div className="next-stop-filter-card"> <div className="next-stop-filter-card">
<label>{t.nextStop.climate}</label> <label>{t.nextStop.climate}</label>
<div className="next-stop-chips"> <div className="next-stop-chips">
{CLIMATE_OPTIONS.map((o) => ( {climateOptions.map((o) => (
<button <button
key={o.value} key={o.value}
type="button" type="button"
@ -173,19 +239,22 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
<div className="next-stop-filter-card"> <div className="next-stop-filter-card">
<label>{t.nextStop.tags}</label> <label>{t.nextStop.tags}</label>
<div className="next-stop-chips"> <div className="next-stop-chips">
{(moreTags ? [...PRIMARY_TAGS, ...EXTRA_TAGS] : PRIMARY_TAGS).map((tag) => ( {(moreTags
? [...PRIMARY_TAG_VALUES, ...EXTRA_TAG_VALUES]
: [...PRIMARY_TAG_VALUES]
).map((tag) => (
<button <button
key={tag} key={tag}
type="button" type="button"
className={`filter-btn${selectedTags.includes(tag) ? " active" : ""}`} className={`filter-btn${selectedTags.includes(tag) ? " active" : ""}`}
onClick={() => toggleTag(tag)} onClick={() => toggleTag(tag)}
> >
{tag} {tagLabel(tag)}
</button> </button>
))} ))}
{!moreTags && ( {!moreTags && (
<button type="button" className="filter-btn" onClick={() => setMoreTags(true)}> <button type="button" className="filter-btn" onClick={() => setMoreTags(true)}>
更多… {t.nextStop.moreTags}
</button> </button>
)} )}
</div> </div>
@ -204,7 +273,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
<p>{best.description}</p> <p>{best.description}</p>
<div className="next-stop-reasons"> <div className="next-stop-reasons">
{best.match_reasons?.map((r) => ( {best.match_reasons?.map((r) => (
<span key={r} className="next-stop-reason">{r}</span> <span key={r} className="next-stop-reason">{localizeReason(r)}</span>
))} ))}
</div> </div>
<div className="next-stop-hero-actions"> <div className="next-stop-hero-actions">
@ -232,10 +301,10 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
<strong>{dest.name}</strong> <strong>{dest.name}</strong>
<em>{dest.match_score}</em> <em>{dest.match_score}</em>
</div> </div>
<p>¥{dest.cost}/月 · {dest.speed}Mbps · ⭐{dest.rating}</p> <p>¥{dest.cost}/{t.common.monthsUnit} · {dest.speed}Mbps · ⭐{dest.rating}</p>
<div className="next-stop-reasons"> <div className="next-stop-reasons">
{dest.match_reasons?.slice(0, 2).map((r) => ( {dest.match_reasons?.slice(0, 2).map((r) => (
<span key={r} className="next-stop-reason">{r}</span> <span key={r} className="next-stop-reason">{localizeReason(r)}</span>
))} ))}
</div> </div>
<div className="next-stop-card-actions"> <div className="next-stop-card-actions">
@ -277,7 +346,10 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
.filter((d): d is Destination => !!d); .filter((d): d is Destination => !!d);
if (stops.length) { if (stops.length) {
mergeDestinationsIntoTrip(stops); 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
<section className="next-stop-meetups reveal"> <section className="next-stop-meetups reveal">
<div className="next-stop-meetups-head"> <div className="next-stop-meetups-head">
<h3>{t.nextStop.meetups}{best ? ` · ${best.name}` : ""}</h3> <h3>{t.nextStop.meetups}{best ? ` · ${best.name}` : ""}</h3>
<Link href={best ? `/meetups?city=${encodeURIComponent(best.name)}` : "/meetups"}> <Link href={best ? meetupCityHref(best.name) : "/meetups"}>
{t.nextStop.allMeetups} → {t.nextStop.allMeetups} →
</Link> </Link>
</div> </div>
@ -302,7 +374,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
{relevantMeetups.map((m) => ( {relevantMeetups.map((m) => (
<Link <Link
key={m.id} key={m.id}
href={`/meetups?city=${encodeURIComponent(m.city)}`} href={meetupEventHref(m)}
className="next-stop-meetup-chip" className="next-stop-meetup-chip"
> >
<span>{m.emoji}</span> <span>{m.emoji}</span>
@ -318,7 +390,7 @@ export default function NextStopClient({ destinations, initialMeetups, initialRo
</div> </div>
</div> </div>
<RingNext steps={rings.afterDiscover} /> <RingNext steps={rings.afterNextStop} />
</div> </div>
</div> </div>
); );

View File

@ -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<Meetup | undefined>(upcoming[0]);
useEffect(() => {
const trip = loadTrip<TripItem>();
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 (
<section
className={`nomad-digest reveal${compact ? " compact" : ""}`}
aria-label={title}
>
<div className="nomad-digest-head">
<div>
{!compact && <span className="section-tag">{t.digest.tag}</span>}
<h2>{title}</h2>
<p className="section-desc">{subtitle}</p>
</div>
<Link href="/next-stop" className="btn btn-ghost btn-sm">
{t.nav.nextStop}
</Link>
</div>
<div className="nomad-digest-grid">
<Link href="/plan" className="nomad-digest-card">
<strong>🗓️ {tripLen}</strong>
<span>{t.digest.planCities}</span>
<small>
{t.digest.readiness} {readiness}%
</small>
</Link>
<Link href="/meetups" className="nomad-digest-card">
<strong>🎉 {rsvpCount}</strong>
<span>{t.digest.rsvps}</span>
<small>{nextUp ? nextUp.title : t.digest.noUpcoming}</small>
</Link>
<Link href="/videos" className="nomad-digest-card">
<strong>🎬 {watchCount}</strong>
<span>{t.digest.watchLater}</span>
<small>{t.digest.openVideos}</small>
</Link>
<Link href="/ai" className="nomad-digest-card">
<strong>🤖</strong>
<span>{t.digest.lastAi}</span>
<small>{lastAi || t.digest.askAi}</small>
</Link>
</div>
{topRecent.length > 0 && (
<div className="nomad-digest-recent">
<span>
{t.recent.title} · {recentCount}
</span>
<div className="nomad-digest-chips">
{topRecent.map((d) => (
<Link key={d.slug} href={`/destinations/${d.slug}`} className="filter-btn">
{d.emoji} {d.name}
</Link>
))}
{topRecent.length >= 2 && (
<Link
href={`/compare?cities=${topRecent.map((d) => d.slug).join(",")}`}
className="filter-btn active"
>
{t.recent.compareRecent}
</Link>
)}
</div>
</div>
)}
</section>
);
}

View File

@ -1,21 +1,26 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n";
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" },
];
export default function NomadTips() { export default function NomadTips() {
const { t } = useI18n();
const [idx, setIdx] = useState(0); const [idx, setIdx] = useState(0);
const [visible, setVisible] = useState(false); 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(() => { useEffect(() => {
if (localStorage.getItem("nomadro-tips-dismissed") === "1") return; if (localStorage.getItem("nomadro-tips-dismissed") === "1") return;
const show = setTimeout(() => setVisible(true), 8000); const show = setTimeout(() => setVisible(true), 8000);
@ -24,13 +29,13 @@ export default function NomadTips() {
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
const t = setInterval(() => setIdx((i) => (i + 1) % TIPS.length), 6000); const timer = setInterval(() => setIdx((i) => (i + 1) % tips.length), 6000);
return () => clearInterval(t); return () => clearInterval(timer);
}, [visible]); }, [visible, tips.length]);
if (!visible) return null; if (!visible) return null;
const tip = TIPS[idx]; const tip = tips[idx] || tips[0];
const dismiss = () => { const dismiss = () => {
localStorage.setItem("nomadro-tips-dismissed", "1"); localStorage.setItem("nomadro-tips-dismissed", "1");
setVisible(false); setVisible(false);
@ -38,19 +43,19 @@ export default function NomadTips() {
return ( return (
<div className="nomad-tips" role="status"> <div className="nomad-tips" role="status">
<button type="button" className="nomad-tips-close" onClick={dismiss} aria-label="关闭"> <button type="button" className="nomad-tips-close" onClick={dismiss} aria-label={t.tips.close}>
✕ ✕
</button> </button>
<span className="nomad-tips-emoji">{tip.emoji}</span> <span className="nomad-tips-emoji">{tip.emoji}</span>
<div className="nomad-tips-body"> <div className="nomad-tips-body">
<strong>游民小贴士</strong> <strong>{t.tips.title}</strong>
<p key={idx}>{tip.text}</p> <p key={idx}>{tip.text}</p>
<Link href={tip.href} className="nomad-tips-link" onClick={dismiss}> <Link href={tip.href} className="nomad-tips-link" onClick={dismiss}>
了解更多 → {t.tips.more}
</Link> </Link>
</div> </div>
<div className="nomad-tips-dots"> <div className="nomad-tips-dots">
{TIPS.map((_, i) => ( {tips.map((_, i) => (
<span key={i} className={i === idx ? "active" : ""} /> <span key={i} className={i === idx ? "active" : ""} />
))} ))}
</div> </div>

View File

@ -6,6 +6,7 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
const PREF_KEYS = ["match", "meetup", "community", "push", "email", "marketing"] as const; 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) { if (!token) {
return ( return (
<div className="dating-page"> <div className="dating-page">
@ -69,20 +74,18 @@ export default function NotificationSettingsClient() {
); );
} }
const toggle = (key: string) => setPrefs({ ...prefs, [key]: !prefs[key] });
return ( return (
<div className="community-page"> <div className="dating-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/notifications">{t.notifSettings.back}</Link> <Link href="/notifications">← {t.notifSettings.back}</Link>
</nav> </nav>
<div className="section-header reveal"> <div className="section-header reveal">
<h1>{t.notifSettings.title}</h1> <h1>{t.notifSettings.title}</h1>
</div> </div>
<div className="join-card reveal"> <div className="join-card reveal">
{loading ? ( {loading ? (
<p className="dest-empty">加载偏好…</p> <p className="dest-empty">{t.notifSettings.loading}</p>
) : ( ) : (
PREF_KEYS.map((k) => ( PREF_KEYS.map((k) => (
<label key={k} className="notif-pref-row"> <label key={k} className="notif-pref-row">
@ -91,10 +94,17 @@ export default function NotificationSettingsClient() {
</label> </label>
)) ))
)} )}
<button type="button" className="btn btn-primary" disabled={loading || saving} onClick={save}> <button type="button" className="btn btn-primary" disabled={loading || saving} onClick={() => void save()}>
{saving ? "保存中…" : t.notifSettings.save} {saving ? t.notifSettings.saving : t.notifSettings.save}
</button> </button>
</div> </div>
<RingNext
steps={[
{ href: "/notifications", emoji: "🔔", label: t.nav.notifications, desc: t.notifSettings.back },
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
]}
/>
</div> </div>
</div> </div>
); );

View File

@ -48,11 +48,20 @@ export default function NotificationsClient() {
<div className="container"> <div className="container">
<div className="dating-gate reveal"> <div className="dating-gate reveal">
<h2>{t.notifications.title}</h2> <h2>{t.notifications.title}</h2>
<p>登录后查看匹配、报名与回复提醒</p> <p>{t.notifications.loginDesc}</p>
<Link href="/login?next=/notifications" className="btn btn-primary"> <div className="dating-empty-actions">
{t.nav.login} <Link href="/login?next=/notifications" className="btn btn-primary">
</Link> {t.nav.login}
</Link>
<Link href="/meetups" className="btn btn-ghost">
{t.nav.meetups}
</Link>
<Link href="/community" className="btn btn-ghost">
{t.nav.community}
</Link>
</div>
</div> </div>
<RingNext steps={rings.afterDating} />
</div> </div>
</div> </div>
); );
@ -64,7 +73,7 @@ export default function NotificationsClient() {
<div className="section-header reveal"> <div className="section-header reveal">
<h1>{t.notifications.title}</h1> <h1>{t.notifications.title}</h1>
<div className="notif-toolbar"> <div className="notif-toolbar">
<button type="button" className="btn btn-sm" onClick={markAll} disabled={items.every((n) => n.read)}> <button type="button" className="btn btn-sm" onClick={() => void markAll()} disabled={items.every((n) => n.read)}>
{t.notifications.markRead} {t.notifications.markRead}
</button> </button>
<Link href="/settings/notifications" className="btn btn-sm"> <Link href="/settings/notifications" className="btn btn-sm">
@ -73,7 +82,7 @@ export default function NotificationsClient() {
</div> </div>
</div> </div>
<div className="chat-list"> <div className="chat-list">
{loading && <p className="dest-empty">加载中…</p>} {loading && <p className="dest-empty">{t.common.loading}</p>}
{!loading && {!loading &&
items.map((n) => ( items.map((n) => (
<div key={n.id} className={`chat-list-item reveal${n.read ? "" : " unread"}`}> <div key={n.id} className={`chat-list-item reveal${n.read ? "" : " unread"}`}>
@ -94,7 +103,7 @@ export default function NotificationsClient() {
) : ( ) : (
!n.read && ( !n.read && (
<button type="button" className="btn btn-sm" onClick={() => void openOne(n)}> <button type="button" className="btn btn-sm" onClick={() => void openOne(n)}>
标为已读 {t.notifications.markOne}
</button> </button>
) )
)} )}
@ -103,16 +112,16 @@ export default function NotificationsClient() {
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
<div className="notif-empty reveal"> <div className="notif-empty reveal">
<p className="dest-empty">{t.notifications.empty}</p> <p className="dest-empty">{t.notifications.empty}</p>
<p className="section-desc">去互动一下,通知会出现在这里</p> <p className="section-desc">{t.notifications.emptyHint}</p>
<div className="notif-empty-links"> <div className="notif-empty-links">
<Link href="/meetups" className="btn btn-sm"> <Link href="/meetups" className="btn btn-sm">
活动 {t.nav.meetups}
</Link> </Link>
<Link href="/dating" className="btn btn-sm"> <Link href="/dating" className="btn btn-sm">
匹配 {t.nav.dating}
</Link> </Link>
<Link href="/community" className="btn btn-sm"> <Link href="/community" className="btn btn-sm">
社区 {t.nav.community}
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -1,42 +1,46 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n";
/** 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",
},
];
export default function OnboardingTour() { export default function OnboardingTour() {
const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [step, setStep] = useState(0); 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(() => { useEffect(() => {
if (localStorage.getItem("nomadro-onboarded")) return; if (localStorage.getItem("nomadro-onboarded")) return;
const t = setTimeout(() => setOpen(true), 2500); const timer = setTimeout(() => setOpen(true), 2500);
return () => clearTimeout(t); return () => clearTimeout(timer);
}, []); }, []);
const finish = () => { const finish = () => {
@ -45,7 +49,7 @@ export default function OnboardingTour() {
}; };
const next = () => { const next = () => {
const s = STEPS[step]; const s = steps[step];
if (s.action === "matcher") { if (s.action === "matcher") {
window.dispatchEvent(new CustomEvent("open-matcher")); window.dispatchEvent(new CustomEvent("open-matcher"));
} else if (s.action === "route" && s.href) { } else if (s.action === "route" && s.href) {
@ -56,7 +60,7 @@ export default function OnboardingTour() {
document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" }); document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" });
} }
if (step >= STEPS.length - 1) { if (step >= steps.length - 1) {
finish(); finish();
return; return;
} }
@ -65,25 +69,25 @@ export default function OnboardingTour() {
if (!open) return null; if (!open) return null;
const current = STEPS[step]; const current = steps[step];
const isLast = step >= STEPS.length - 1; const isLast = step >= steps.length - 1;
return ( return (
<div className="onboard-card" role="dialog" aria-label="新手引导"> <div className="onboard-card" role="dialog" aria-label={t.onboard.aria}>
<button className="onboard-skip" onClick={finish}> <button className="onboard-skip" onClick={finish}>
跳过 {t.onboard.skip}
</button> </button>
<div className="onboard-step"> <div className="onboard-step">
<span className="onboard-emoji">{current.emoji}</span> <span className="onboard-emoji">{current.emoji}</span>
<strong>{current.title}</strong> <strong>{current.title}</strong>
<p>{current.desc}</p> <p>{current.desc}</p>
<div className="onboard-dots"> <div className="onboard-dots">
{STEPS.map((_, i) => ( {steps.map((_, i) => (
<span key={i} className={i === step ? "active" : ""} /> <span key={i} className={i === step ? "active" : ""} />
))} ))}
</div> </div>
<button className="btn btn-primary btn-sm" onClick={next}> <button className="btn btn-primary btn-sm" onClick={next}>
{isLast ? "去学院看看 →" : step === 0 ? "开始匹配 →" : "下一步 →"} {isLast ? t.onboard.goDigital : step === 0 ? t.onboard.startMatch : t.onboard.next}
</button> </button>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import RingNext from "@/components/RingNext";
export default function PricingClient() { export default function PricingClient() {
const { t } = useI18n(); const { t } = useI18n();
@ -29,6 +30,13 @@ export default function PricingClient() {
<Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link> <Link href="/join" className="btn btn-primary">{t.join.payBtn}</Link>
</div> </div>
</div> </div>
<RingNext
steps={[
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
{ href: "/digital", emoji: "🎓", label: t.ring.digital, desc: t.ring.digitalDesc },
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
]}
/>
</div> </div>
</div> </div>
); );

View File

@ -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<RecentDestination[]>([]);
useEffect(() => {
setItems(loadRecentDestinations());
}, []);
if (items.length === 0) return null;
return (
<section className={`recently-viewed${compact ? " compact" : ""}`} aria-label={t.recent.title}>
<div className={compact ? undefined : "container"}>
<div className="recently-viewed-head">
<div>
<span className="section-tag">{t.recent.tag}</span>
<h2>{t.recent.title}</h2>
{!compact && <p className="section-desc">{t.recent.subtitle}</p>}
</div>
<div className="recently-viewed-actions">
{!compact && (
<Link href="/compare" className="btn btn-ghost btn-sm">
{t.nav.compare}
</Link>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
clearRecentDestinations();
setItems([]);
}}
>
{t.common.clear}
</button>
</div>
</div>
<div className="recently-viewed-grid">
{items.map((d) => (
<Link key={d.slug} href={`/destinations/${d.slug}`} className="recently-viewed-card">
<span className="recently-viewed-emoji" aria-hidden>
{d.emoji}
</span>
<div>
<strong>
{d.name}
<span className="recently-viewed-country">, {d.country}</span>
</strong>
<p>
¥{d.cost.toLocaleString()}/mo · ⭐ {d.rating}
</p>
</div>
</Link>
))}
</div>
{items.length >= 2 && (
<div className="recently-viewed-compare">
<Link
href={`/compare?cities=${items
.slice(0, 4)
.map((d) => d.slug)
.join(",")}`}
className="btn btn-primary btn-sm"
>
{t.recent.compareRecent}
</Link>
<Link href={meetupCityHref(items[0].name)} className="btn btn-sm">
{t.common.cityMeetups}
</Link>
<Link href="/plan" className="btn btn-ghost btn-sm">
{t.strip.openPlan}
</Link>
</div>
)}
</div>
</section>
);
}

View File

@ -4,11 +4,14 @@ import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { meetupCityHref } from "@/lib/meetupLinks";
export default function ReportClient() { export default function ReportClient() {
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast();
const rings = useRingSteps(); const rings = useRingSteps();
const [ranking, setRanking] = useState<{ slug: string; name: string; score: number; nomads: string }[]>([]); const [ranking, setRanking] = useState<{ slug: string; name: string; score: number; nomads: string }[]>([]);
const [members, setMembers] = useState<{ name: string; city: string; emoji: string }[]>([]); const [members, setMembers] = useState<{ name: string; city: string; emoji: string }[]>([]);
@ -21,59 +24,116 @@ export default function ReportClient() {
]).finally(() => setLoading(false)); ]).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 ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/">{t.common.backHome}</Link> <Link href="/">{t.common.backHome}</Link>
<span> · </span>
<Link href="/next-stop">{t.nav.nextStop}</Link>
<span> · </span>
<Link href="/meetups">{t.nav.meetups}</Link>
</nav> </nav>
<div className="section-header reveal"> <div className="section-header reveal">
<span className="section-tag">{t.report.tag}</span> <span className="section-tag">{t.report.tag}</span>
<h1>{t.report.title}</h1> <h1>{t.report.title}</h1>
<p>{t.report.subtitle}</p> <p>{t.report.subtitle}</p>
{!loading && (ranking.length > 0 || members.length > 0) && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void copySnapshot()}>
{t.report.snapshot}
</button>
)}
</div> </div>
{loading && <p className="dest-empty">加载报告…</p>} {loading && <p className="dest-empty">{t.report.loading}</p>}
<div className="report-grid"> <div className="report-grid">
<section className="join-card reveal"> <section className="join-card reveal">
<h3>{t.report.ranking}</h3> <h3>{t.report.ranking}</h3>
{!loading && ranking.length === 0 ? ( {!loading && ranking.length === 0 ? (
<div className="notif-empty"> <div className="notif-empty">
<p className="dest-empty">排名数据暂不可用</p> <p className="dest-empty">{t.report.rankEmpty}</p>
<Link href="/next-stop" className="btn btn-sm"> <Link href="/next-stop" className="btn btn-sm">
去智能下一站 {t.report.goNextStop}
</Link> </Link>
</div> </div>
) : ( ) : (
<ol className="report-rank"> <>
{ranking.map((c, i) => ( <ol className="report-rank">
<li key={c.slug}> {ranking.map((c, i) => (
<span>{i + 1}</span> <li key={c.slug}>
<Link href={`/destinations/${c.slug}`}>{c.name}</Link> <span>{i + 1}</span>
<em>{c.score}</em> <Link href={`/destinations/${c.slug}`}>{c.name}</Link>
<small>{c.nomads}</small> <em>{c.score}</em>
</li> <small>{c.nomads}</small>
))} </li>
</ol> ))}
</ol>
{ranking.length > 0 && (
<div className="dating-empty-actions" style={{ marginTop: "0.85rem" }}>
<Link href="/plan" className="btn btn-sm">
{t.strip.openPlan}
</Link>
<Link href="/next-stop" className="btn btn-sm">
{t.report.goNextStop}
</Link>
{ranking[0] && (
<Link href={meetupCityHref(ranking[0].name)} className="btn btn-sm btn-ghost">
{t.common.cityMeetups}
</Link>
)}
</div>
)}
</>
)} )}
</section> </section>
<section className="join-card reveal"> <section className="join-card reveal">
<h3>{t.report.members}</h3> <h3>{t.report.members}</h3>
{!loading && members.length === 0 ? ( {!loading && members.length === 0 ? (
<p className="dest-empty">会员分布暂不可用</p> <div className="notif-empty">
<p className="dest-empty">{t.report.membersEmpty}</p>
<Link href="/dating" className="btn btn-sm">
{t.nav.dating}
</Link>
</div>
) : ( ) : (
<div className="member-map-list"> <div className="member-map-list">
{members.map((m) => ( {members.map((m) => (
<div key={m.name + m.city} className="member-map-item"> <div key={m.name + m.city} className="member-map-item">
<span>{m.emoji}</span> <span>{m.emoji}</span>
<strong>{m.name}</strong> <strong>{m.name}</strong>
<span>{m.city}</span> <Link href={meetupCityHref(m.city)} className="member-loc-link">
{m.city}
</Link>
</div> </div>
))} ))}
</div> </div>
)} )}
<Link href="/map" className="btn btn-sm"> <div className="dating-empty-actions" style={{ marginTop: "0.85rem" }}>
{t.report.viewMap} <Link href="/map" className="btn btn-sm">
</Link> {t.report.viewMap}
</Link>
<Link href="/meetups" className="btn btn-sm btn-ghost">
{t.nav.meetups}
</Link>
</div>
</section> </section>
</div> </div>
<RingNext steps={rings.afterDiscover} /> <RingNext steps={rings.afterDiscover} />

View File

@ -1,7 +1,12 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types"; 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"; const STORAGE_KEY = "nomadro-runway";
@ -17,6 +22,8 @@ interface Saved {
} }
export default function RunwayCalculator({ destinations }: Props) { export default function RunwayCalculator({ destinations }: Props) {
const { t } = useI18n();
const { toast } = useToast();
const [savings, setSavings] = useState(80000); const [savings, setSavings] = useState(80000);
const [monthly, setMonthly] = useState(4500); const [monthly, setMonthly] = useState(4500);
const [income, setIncome] = useState(0); const [income, setIncome] = useState(0);
@ -34,7 +41,9 @@ export default function RunwayCalculator({ destinations }: Props) {
setIncome(s.income ?? 0); setIncome(s.income ?? 0);
setSlug(s.slug ?? ""); setSlug(s.slug ?? "");
} }
} catch { /* ignore */ } } catch {
/* ignore */
}
setReady(true); setReady(true);
}, []); }, []);
@ -43,18 +52,23 @@ export default function RunwayCalculator({ destinations }: Props) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ savings, monthly, income, slug })); localStorage.setItem(STORAGE_KEY, JSON.stringify({ savings, monthly, income, slug }));
}, [savings, monthly, income, slug, ready]); }, [savings, monthly, income, slug, ready]);
const selected = destinations.find((d) => d.slug === slug);
const netBurn = Math.max(monthly - income, 0); const netBurn = Math.max(monthly - income, 0);
const months = useMemo(() => { const months = useMemo(() => {
if (netBurn <= 0) return income > 0 ? 999 : 0; 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]); }, [savings, netBurn, income]);
const pct = Math.min((months / 18) * 100, 100); const pct = Math.min((months / 18) * 100, 100);
const level = const level =
months >= 12 ? { label: "从容远航", color: "#4ECDC4", tip: "可以大胆规划长线旅居" } : months >= 12
months >= 6 ? { label: "稳健起步", color: "#FFE66D", tip: "适合 1–2 站深度停留" } : ? { label: "从容远航", color: "#4ECDC4", tip: "可以大胆规划长线旅居" }
months >= 3 ? { label: "短途试水", color: "#F472B6", tip: "建议选低成本城市先验证" } : : months >= 6
{ label: "需要蓄力", color: "#FF6B6B", tip: "先攒启动金或提高远程收入" }; ? { label: "稳健起步", color: "#FFE66D", tip: "适合 1–2 站深度停留" }
: months >= 3
? { label: "短途试水", color: "#F472B6", tip: "建议选低成本城市先验证" }
: { label: "需要蓄力", color: "#FF6B6B", tip: "先攒启动金或提高远程收入" };
const applyDest = (s: string) => { const applyDest = (s: string) => {
const d = destinations.find((x) => x.slug === s); const d = destinations.find((x) => x.slug === s);
@ -65,6 +79,17 @@ export default function RunwayCalculator({ destinations }: Props) {
setTimeout(() => setPulse(false), 500); 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 = [ const milestones = [
{ m: 3, label: "试水期" }, { m: 3, label: "试水期" },
{ m: 6, label: "半程" }, { m: 6, label: "半程" },
@ -116,7 +141,10 @@ export default function RunwayCalculator({ destinations }: Props) {
min={0} min={0}
step={100} step={100}
value={monthly} value={monthly}
onChange={(e) => { setMonthly(Math.max(0, +e.target.value || 0)); setSlug(""); }} onChange={(e) => {
setMonthly(Math.max(0, +e.target.value || 0));
setSlug("");
}}
/> />
</label> </label>
<label> <label>
@ -167,6 +195,20 @@ export default function RunwayCalculator({ destinations }: Props) {
</div> </div>
))} ))}
</div> </div>
{selected && (
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
🗓️ {t.nav.plan}
</button>
<Link href={`/destinations/${selected.slug}`} className="btn btn-sm">
{t.common.detail}
</Link>
<Link href={meetupCityHref(selected.name)} className="btn btn-sm btn-ghost">
{t.common.cityMeetups}
</Link>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
const STORAGE_KEY = "nomadro-savings"; const STORAGE_KEY = "nomadro-savings";
@ -106,6 +107,7 @@ export default function SavingsGoal({ destinations }: Props) {
{dest.emoji} {dest.name} 月均约 ¥{dest.cost.toLocaleString()},建议备 3 个月缓冲 + 机票 {dest.emoji} {dest.name} 月均约 ¥{dest.cost.toLocaleString()},建议备 3 个月缓冲 + 机票
</p> </p>
)} )}
{dest && <ToolCityExits dest={dest} months={3} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
@ -8,6 +8,9 @@ import { useI18n } from "@/lib/i18n";
import type { ServiceItem } from "@/lib/types"; import type { ServiceItem } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; 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 }; type LeadForm = { name: string; email: string; message: string };
@ -18,6 +21,35 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
const [form, setForm] = useState<Record<string, LeadForm>>({}); const [form, setForm] = useState<Record<string, LeadForm>>({});
const [sent, setSent] = useState<Record<string, boolean>>({}); const [sent, setSent] = useState<Record<string, boolean>>({});
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
const draft = loadDraft<Record<string, LeadForm>>(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 patch = (id: string, key: keyof LeadForm, value: string) => {
const cur = form[id] || { name: "", email: "", message: "" }; const cur = form[id] || { name: "", email: "", message: "" };
@ -27,11 +59,11 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
const submit = async (serviceId: string) => { const submit = async (serviceId: string) => {
const f = form[serviceId]; const f = form[serviceId];
if (!f?.name?.trim() || !f?.email?.trim() || !f?.message?.trim()) { if (!f?.name?.trim() || !f?.email?.trim() || !f?.message?.trim()) {
toast("请填写姓名、邮箱和需求说明", "info"); toast(t.services.needFields, "info");
return; return;
} }
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(f.email.trim())) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(f.email.trim())) {
toast("请填写有效邮箱地址", "info"); toast(t.services.needEmail, "info");
return; return;
} }
setBusyId(serviceId); setBusyId(serviceId);
@ -41,8 +73,15 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
email: f.email.trim(), email: f.email.trim(),
message: f.message.trim(), message: f.message.trim(),
}); });
const next = { ...form };
delete next[serviceId];
setForm(next);
saveDraft(DRAFT_KEY, next);
setSent((s) => ({ ...s, [serviceId]: true })); setSent((s) => ({ ...s, [serviceId]: true }));
toast(t.services.leadOk, "success"); toast(t.services.leadOk, "success", {
href: "/community",
label: t.nav.community,
});
} catch { } catch {
toast(t.services.leadFail, "error"); toast(t.services.leadFail, "error");
} finally { } finally {
@ -58,11 +97,12 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
<span className="section-tag">{t.services.tag}</span> <span className="section-tag">{t.services.tag}</span>
<h1>{t.services.title}</h1> <h1>{t.services.title}</h1>
<p>{t.services.subtitle}</p> <p>{t.services.subtitle}</p>
<p className="section-desc">{t.services.draftHint}</p>
</div> </div>
{services.length === 0 ? ( {services.length === 0 ? (
<div className="notif-empty reveal"> <div className="notif-empty reveal">
<p className="dest-empty">服务清单准备中</p> <p className="dest-empty">{t.services.empty}</p>
<Link href="/community" className="btn btn-primary btn-sm">先去社区问问</Link> <Link href="/community" className="btn btn-primary btn-sm">{t.services.emptyCta}</Link>
</div> </div>
) : ( ) : (
<div className="join-grid"> <div className="join-grid">
@ -72,7 +112,7 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
<p>{s.description}</p> <p>{s.description}</p>
<p><strong>{s.price}</strong> · {s.provider}</p> <p><strong>{s.price}</strong> · {s.provider}</p>
{sent[s.id] ? ( {sent[s.id] ? (
<p className="gigs-applied">✓ 已提交咨询,我们会尽快联系你</p> <p className="gigs-applied">{t.services.sent}</p>
) : ( ) : (
<> <>
<input <input
@ -81,7 +121,6 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
onChange={(e) => patch(s.id, "name", e.target.value)} onChange={(e) => patch(s.id, "name", e.target.value)}
/> />
<input <input
type="email"
placeholder={t.services.email} placeholder={t.services.email}
value={form[s.id]?.email || ""} value={form[s.id]?.email || ""}
onChange={(e) => patch(s.id, "email", e.target.value)} onChange={(e) => patch(s.id, "email", e.target.value)}
@ -96,9 +135,9 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
disabled={busyId === s.id} 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}
</button> </button>
</> </>
)} )}

View File

@ -2,19 +2,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
interface Props { interface Props {
title: string; title: string;
url?: string; url?: string;
className?: string; className?: string;
compact?: boolean; 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 { toast } = useToast();
const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const getUrl = () => url ?? (typeof window !== "undefined" ? window.location.href : ""); const getUrl = () => url ?? (typeof window !== "undefined" ? window.location.href : "");
const copyAction = afterCopy ?? { href: "/plan", label: t.strip.openPlan };
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -28,9 +39,9 @@ export default function ShareButton({ title, url, className = "", compact = fals
const copyLink = async () => { const copyLink = async () => {
try { try {
await navigator.clipboard.writeText(getUrl()); await navigator.clipboard.writeText(getUrl());
toast("链接已复制到剪贴板 📋"); toast(t.common.linkCopied, "success", copyAction);
} catch { } catch {
toast("复制失败,请手动复制地址栏", "error"); toast(t.common.copyFail, "error");
} }
setOpen(false); setOpen(false);
}; };
@ -58,7 +69,7 @@ export default function ShareButton({ title, url, className = "", compact = fals
if (compact) { if (compact) {
return ( return (
<button type="button" className={`share-btn-compact ${className}`} onClick={() => void nativeShare()} aria-label="分享"> <button type="button" className={`share-btn-compact ${className}`} onClick={() => void nativeShare()} aria-label={t.share.aria}>
📤 📤
</button> </button>
); );
@ -67,20 +78,20 @@ export default function ShareButton({ title, url, className = "", compact = fals
return ( return (
<div className={`share-dropdown ${className}`}> <div className={`share-dropdown ${className}`}>
<button type="button" className="btn btn-ghost share-trigger" onClick={() => setOpen(!open)} aria-expanded={open}> <button type="button" className="btn btn-ghost share-trigger" onClick={() => setOpen(!open)} aria-expanded={open}>
📤 分享 {t.share.button}
</button> </button>
{open && ( {open && (
<> <>
<div className="share-backdrop" onClick={() => setOpen(false)} /> <div className="share-backdrop" onClick={() => setOpen(false)} />
<div className="share-menu" role="menu"> <div className="share-menu" role="menu">
<button type="button" onClick={() => void nativeShare()}> <button type="button" onClick={() => void nativeShare()}>
📱 系统分享 {t.share.system}
</button> </button>
<button type="button" onClick={() => void copyLink()}> <button type="button" onClick={() => void copyLink()}>
📋 复制链接 {t.share.copy}
</button> </button>
<button type="button" onClick={shareTwitter}> <button type="button" onClick={shareTwitter}>
🐦 分享到 X {t.share.twitter}
</button> </button>
</div> </div>
</> </>

View File

@ -47,7 +47,7 @@ export default function SiteShell({ children, showFooter = false }: { children:
if (pathname === "/") { if (pathname === "/") {
window.dispatchEvent(new Event("open-matcher")); window.dispatchEvent(new Event("open-matcher"));
} else { } else {
router.push("/next-stop"); router.push("/#matcher");
} }
}} }}
/> />

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
interface Props { interface Props {
destinations: Destination[]; destinations: Destination[];
@ -71,11 +71,9 @@ export default function SpinGlobe({ destinations }: Props) {
</div> </div>
<p className="spin-result-desc">{result.description}</p> <p className="spin-result-desc">{result.description}</p>
<div className="spin-result-actions"> <div className="spin-result-actions">
<Link href={`/destinations/${result.slug}`} className="btn btn-primary">
查看详情 →
</Link>
<button className="btn btn-ghost" onClick={spin}>再转一次 🎲</button> <button className="btn btn-ghost" onClick={spin}>再转一次 🎲</button>
</div> </div>
<ToolCityExits dest={result} />
</div> </div>
)} )}

View File

@ -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 (
<div className="dating-empty-actions" style={{ marginTop: "1rem" }}>
<button type="button" className="btn btn-primary btn-sm" onClick={addToPlan}>
🗓️ {t.nav.plan}
</button>
<Link href={`/destinations/${dest.slug}`} className="btn btn-sm">
{t.common.detail}
</Link>
<Link href={meetupCityHref(dest.name)} className="btn btn-sm btn-ghost">
{t.common.cityMeetups}
</Link>
</div>
);
}

View File

@ -6,6 +6,10 @@ import { useMemo } from "react";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import { TOOL_LINKS } from "@/lib/tools"; import { TOOL_LINKS } from "@/lib/tools";
import { TOOL_COMPONENTS } from "@/lib/toolsRegistry"; 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 { interface Props {
toolId: string; toolId: string;
@ -13,16 +17,18 @@ interface Props {
} }
export default function ToolRunnerClient({ toolId, destinations }: 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 loader = TOOL_COMPONENTS[toolId];
const ToolComponent = useMemo(() => { const ToolComponent = useMemo(() => {
if (!loader) return null; if (!loader) return null;
return dynamic(loader, { return dynamic(loader, {
ssr: false, ssr: false,
loading: () => <div className="tool-loading">加载中…</div>, loading: () => <div className="tool-loading">{t.tools.loading}</div>,
}); });
}, [toolId, loader]); }, [toolId, loader, t.tools.loading]);
if (!tool || !ToolComponent) return null; if (!tool || !ToolComponent) return null;
@ -30,7 +36,7 @@ export default function ToolRunnerClient({ toolId, destinations }: Props) {
<div className="tool-runner-page"> <div className="tool-runner-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/tools">← 返回工具箱</Link> <Link href="/tools">{t.tools.backHub}</Link>
</nav> </nav>
<div className="tool-runner-head reveal"> <div className="tool-runner-head reveal">
<span className="section-tag">{tool.emoji} TOOL</span> <span className="section-tag">{tool.emoji} TOOL</span>
@ -38,6 +44,18 @@ export default function ToolRunnerClient({ toolId, destinations }: Props) {
<p>{tool.desc}</p> <p>{tool.desc}</p>
</div> </div>
<ToolComponent destinations={destinations} /> <ToolComponent destinations={destinations} />
<div className="tool-runner-exits dating-empty-actions reveal">
<Link href="/plan" className="btn btn-sm">
{t.strip.openPlan}
</Link>
<Link href="/next-stop" className="btn btn-sm">
{t.tools.goNextStop}
</Link>
<Link href="/meetups" className="btn btn-sm btn-ghost">
{t.nav.meetups}
</Link>
</div>
<RingNext steps={rings.afterTools} />
</div> </div>
</div> </div>
); );

View File

@ -1,19 +1,55 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import type { VideoItem } from "@/lib/types"; import type { VideoItem } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import { meetupCityHref } from "@/lib/meetupLinks";
import { isWatchLater, toggleWatchLater } from "@/lib/watchLater";
export default function VideoDetailClient({ video }: { video: VideoItem }) { export default function VideoDetailClient({ video }: { video: VideoItem }) {
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast();
const rings = useRingSteps(); 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 ( return (
<div className="community-detail-page"> <div className="community-detail-page">
<div className="container"> <div className="container">
<nav className="detail-nav"> <nav className="detail-nav">
<Link href="/videos">{t.videos.back}</Link> <Link href="/videos">{t.videos.back}</Link>
{city && (
<>
<span> · </span>
<Link href={meetupsHref}>{t.common.cityMeetups}</Link>
</>
)}
</nav> </nav>
<article className="community-detail reveal"> <article className="community-detail reveal">
<span className="community-detail-cat"> <span className="community-detail-cat">
@ -29,22 +65,25 @@ export default function VideoDetailClient({ video }: { video: VideoItem }) {
</div> </div>
) : ( ) : (
<div className="notif-empty"> <div className="notif-empty">
<p className="dest-empty">播放链接暂未配置</p> <p className="dest-empty">{t.videos.noPlayer}</p>
<Link href="/book/read" className="btn btn-primary btn-sm"> <Link href="/book/read" className="btn btn-primary btn-sm">
去读电子书 {t.videos.readEbook}
</Link> </Link>
</div> </div>
)} )}
</article> </article>
<div className="dating-empty-actions" style={{ marginTop: "1.5rem" }}> <div className="dating-empty-actions" style={{ marginTop: "1.5rem" }}>
<Link href="/meetups" className="btn btn-sm"> <button type="button" className={`btn btn-sm${saved ? " btn-primary" : ""}`} onClick={onToggle}>
相关活动 {saved ? `✓ ${t.videos.watchLater}` : t.videos.saveLater}
</button>
<Link href={meetupsHref} className="btn btn-sm">
{t.videos.relatedMeetups}
</Link> </Link>
<Link href="/digital" className="btn btn-sm"> <Link href="/digital" className="btn btn-sm">
学院 {t.videos.goDigital}
</Link> </Link>
</div> </div>
<RingNext steps={rings.afterDiscover} /> <RingNext steps={city ? rings.afterCity(city) : rings.afterDiscover} />
</div> </div>
</div> </div>
); );

View File

@ -1,14 +1,46 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useToast } from "@/lib/toast";
import type { VideoItem } from "@/lib/types"; import type { VideoItem } from "@/lib/types";
import RingNext from "@/components/RingNext"; import RingNext from "@/components/RingNext";
import { useRingSteps } from "@/lib/rings"; import { useRingSteps } from "@/lib/rings";
import {
clearWatchLater,
loadWatchLater,
toggleWatchLater,
type SavedVideo,
} from "@/lib/watchLater";
export default function VideosClient({ videos }: { videos: VideoItem[] }) { export default function VideosClient({ videos }: { videos: VideoItem[] }) {
const { t } = useI18n(); const { t } = useI18n();
const { toast } = useToast();
const rings = useRingSteps(); const rings = useRingSteps();
const [saved, setSaved] = useState<SavedVideo[]>([]);
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 ( return (
<div className="community-page"> <div className="community-page">
<div className="container"> <div className="container">
@ -20,32 +52,85 @@ export default function VideosClient({ videos }: { videos: VideoItem[] }) {
<h1>{t.videos.title}</h1> <h1>{t.videos.title}</h1>
<p>{t.videos.subtitle}</p> <p>{t.videos.subtitle}</p>
</div> </div>
{saved.length > 0 ? (
<section className="watch-later-panel reveal">
<div className="watch-later-head">
<h2>
{t.videos.watchLater} · {saved.length}
</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
clearWatchLater();
setSaved([]);
}}
>
{t.videos.clearSaved}
</button>
</div>
<div className="watch-later-list">
{saved.map((v) => (
<Link key={v.slug} href={`/videos/${v.slug}`} className="watch-later-chip">
<span>{v.emoji}</span>
<strong>{v.title}</strong>
<small>
{v.city} · {v.duration}
</small>
</Link>
))}
</div>
</section>
) : (
videos.length > 0 && (
<section className="watch-later-panel reveal">
<div className="watch-later-head">
<h2>{t.videos.watchLater}</h2>
</div>
<p className="dest-empty">{t.videos.savedEmpty}</p>
</section>
)
)}
{videos.length === 0 ? ( {videos.length === 0 ? (
<div className="notif-empty reveal"> <div className="notif-empty reveal">
<p className="dest-empty">视频内容准备中</p> <p className="dest-empty">{t.videos.empty}</p>
<div className="dating-empty-actions"> <div className="dating-empty-actions">
<Link href="/book" className="btn btn-primary btn-sm"> <Link href="/book" className="btn btn-primary btn-sm">
先读电子书 {t.videos.readEbook}
</Link> </Link>
<Link href="/digital" className="btn btn-sm"> <Link href="/digital" className="btn btn-sm">
去学院 {t.videos.goDigital}
</Link> </Link>
</div> </div>
</div> </div>
) : ( ) : (
<div className="community-list"> <div className="community-list">
{videos.map((v) => ( {videos.map((v) => {
<Link key={v.id} href={`/videos/${v.slug}`} className="community-card reveal"> const isSaved = saved.some((s) => s.slug === v.slug);
<div className="community-card-head"> return (
<span>{v.emoji}</span> <div key={v.id} className="community-card reveal video-card-wrap">
<span className="community-meta"> <Link href={`/videos/${v.slug}`}>
{v.city} · {v.duration} <div className="community-card-head">
</span> <span>{v.emoji}</span>
<span className="community-meta">
{v.city} · {v.duration}
</span>
</div>
<h3>{v.title}</h3>
<p className="community-excerpt">{v.excerpt}</p>
</Link>
<button
type="button"
className={`btn btn-sm${isSaved ? " btn-primary" : " btn-ghost"}`}
onClick={() => onToggle(v)}
>
{isSaved ? `✓ ${t.videos.watchLater}` : t.videos.saveLater}
</button>
</div> </div>
<h3>{v.title}</h3> );
<p className="community-excerpt">{v.excerpt}</p> })}
</Link>
))}
</div> </div>
)} )}
<RingNext steps={rings.afterDiscover} /> <RingNext steps={rings.afterDiscover} />

View File

@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
const STORAGE_KEY = "nomadro-visa-stay"; const STORAGE_KEY = "nomadro-visa-stay";
@ -135,6 +136,7 @@ export default function VisaStayCountdown({ destinations }: Props) {
<div><small>额度</small><strong>{days} 天</strong></div> <div><small>额度</small><strong>{days} 天</strong></div>
<div><small>建议离境前</small><strong>{exitDate}</strong></div> <div><small>建议离境前</small><strong>{exitDate}</strong></div>
</div> </div>
{dest && <ToolCityExits dest={dest} months={Math.max(1, Math.round(days / 30))} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
interface Idea { interface Idea {
emoji: string; emoji: string;
@ -137,6 +138,7 @@ export default function WeekendIdeas({ destinations }: Props) {
</button> </button>
))} ))}
</div> </div>
{dest && <ToolCityExits dest={dest} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import { useMemo, useState, type CSSProperties } from "react"; import { useMemo, useState, type CSSProperties } from "react";
import Link from "next/link";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
import ToolCityExits from "@/components/ToolCityExits";
function workLevel(speed: number) { function workLevel(speed: number) {
if (speed >= 150) return { label: "极速办公", emoji: "🚀", color: "#6ee7b7", tip: "视频会议、大文件传输无压力" }; if (speed >= 150) return { label: "极速办公", emoji: "🚀", color: "#6ee7b7", tip: "视频会议、大文件传输无压力" };
@ -57,7 +57,7 @@ export default function WifiWorkScore({ destinations }: Props) {
{level.emoji} {level.label} {level.emoji} {level.label}
</span> </span>
<p>{level.tip}</p> <p>{level.tip}</p>
<Link href={`/destinations/${dest.slug}`} className="wifi-link">查看城市详情 →</Link> <ToolCityExits dest={dest} />
</div> </div>
</div> </div>
)} )}

View File

@ -7,6 +7,7 @@ import { compareUrl } from "@/lib/compareScore";
import { mergeDestinationsIntoTrip } from "@/lib/tripActions"; import { mergeDestinationsIntoTrip } from "@/lib/tripActions";
import { useToast } from "@/lib/toast"; import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { meetupCityHref } from "@/lib/meetupLinks";
import type { Destination } from "@/lib/types"; import type { Destination } from "@/lib/types";
interface Props { destinations: Destination[] } interface Props { destinations: Destination[] }
@ -21,8 +22,11 @@ export default function WorldMap({ destinations }: Props) {
if (!selected) return; if (!selected) return;
const { added } = mergeDestinationsIntoTrip([selected], 1); const { added } = mergeDestinationsIntoTrip([selected], 1);
toast( toast(
added ? `${selected.emoji} ${selected.name} 已加入计划` : "该城已在计划中", added
added ? "success" : "info" ? `${selected.emoji} ${selected.name} ${t.plan.addedToPlan}`
: t.common.alreadyInPlan,
added ? "success" : "info",
{ href: "/plan", label: t.strip.openPlan }
); );
if (added) router.push("/plan"); if (added) router.push("/plan");
}; };
@ -95,6 +99,9 @@ export default function WorldMap({ destinations }: Props) {
<button type="button" className="btn btn-sm" onClick={addToPlan}> <button type="button" className="btn btn-sm" onClick={addToPlan}>
🗓️ {t.nav.plan} 🗓️ {t.nav.plan}
</button> </button>
<Link href={meetupCityHref(selected.name)} className="btn btn-sm">
{t.common.cityMeetups}
</Link>
<Link href={compareUrl([selected.slug])} className="btn btn-ghost btn-sm"> <Link href={compareUrl([selected.slug])} className="btn btn-ghost btn-sm">
⚖️ {t.nav.compare} ⚖️ {t.nav.compare}
</Link> </Link>

View File

@ -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, "id" | "at">): 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);
}

View File

@ -413,4 +413,9 @@ export const api = {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}), }),
getMeetupSocialSuggestions: (meetupId: string) =>
fetchWithFallback<{ items: MatchProfile[] }>(
`/meetups/${meetupId}/social-suggestions`,
{ items: [] }
),
}; };

View File

@ -15,6 +15,12 @@ type LoginDict = {
hintGigs: string; hintGigs: string;
hintCommunity: string; hintCommunity: string;
hintSubmit: string; hintSubmit: string;
hintDigital: string;
hintVideos: string;
hintJobs: string;
hintAi: string;
hintNextStop: string;
hintMap: string;
redirectTo: string; redirectTo: string;
planMergeNote: string; planMergeNote: string;
destHome: string; destHome: string;
@ -31,6 +37,12 @@ type LoginDict = {
destGigs: string; destGigs: string;
destCommunity: string; destCommunity: string;
destSubmit: string; destSubmit: string;
destDigital: string;
destVideos: string;
destJobs: string;
destAi: string;
destNextStop: string;
destMap: string;
submitLoginDefault: string; submitLoginDefault: string;
submitLoginPlan: string; submitLoginPlan: string;
demoHintDefault: string; demoHintDefault: string;
@ -69,7 +81,7 @@ function destLabel(path: string, t: LoginDict): string {
if (path === "/profile") return t.destProfile; if (path === "/profile") return t.destProfile;
if (path === "/plan") return t.destPlan; if (path === "/plan") return t.destPlan;
if (path === "/compare") return t.destCompare; 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("/destinations/")) return t.destDestination;
if (path.startsWith("/dating")) return t.destDating; if (path.startsWith("/dating")) return t.destDating;
if (path.startsWith("/chat")) return t.destChat; 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("/gigs")) return t.destGigs;
if (path.startsWith("/community")) return t.destCommunity; if (path.startsWith("/community")) return t.destCommunity;
if (path === "/submit") return t.destSubmit; 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; return t.destProfile;
} }
function hintFor(path: string, t: LoginDict): string { function hintFor(path: string, t: LoginDict): string {
if (path === "/plan") return t.hintPlan; if (path === "/plan") return t.hintPlan;
if (path === "/compare") return t.hintCompare; 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.startsWith("/destinations/")) return t.hintDestination;
if (path === "/profile") return t.hintProfile; if (path === "/profile") return t.hintProfile;
if (path.startsWith("/dating")) return t.hintDating; 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("/gigs")) return t.hintGigs;
if (path.startsWith("/community")) return t.hintCommunity; if (path.startsWith("/community")) return t.hintCommunity;
if (path === "/submit") return t.hintSubmit; 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; return t.hintExplore;
} }

View File

@ -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<CommunityDraft, "updatedAt">) {
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);
}

View File

@ -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);
}

View File

@ -57,4 +57,34 @@ export const COWORKING_SPACES: CoworkingSpace[] = [
price: "¥3,500/天", wifi: 200, rating: 4.6, price: "¥3,500/天", wifi: 200, rating: 4.6,
perks: ["极致网速", "地铁直达", "静音舱"], vibe: "高效都市", 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: "中东跳板",
},
]; ];

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
"use client";
/** Tiny localStorage draft helper used by write forms. */
export function loadDraft<T>(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);
}

View File

@ -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);
}

View File

@ -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<Meetup, "id" | "city" | "mode">) {
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;
}

View File

@ -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<Partial<NextStopPrefs>>(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);
}

View File

@ -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, "viewedAt">): 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);
}

View File

@ -1,54 +1,206 @@
"use client"; "use client";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { RingStep } from "@/components/RingNext"; import type { RingStep } from "@/components/RingNext";
import { meetupCityHref } from "@/lib/meetupLinks";
/** Shared ring definitions — hubs pick the next slice, never the full catalog. */ /** Shared ring definitions — hubs pick the next slice, never the full catalog. */
export function useRingSteps() { export function useRingSteps() {
const { t } = useI18n(); const { t } = useI18n();
const afterCity: RingStep[] = [
const afterCity = (city?: string): RingStep[] => [
{ href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc }, { 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: "/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[] = [ const afterMeetups: RingStep[] = [
{ href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityDesc }, { href: "/community", emoji: "💬", label: t.ring.community, desc: t.ring.communityDesc },
{ href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc }, { 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 }, { href: "/dating", emoji: "💕", label: t.ring.dating, desc: t.ring.datingDesc },
]; ];
const afterDating: RingStep[] = [ const afterDating: RingStep[] = [
{ href: "/chat", emoji: "✉️", label: t.ring.chat, desc: t.ring.chatDesc }, { href: "/chat", emoji: "✉️", label: t.ring.chat, desc: t.ring.chatDesc },
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsFromDating }, { 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[] = [ const afterDiscover: RingStep[] = [
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc }, { href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
{ href: "/next-stop", emoji: "🧭", label: t.ring.nextStop, desc: t.ring.nextStopDesc }, { href: "/next-stop", emoji: "🧭", label: t.ring.nextStop, desc: t.ring.nextStopDesc },
]; ];
const afterGigs: RingStep[] = [ const afterGigs: RingStep[] = [
{ href: "/digital", emoji: "🎓", label: t.ring.digital, desc: t.ring.digitalDesc }, { 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[] = [ const afterTools: RingStep[] = [
{ href: "/next-stop", emoji: "🧭", label: t.ring.nextStop, desc: t.ring.nextStopDesc }, { href: "/next-stop", emoji: "🧭", label: t.ring.nextStop, desc: t.ring.nextStopDesc },
{ href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc }, { href: "/plan", emoji: "🗓️", label: t.ring.plan, desc: t.ring.planDesc },
{ href: "/meetups", emoji: "🎉", label: t.ring.meetups, desc: t.ring.meetupsDesc },
]; ];
return { return {
afterCity, afterCity,
afterNextStop,
afterCompare,
afterMeetups, afterMeetups,
afterCommunity, afterCommunity,
afterDating, afterDating,
afterDiscover, afterDiscover,
afterGigs, afterGigs,
afterDigital,
afterTools, afterTools,
}; };
} }

View File

@ -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<SavedGig[]>(LIST_KEY);
return Array.isArray(list) ? list.slice(0, MAX) : [];
}
export function toggleSavedGig(item: Omit<SavedGig, "savedAt">): 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<string, string> {
return loadDraft<Record<string, string>>(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);
}

View File

@ -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<SavedJob[]>(KEY);
return Array.isArray(list) ? list.slice(0, MAX) : [];
}
export function toggleSavedJob(item: Omit<SavedJob, "savedAt">): 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);
}

View File

@ -1,18 +1,25 @@
"use client"; "use client";
import Link from "next/link";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
type ToastType = "success" | "error" | "info"; type ToastType = "success" | "error" | "info";
export type ToastAction = {
href: string;
label: string;
};
interface ToastItem { interface ToastItem {
id: number; id: number;
message: string; message: string;
type: ToastType; type: ToastType;
icon: string; icon: string;
action?: ToastAction;
} }
interface ToastContextValue { interface ToastContextValue {
toast: (message: string, type?: ToastType) => void; toast: (message: string, type?: ToastType, action?: ToastAction) => void;
} }
const ICONS: Record<ToastType, string> = { const ICONS: Record<ToastType, string> = {
@ -26,10 +33,11 @@ const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
export function ToastProvider({ children }: { children: ReactNode }) { export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]); const [items, setItems] = useState<ToastItem[]>([]);
const toast = useCallback((message: string, type: ToastType = "success") => { const toast = useCallback((message: string, type: ToastType = "success", action?: ToastAction) => {
const id = Date.now() + Math.random(); const id = Date.now() + Math.random();
setItems((prev) => [...prev, { id, message, type, icon: ICONS[type] }]); setItems((prev) => [...prev, { id, message, type, icon: ICONS[type], action }]);
setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), 3800); const ttl = action ? 5600 : 3800;
setTimeout(() => setItems((prev) => prev.filter((t) => t.id !== id)), ttl);
}, []); }, []);
return ( return (
@ -40,6 +48,11 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<div key={t.id} className={`toast toast-${t.type} show`}> <div key={t.id} className={`toast toast-${t.type} show`}>
<span className="toast-icon">{t.icon}</span> <span className="toast-icon">{t.icon}</span>
<span className="toast-msg">{t.message}</span> <span className="toast-msg">{t.message}</span>
{t.action && (
<Link href={t.action.href} className="toast-action">
{t.action.label}
</Link>
)}
</div> </div>
))} ))}
</div> </div>

View File

@ -0,0 +1,98 @@
import type { Locale } from "@/lib/i18n";
import { TOOL_LINKS, type ToolLink } from "@/lib/tools";
type ToolCopy = Record<string, { title: string; desc: string }>;
/** 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<string, string> = {
all: "🌏 All",
plan: "🗺️ Plan",
money: "💰 Money",
work: "💻 Work",
life: "🌴 Life",
safety: "🛡️ Safety",
};
const CAT_ZH: Record<string, string> = {
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);
}

View File

@ -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, "savedAt">): 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);
}

View File

@ -32,6 +32,10 @@ API_FILES = [
"backend/app/services/meetup_live.py", "backend/app/services/meetup_live.py",
"backend/app/data/community_data.py", "backend/app/data/community_data.py",
"backend/app/data/social_profiles.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 = [ WEB_FILES = [