Fix dating match gate and expand seed data for usable demos.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8ea3f622ed
commit
bdf0516197
@ -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",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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": "🖥️",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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": "第比利斯"},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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: "决策与发现闭环",
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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" && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
156
frontend/src/components/NomadDigest.tsx
Normal file
156
frontend/src/components/NomadDigest.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
94
frontend/src/components/RecentlyViewed.tsx
Normal file
94
frontend/src/components/RecentlyViewed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
37
frontend/src/components/ToolCityExits.tsx
Normal file
37
frontend/src/components/ToolCityExits.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
44
frontend/src/lib/aiHistory.ts
Normal file
44
frontend/src/lib/aiHistory.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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: [] }
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
frontend/src/lib/communityDraft.ts
Normal file
38
frontend/src/lib/communityDraft.ts
Normal 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);
|
||||||
|
}
|
||||||
42
frontend/src/lib/compareHistory.ts
Normal file
42
frontend/src/lib/compareHistory.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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
27
frontend/src/lib/localDraft.ts
Normal file
27
frontend/src/lib/localDraft.ts
Normal 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);
|
||||||
|
}
|
||||||
76
frontend/src/lib/meetupIcs.ts
Normal file
76
frontend/src/lib/meetupIcs.ts
Normal 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);
|
||||||
|
}
|
||||||
30
frontend/src/lib/meetupLinks.ts
Normal file
30
frontend/src/lib/meetupLinks.ts
Normal 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;
|
||||||
|
}
|
||||||
40
frontend/src/lib/nextStopPrefs.ts
Normal file
40
frontend/src/lib/nextStopPrefs.ts
Normal 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);
|
||||||
|
}
|
||||||
45
frontend/src/lib/recentDestinations.ts
Normal file
45
frontend/src/lib/recentDestinations.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
54
frontend/src/lib/savedGigs.ts
Normal file
54
frontend/src/lib/savedGigs.ts
Normal 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);
|
||||||
|
}
|
||||||
35
frontend/src/lib/savedJobs.ts
Normal file
35
frontend/src/lib/savedJobs.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
98
frontend/src/lib/toolCopy.ts
Normal file
98
frontend/src/lib/toolCopy.ts
Normal 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);
|
||||||
|
}
|
||||||
48
frontend/src/lib/watchLater.ts
Normal file
48
frontend/src/lib/watchLater.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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 = [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user