Close Explore/Grow loops: visa wizard, jobs, lessons, gigs notify, map.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7d5ac47ee1
commit
e23cf9a03c
@ -386,6 +386,7 @@ GIGS = [
|
|||||||
"deadline": "2026-09-15",
|
"deadline": "2026-09-15",
|
||||||
"tags": ["设计", "远程"],
|
"tags": ["设计", "远程"],
|
||||||
"poster": "nomadro",
|
"poster": "nomadro",
|
||||||
|
"poster_email": "demo@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "设计",
|
"category": "设计",
|
||||||
"location": "远程",
|
"location": "远程",
|
||||||
@ -398,6 +399,7 @@ GIGS = [
|
|||||||
"deadline": "2026-09-20",
|
"deadline": "2026-09-20",
|
||||||
"tags": ["活动", "深圳"],
|
"tags": ["活动", "深圳"],
|
||||||
"poster": "深圳湾区主理人",
|
"poster": "深圳湾区主理人",
|
||||||
|
"poster_email": "demo@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "活动",
|
"category": "活动",
|
||||||
"location": "深圳",
|
"location": "深圳",
|
||||||
@ -410,6 +412,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-01",
|
"deadline": "2026-10-01",
|
||||||
"tags": ["翻译", "内容"],
|
"tags": ["翻译", "内容"],
|
||||||
"poster": "nomadro",
|
"poster": "nomadro",
|
||||||
|
"poster_email": "demo@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "内容",
|
"category": "内容",
|
||||||
"location": "远程",
|
"location": "远程",
|
||||||
@ -422,6 +425,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-10",
|
"deadline": "2026-10-10",
|
||||||
"tags": ["前端", "Next.js"],
|
"tags": ["前端", "Next.js"],
|
||||||
"poster": "Marco",
|
"poster": "Marco",
|
||||||
|
"poster_email": "marco@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "开发",
|
"category": "开发",
|
||||||
"location": "远程",
|
"location": "远程",
|
||||||
@ -434,6 +438,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-12",
|
"deadline": "2026-10-12",
|
||||||
"tags": ["视频", "剪辑"],
|
"tags": ["视频", "剪辑"],
|
||||||
"poster": "Lina",
|
"poster": "Lina",
|
||||||
|
"poster_email": "lina@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "内容",
|
"category": "内容",
|
||||||
"location": "远程",
|
"location": "远程",
|
||||||
@ -446,6 +451,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-08",
|
"deadline": "2026-10-08",
|
||||||
"tags": ["SEO", "内容"],
|
"tags": ["SEO", "内容"],
|
||||||
"poster": "nomadro",
|
"poster": "nomadro",
|
||||||
|
"poster_email": "demo@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "运营",
|
"category": "运营",
|
||||||
"location": "远程",
|
"location": "远程",
|
||||||
@ -458,6 +464,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-05",
|
"deadline": "2026-10-05",
|
||||||
"tags": ["摄影", "大理"],
|
"tags": ["摄影", "大理"],
|
||||||
"poster": "Lina",
|
"poster": "Lina",
|
||||||
|
"poster_email": "lina@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "设计",
|
"category": "设计",
|
||||||
"location": "大理",
|
"location": "大理",
|
||||||
@ -470,6 +477,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-18",
|
"deadline": "2026-10-18",
|
||||||
"tags": ["税务", "文档"],
|
"tags": ["税务", "文档"],
|
||||||
"poster": "Devon",
|
"poster": "Devon",
|
||||||
|
"poster_email": "devon@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "内容",
|
"category": "内容",
|
||||||
"location": "远程",
|
"location": "远程",
|
||||||
@ -482,6 +490,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-08",
|
"deadline": "2026-10-08",
|
||||||
"tags": ["摄影", "活动"],
|
"tags": ["摄影", "活动"],
|
||||||
"poster": "Sofia",
|
"poster": "Sofia",
|
||||||
|
"poster_email": "sofia@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "活动",
|
"category": "活动",
|
||||||
"location": "麦德林",
|
"location": "麦德林",
|
||||||
@ -494,6 +503,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-20",
|
"deadline": "2026-10-20",
|
||||||
"tags": ["AI", "内容"],
|
"tags": ["AI", "内容"],
|
||||||
"poster": "Alex",
|
"poster": "Alex",
|
||||||
|
"poster_email": "alex@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "开发",
|
"category": "开发",
|
||||||
"location": "远程",
|
"location": "远程",
|
||||||
@ -506,6 +516,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-15",
|
"deadline": "2026-10-15",
|
||||||
"tags": ["UX", "研究"],
|
"tags": ["UX", "研究"],
|
||||||
"poster": "Sara",
|
"poster": "Sara",
|
||||||
|
"poster_email": "sara@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "产品",
|
"category": "产品",
|
||||||
"location": "远程",
|
"location": "远程",
|
||||||
@ -518,6 +529,7 @@ GIGS = [
|
|||||||
"deadline": "2026-10-30",
|
"deadline": "2026-10-30",
|
||||||
"tags": ["社区", "兼职"],
|
"tags": ["社区", "兼职"],
|
||||||
"poster": "nomadro",
|
"poster": "nomadro",
|
||||||
|
"poster_email": "demo@nomadro.com",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"category": "运营",
|
"category": "运营",
|
||||||
"location": "远程",
|
"location": "远程",
|
||||||
|
|||||||
@ -95,7 +95,7 @@ JOBS = [
|
|||||||
"type": "全职",
|
"type": "全职",
|
||||||
"salary": "$80k–120k",
|
"salary": "$80k–120k",
|
||||||
"tags": ["React", "TypeScript", "远程"],
|
"tags": ["React", "TypeScript", "远程"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j2",
|
"id": "j2",
|
||||||
@ -105,7 +105,7 @@ JOBS = [
|
|||||||
"type": "合同",
|
"type": "合同",
|
||||||
"salary": "€60–90/h",
|
"salary": "€60–90/h",
|
||||||
"tags": ["K8s", "AWS", "异步"],
|
"tags": ["K8s", "AWS", "异步"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j3",
|
"id": "j3",
|
||||||
@ -115,7 +115,7 @@ JOBS = [
|
|||||||
"type": "兼职",
|
"type": "兼职",
|
||||||
"salary": "面议",
|
"salary": "面议",
|
||||||
"tags": ["社区", "写作", "游民"],
|
"tags": ["社区", "写作", "游民"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j4",
|
"id": "j4",
|
||||||
@ -125,7 +125,7 @@ JOBS = [
|
|||||||
"type": "全职",
|
"type": "全职",
|
||||||
"salary": "€55k–75k",
|
"salary": "€55k–75k",
|
||||||
"tags": ["Figma", "B2B", "远程"],
|
"tags": ["Figma", "B2B", "远程"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j5",
|
"id": "j5",
|
||||||
@ -135,7 +135,7 @@ JOBS = [
|
|||||||
"type": "合同",
|
"type": "合同",
|
||||||
"salary": "$4k–6k/月",
|
"salary": "$4k–6k/月",
|
||||||
"tags": ["SEO", "内容", "增长"],
|
"tags": ["SEO", "内容", "增长"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j6",
|
"id": "j6",
|
||||||
@ -145,7 +145,7 @@ JOBS = [
|
|||||||
"type": "全职",
|
"type": "全职",
|
||||||
"salary": "$90k–130k",
|
"salary": "$90k–130k",
|
||||||
"tags": ["Python", "FastAPI", "Postgres"],
|
"tags": ["Python", "FastAPI", "Postgres"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j7",
|
"id": "j7",
|
||||||
@ -155,7 +155,7 @@ JOBS = [
|
|||||||
"type": "兼职",
|
"type": "兼职",
|
||||||
"salary": "¥8k–12k/月",
|
"salary": "¥8k–12k/月",
|
||||||
"tags": ["社区", "活动", "中英"],
|
"tags": ["社区", "活动", "中英"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j8",
|
"id": "j8",
|
||||||
@ -165,7 +165,7 @@ JOBS = [
|
|||||||
"type": "全职",
|
"type": "全职",
|
||||||
"salary": "$70k–100k",
|
"salary": "$70k–100k",
|
||||||
"tags": ["Flutter", "移动端"],
|
"tags": ["Flutter", "移动端"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j9",
|
"id": "j9",
|
||||||
@ -175,7 +175,7 @@ JOBS = [
|
|||||||
"type": "合同",
|
"type": "合同",
|
||||||
"salary": "$40–70/h",
|
"salary": "$40–70/h",
|
||||||
"tags": ["文档", "开发者关系"],
|
"tags": ["文档", "开发者关系"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j10",
|
"id": "j10",
|
||||||
@ -185,7 +185,7 @@ JOBS = [
|
|||||||
"type": "全职",
|
"type": "全职",
|
||||||
"salary": "$65k–95k",
|
"salary": "$65k–95k",
|
||||||
"tags": ["SQL", "看板", "增长"],
|
"tags": ["SQL", "看板", "增长"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j11",
|
"id": "j11",
|
||||||
@ -195,7 +195,7 @@ JOBS = [
|
|||||||
"type": "兼职",
|
"type": "兼职",
|
||||||
"salary": "¥6k–10k/月",
|
"salary": "¥6k–10k/月",
|
||||||
"tags": ["客服", "签证", "中文"],
|
"tags": ["客服", "签证", "中文"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "j12",
|
"id": "j12",
|
||||||
@ -205,6 +205,6 @@ JOBS = [
|
|||||||
"type": "全职",
|
"type": "全职",
|
||||||
"salary": "$85k–115k",
|
"salary": "$85k–115k",
|
||||||
"tags": ["Next.js", "Python", "独立产品"],
|
"tags": ["Next.js", "Python", "独立产品"],
|
||||||
"url": "https://nomadro.com",
|
"url": "/gigs",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -105,7 +105,7 @@ VIDEOS = [
|
|||||||
"city": "清迈",
|
"city": "清迈",
|
||||||
"published_at": "2026-03-12",
|
"published_at": "2026-03-12",
|
||||||
"tags": ["泰国", "成本", "社区"],
|
"tags": ["泰国", "成本", "社区"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "lisbon-d7",
|
"id": "lisbon-d7",
|
||||||
@ -118,7 +118,7 @@ VIDEOS = [
|
|||||||
"city": "里斯本",
|
"city": "里斯本",
|
||||||
"published_at": "2026-02-08",
|
"published_at": "2026-02-08",
|
||||||
"tags": ["葡萄牙", "签证"],
|
"tags": ["葡萄牙", "签证"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bali-cowork",
|
"id": "bali-cowork",
|
||||||
@ -131,7 +131,7 @@ VIDEOS = [
|
|||||||
"city": "巴厘岛",
|
"city": "巴厘岛",
|
||||||
"published_at": "2026-01-20",
|
"published_at": "2026-01-20",
|
||||||
"tags": ["印尼", "办公"],
|
"tags": ["印尼", "办公"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "medellin-30d",
|
"id": "medellin-30d",
|
||||||
@ -144,7 +144,7 @@ VIDEOS = [
|
|||||||
"city": "麦德林",
|
"city": "麦德林",
|
||||||
"published_at": "2026-03-28",
|
"published_at": "2026-03-28",
|
||||||
"tags": ["哥伦比亚", "试住"],
|
"tags": ["哥伦比亚", "试住"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "tbilisi-tax",
|
"id": "tbilisi-tax",
|
||||||
@ -157,7 +157,7 @@ VIDEOS = [
|
|||||||
"city": "第比利斯",
|
"city": "第比利斯",
|
||||||
"published_at": "2026-04-05",
|
"published_at": "2026-04-05",
|
||||||
"tags": ["格鲁吉亚", "税务"],
|
"tags": ["格鲁吉亚", "税务"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "berlin-oss",
|
"id": "berlin-oss",
|
||||||
@ -170,7 +170,7 @@ VIDEOS = [
|
|||||||
"city": "柏林",
|
"city": "柏林",
|
||||||
"published_at": "2026-04-22",
|
"published_at": "2026-04-22",
|
||||||
"tags": ["德国", "开源"],
|
"tags": ["德国", "开源"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dali-create",
|
"id": "dali-create",
|
||||||
@ -183,7 +183,7 @@ VIDEOS = [
|
|||||||
"city": "大理",
|
"city": "大理",
|
||||||
"published_at": "2026-05-03",
|
"published_at": "2026-05-03",
|
||||||
"tags": ["大理", "内容"],
|
"tags": ["大理", "内容"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dubai-hub",
|
"id": "dubai-hub",
|
||||||
@ -196,7 +196,7 @@ VIDEOS = [
|
|||||||
"city": "迪拜",
|
"city": "迪拜",
|
||||||
"published_at": "2026-05-18",
|
"published_at": "2026-05-18",
|
||||||
"tags": ["迪拜", "中东"],
|
"tags": ["迪拜", "中东"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "seoul-net",
|
"id": "seoul-net",
|
||||||
@ -209,7 +209,7 @@ VIDEOS = [
|
|||||||
"city": "首尔",
|
"city": "首尔",
|
||||||
"published_at": "2026-06-02",
|
"published_at": "2026-06-02",
|
||||||
"tags": ["韩国", "网速"],
|
"tags": ["韩国", "网速"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mexico-food",
|
"id": "mexico-food",
|
||||||
@ -222,7 +222,7 @@ VIDEOS = [
|
|||||||
"city": "墨西哥城",
|
"city": "墨西哥城",
|
||||||
"published_at": "2026-06-15",
|
"published_at": "2026-06-15",
|
||||||
"tags": ["墨西哥", "美食"],
|
"tags": ["墨西哥", "美食"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "first-month",
|
"id": "first-month",
|
||||||
@ -235,7 +235,7 @@ VIDEOS = [
|
|||||||
"city": "线上",
|
"city": "线上",
|
||||||
"published_at": "2026-06-28",
|
"published_at": "2026-06-28",
|
||||||
"tags": ["新手", "清单"],
|
"tags": ["新手", "清单"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "barcelona-visa",
|
"id": "barcelona-visa",
|
||||||
@ -248,7 +248,7 @@ VIDEOS = [
|
|||||||
"city": "巴塞罗那",
|
"city": "巴塞罗那",
|
||||||
"published_at": "2026-07-08",
|
"published_at": "2026-07-08",
|
||||||
"tags": ["西班牙", "签证"],
|
"tags": ["西班牙", "签证"],
|
||||||
"video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
"video_url": "",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -100,6 +100,14 @@ async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | Non
|
|||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
raise HTTPException(400, res.get("error", "申请失败"))
|
raise HTTPException(400, res.get("error", "申请失败"))
|
||||||
poster_id = res.get("poster_id") or ""
|
poster_id = res.get("poster_id") or ""
|
||||||
|
if not poster_id:
|
||||||
|
email = (res.get("poster_email") or "").strip().lower()
|
||||||
|
if email:
|
||||||
|
from app.services.auth import find_user_public_by_email
|
||||||
|
|
||||||
|
peer = find_user_public_by_email(email)
|
||||||
|
if peer:
|
||||||
|
poster_id = peer["id"]
|
||||||
if poster_id and poster_id != user["id"]:
|
if poster_id and poster_id != user["id"]:
|
||||||
from app.services.notify import notify_user
|
from app.services.notify import notify_user
|
||||||
|
|
||||||
|
|||||||
@ -505,6 +505,6 @@ else:
|
|||||||
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
|
# Best-effort: ensure a few named demo accounts exist in PocketBase too
|
||||||
for email, password, name, _avatar in _DEMO_SEED_USERS[:6]:
|
for email, password, name, _avatar in _DEMO_SEED_USERS:
|
||||||
if not _get_account_by_email(email):
|
if not _get_account_by_email(email):
|
||||||
register_user(email, password, name)
|
register_user(email, password, name)
|
||||||
|
|||||||
@ -361,6 +361,7 @@ def _gig_from_pb(r: dict) -> dict:
|
|||||||
"tags": r.get("tags") or [],
|
"tags": r.get("tags") or [],
|
||||||
"poster": r.get("posterName", ""),
|
"poster": r.get("posterName", ""),
|
||||||
"poster_id": r.get("posterId", ""),
|
"poster_id": r.get("posterId", ""),
|
||||||
|
"poster_email": r.get("posterEmail", ""),
|
||||||
"status": r.get("status", "open"),
|
"status": r.get("status", "open"),
|
||||||
"category": r.get("category", ""),
|
"category": r.get("category", ""),
|
||||||
"location": r.get("location", ""),
|
"location": r.get("location", ""),
|
||||||
@ -416,6 +417,7 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict:
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"message": "申请已提交",
|
"message": "申请已提交",
|
||||||
"poster_id": g.get("posterId", ""),
|
"poster_id": g.get("posterId", ""),
|
||||||
|
"poster_email": g.get("posterEmail", ""),
|
||||||
"title": g.get("title", ""),
|
"title": g.get("title", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -568,7 +570,7 @@ _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.jso
|
|||||||
_j: dict[str, Any] = {}
|
_j: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
_SEED_VERSION = 3
|
_SEED_VERSION = 4
|
||||||
|
|
||||||
|
|
||||||
def _jload() -> None:
|
def _jload() -> None:
|
||||||
@ -753,6 +755,7 @@ def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) ->
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"message": "申请已提交",
|
"message": "申请已提交",
|
||||||
"poster_id": gig.get("poster_id", ""),
|
"poster_id": gig.get("poster_id", ""),
|
||||||
|
"poster_email": gig.get("poster_email", ""),
|
||||||
"title": gig.get("title", ""),
|
"title": gig.get("title", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,14 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LOGS = [
|
const LOGS = [
|
||||||
|
{
|
||||||
|
date: "2026-09-04",
|
||||||
|
tag: "全站闭环增强",
|
||||||
|
items: [
|
||||||
|
"签证向导中英文化 + 结果直达城市/计划/咨询;课程跨模块上下一课;岗位申请改走赏金页",
|
||||||
|
"赏金申请通知发布者;视频去掉无效外链;探索导航增加游民地图入口",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: "2026-09-04",
|
date: "2026-09-04",
|
||||||
tag: "直播报名 · 私信角标",
|
tag: "直播报名 · 私信角标",
|
||||||
|
|||||||
@ -177,8 +177,13 @@ export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
|
|||||||
<span key={item} className="meetup-tag">{item}</span>
|
<span key={item} className="meetup-tag">{item}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<a href={job.url} target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
|
<a
|
||||||
{t.digital.apply}
|
href={job.url.startsWith("http") ? job.url : job.url || "/gigs"}
|
||||||
|
target={job.url.startsWith("http") ? "_blank" : undefined}
|
||||||
|
rel={job.url.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
{job.url.startsWith("http") ? t.digital.apply : t.digital.jobsGoGigs}
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -24,6 +24,8 @@ export default function DigitalLessonClient({
|
|||||||
const [missing, setMissing] = useState(false);
|
const [missing, setMissing] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [hasNext, setHasNext] = useState(false);
|
const [hasNext, setHasNext] = useState(false);
|
||||||
|
const [nextHref, setNextHref] = useState<string | null>(null);
|
||||||
|
const [prevHref, setPrevHref] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -31,18 +33,40 @@ export default function DigitalLessonClient({
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const course: DigitalCourse = await api.getDigitalCourse();
|
const course: DigitalCourse = await api.getDigitalCourse();
|
||||||
const mod = course.modules[moduleIndex];
|
const modules = course.modules || [];
|
||||||
|
const mod = modules[moduleIndex];
|
||||||
const meta = mod?.lessons?.[lessonIndex];
|
const meta = mod?.lessons?.[lessonIndex];
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMissing(true);
|
setMissing(true);
|
||||||
setLocked(false);
|
setLocked(false);
|
||||||
setLesson(null);
|
setLesson(null);
|
||||||
|
setHasNext(false);
|
||||||
|
setNextHref(null);
|
||||||
|
setPrevHref(null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let next: string | null = null;
|
||||||
|
if (mod.lessons[lessonIndex + 1]) {
|
||||||
|
next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
|
||||||
|
} else if (modules[moduleIndex + 1]?.lessons?.length) {
|
||||||
|
next = `/digital/course/${moduleIndex + 1}/0`;
|
||||||
|
}
|
||||||
|
let prev: string | null = null;
|
||||||
|
if (lessonIndex > 0) {
|
||||||
|
prev = `/digital/course/${moduleIndex}/${lessonIndex - 1}`;
|
||||||
|
} else if (moduleIndex > 0) {
|
||||||
|
const prevMod = modules[moduleIndex - 1];
|
||||||
|
const last = (prevMod?.lessons?.length || 1) - 1;
|
||||||
|
if (prevMod?.lessons?.length) {
|
||||||
|
prev = `/digital/course/${moduleIndex - 1}/${last}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setHasNext(Boolean(mod.lessons[lessonIndex + 1]));
|
setHasNext(Boolean(next));
|
||||||
|
setNextHref(next);
|
||||||
|
setPrevHref(prev);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await api.getDigitalLesson(moduleIndex, lessonIndex, token || undefined);
|
const data = await api.getDigitalLesson(moduleIndex, lessonIndex, token || undefined);
|
||||||
@ -118,8 +142,8 @@ export default function DigitalLessonClient({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null;
|
const prev = prevHref;
|
||||||
const next = hasNext ? `/digital/course/${moduleIndex}/${lessonIndex + 1}` : null;
|
const next = hasNext ? nextHref : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="digital-page">
|
<div className="digital-page">
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export default function Footer() {
|
|||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>{t.footer.explore}</h4>
|
<h4>{t.footer.explore}</h4>
|
||||||
<Link href="/#destinations">{t.footer.destinations}</Link>
|
<Link href="/#destinations">{t.footer.destinations}</Link>
|
||||||
|
<Link href="/map">{t.nav.memberMap}</Link>
|
||||||
<Link href="/next-stop">{t.footer.nextStop}</Link>
|
<Link href="/next-stop">{t.footer.nextStop}</Link>
|
||||||
{!isHome && <Link href="/plan">{t.footer.plan}</Link>}
|
{!isHome && <Link href="/plan">{t.footer.plan}</Link>}
|
||||||
{!isHome && <Link href="/compare">{t.footer.compare}</Link>}
|
{!isHome && <Link href="/compare">{t.footer.compare}</Link>}
|
||||||
|
|||||||
@ -69,8 +69,8 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
toast(t.gigs.applyOk, "success", {
|
toast(t.gigs.applyOk, "success", {
|
||||||
href: "/digital",
|
href: "/notifications",
|
||||||
label: t.nav.digital,
|
label: t.nav.notifications,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast(t.gigs.applyFail, "error");
|
toast(t.gigs.applyFail, "error");
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export default function Navbar() {
|
|||||||
const explore: NavItem[] = [
|
const explore: NavItem[] = [
|
||||||
{ href: "/#destinations", label: t.nav.destinations, section: "destinations" },
|
{ href: "/#destinations", label: t.nav.destinations, section: "destinations" },
|
||||||
{ href: "/#map", label: t.nav.map, section: "map" },
|
{ href: "/#map", label: t.nav.map, section: "map" },
|
||||||
|
{ href: "/map", label: t.nav.memberMap, section: "member-map" },
|
||||||
{ href: "/next-stop", label: t.nav.nextStop, section: "next-stop" },
|
{ href: "/next-stop", label: t.nav.nextStop, section: "next-stop" },
|
||||||
...(!isHome
|
...(!isHome
|
||||||
? [
|
? [
|
||||||
@ -147,6 +148,7 @@ export default function Navbar() {
|
|||||||
if (l.href === "/book") return pathname.startsWith("/book");
|
if (l.href === "/book") return pathname.startsWith("/book");
|
||||||
if (l.href === "/digital") return pathname.startsWith("/digital");
|
if (l.href === "/digital") return pathname.startsWith("/digital");
|
||||||
if (l.href === "/dating") return pathname.startsWith("/dating");
|
if (l.href === "/dating") return pathname.startsWith("/dating");
|
||||||
|
if (l.href === "/map") return pathname === "/map";
|
||||||
if (l.href === "/meetups") return pathname.startsWith("/meetups");
|
if (l.href === "/meetups") return pathname.startsWith("/meetups");
|
||||||
if (l.href === "/community") return pathname.startsWith("/community");
|
if (l.href === "/community") return pathname.startsWith("/community");
|
||||||
if (l.href === "/gigs") return pathname.startsWith("/gigs");
|
if (l.href === "/gigs") return pathname.startsWith("/gigs");
|
||||||
|
|||||||
@ -1,51 +1,100 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import type { Visa } from "@/lib/types";
|
import type { Visa } from "@/lib/types";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visas: Visa[];
|
visas: Visa[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const INCOME_OPTIONS = [
|
const INCOME_KEYS = [
|
||||||
{ key: "low", label: "💰 低于 ¥8,000/月", max: 8000 },
|
{ key: "low", max: 8000 },
|
||||||
{ key: "mid", label: "💼 ¥8,000 – ¥20,000/月", max: 20000 },
|
{ key: "mid", max: 20000 },
|
||||||
{ key: "high", label: "🚀 ¥20,000 – ¥50,000/月", max: 50000 },
|
{ key: "high", max: 50000 },
|
||||||
{ key: "premium", label: "👑 高于 ¥50,000/月", max: Infinity },
|
{ key: "premium", max: Infinity },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const DURATION_OPTIONS = [
|
const DURATION_KEYS = [
|
||||||
{ key: "short", label: "⏱️ 1-3 个月", months: 3 },
|
{ key: "short", months: 3 },
|
||||||
{ key: "medium", label: "📅 3-12 个月", months: 12 },
|
{ key: "medium", months: 12 },
|
||||||
{ key: "long", label: "🏠 1 年以上", months: 24 },
|
{ key: "long", months: 24 },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const REGION_OPTIONS = [
|
const REGION_KEYS = ["any", "europe", "sea", "latam"] as const;
|
||||||
{ key: "any", label: "🌏 不限" },
|
|
||||||
{ key: "europe", label: "🏰 欧洲" },
|
|
||||||
{ key: "sea", label: "🌴 东南亚" },
|
|
||||||
{ key: "latam", label: "🌮 拉美" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const VISA_INCOME: Record<string, number> = {
|
const VISA_INCOME: Record<string, number> = {
|
||||||
"1": 6000, "2": 17000, "3": 2000, "4": 47000, "5": 18000, "6": 28000,
|
"1": 6000,
|
||||||
|
"2": 17000,
|
||||||
|
"3": 2000,
|
||||||
|
"4": 47000,
|
||||||
|
"5": 18000,
|
||||||
|
"6": 28000,
|
||||||
|
"7": 2000,
|
||||||
|
"8": 35000,
|
||||||
|
"9": 5000,
|
||||||
|
"10": 30000,
|
||||||
|
"11": 25000,
|
||||||
|
"12": 15000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VISA_REGION: Record<string, string> = {
|
const VISA_REGION: Record<string, string> = {
|
||||||
"1": "europe", "2": "europe", "3": "sea", "4": "sea", "5": "latam", "6": "europe",
|
"1": "europe",
|
||||||
|
"2": "europe",
|
||||||
|
"3": "sea",
|
||||||
|
"4": "sea",
|
||||||
|
"5": "latam",
|
||||||
|
"6": "europe",
|
||||||
|
"7": "europe",
|
||||||
|
"8": "any",
|
||||||
|
"9": "latam",
|
||||||
|
"10": "sea",
|
||||||
|
"11": "europe",
|
||||||
|
"12": "sea",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISA_DEST: Record<string, string> = {
|
||||||
|
"1": "lisbon",
|
||||||
|
"2": "barcelona",
|
||||||
|
"3": "bali",
|
||||||
|
"4": "chiangmai",
|
||||||
|
"5": "mexico",
|
||||||
|
"7": "tbilisi",
|
||||||
|
"8": "dubai",
|
||||||
|
"9": "medellin",
|
||||||
|
"10": "seoul",
|
||||||
|
"11": "berlin",
|
||||||
|
"12": "chiangmai",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function VisaWizard({ visas }: Props) {
|
export default function VisaWizard({ visas }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const w = t.visaWizard;
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [income, setIncome] = useState("");
|
const [income, setIncome] = useState("");
|
||||||
const [duration, setDuration] = useState("");
|
const [duration, setDuration] = useState("");
|
||||||
const [region, setRegion] = useState("any");
|
const [region, setRegion] = useState("any");
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
|
||||||
|
const incomeOptions = INCOME_KEYS.map((o) => ({
|
||||||
|
...o,
|
||||||
|
label: w[`income_${o.key}` as keyof typeof w] as string,
|
||||||
|
}));
|
||||||
|
const durationOptions = DURATION_KEYS.map((o) => ({
|
||||||
|
...o,
|
||||||
|
label: w[`duration_${o.key}` as keyof typeof w] as string,
|
||||||
|
}));
|
||||||
|
const regionOptions = REGION_KEYS.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: w[`region_${key}` as keyof typeof w] as string,
|
||||||
|
}));
|
||||||
|
const stepLabels = [w.stepIncome, w.stepDuration, w.stepRegion];
|
||||||
|
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
if (!income || !duration) return [];
|
if (!income || !duration) return [];
|
||||||
const incomeOpt = INCOME_OPTIONS.find((o) => o.key === income)!;
|
const incomeOpt = INCOME_KEYS.find((o) => o.key === income)!;
|
||||||
const durOpt = DURATION_OPTIONS.find((o) => o.key === duration)!;
|
const durOpt = DURATION_KEYS.find((o) => o.key === duration)!;
|
||||||
|
|
||||||
return visas
|
return visas
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
@ -91,16 +140,19 @@ export default function VisaWizard({ visas }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="visa-wizard reveal">
|
<div className="visa-wizard reveal">
|
||||||
<div className="visa-wizard-header">
|
<div className="visa-wizard-header">
|
||||||
<span className="section-tag">🧙 VISA WIZARD</span>
|
<span className="section-tag">{w.tag}</span>
|
||||||
<h3>签证智能匹配</h3>
|
<h3>{w.title}</h3>
|
||||||
<p>回答 3 个问题,找到最适合你的远程工作签证</p>
|
<p>{w.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!showResults ? (
|
{!showResults ? (
|
||||||
<div className="visa-wizard-body">
|
<div className="visa-wizard-body">
|
||||||
<div className="visa-wizard-steps">
|
<div className="visa-wizard-steps">
|
||||||
{["月收入", "停留时长", "目的地"].map((label, i) => (
|
{stepLabels.map((label, i) => (
|
||||||
<div key={label} className={`wizard-step-dot${i <= step ? " active" : ""}${i < step ? " done" : ""}`}>
|
<div
|
||||||
|
key={label}
|
||||||
|
className={`wizard-step-dot${i <= step ? " active" : ""}${i < step ? " done" : ""}`}
|
||||||
|
>
|
||||||
<span>{i < step ? "✓" : i + 1}</span>
|
<span>{i < step ? "✓" : i + 1}</span>
|
||||||
<small>{label}</small>
|
<small>{label}</small>
|
||||||
</div>
|
</div>
|
||||||
@ -109,8 +161,13 @@ export default function VisaWizard({ visas }: Props) {
|
|||||||
|
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<div className="wizard-options">
|
<div className="wizard-options">
|
||||||
{INCOME_OPTIONS.map((o) => (
|
{incomeOptions.map((o) => (
|
||||||
<button key={o.key} className={`wizard-option${income === o.key ? " selected" : ""}`} onClick={() => setIncome(o.key)}>
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
className={`wizard-option${income === o.key ? " selected" : ""}`}
|
||||||
|
onClick={() => setIncome(o.key)}
|
||||||
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -119,8 +176,13 @@ export default function VisaWizard({ visas }: Props) {
|
|||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="wizard-options">
|
<div className="wizard-options">
|
||||||
{DURATION_OPTIONS.map((o) => (
|
{durationOptions.map((o) => (
|
||||||
<button key={o.key} className={`wizard-option${duration === o.key ? " selected" : ""}`} onClick={() => setDuration(o.key)}>
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
className={`wizard-option${duration === o.key ? " selected" : ""}`}
|
||||||
|
onClick={() => setDuration(o.key)}
|
||||||
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -129,8 +191,13 @@ export default function VisaWizard({ visas }: Props) {
|
|||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="wizard-options">
|
<div className="wizard-options">
|
||||||
{REGION_OPTIONS.map((o) => (
|
{regionOptions.map((o) => (
|
||||||
<button key={o.key} className={`wizard-option${region === o.key ? " selected" : ""}`} onClick={() => setRegion(o.key)}>
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
className={`wizard-option${region === o.key ? " selected" : ""}`}
|
||||||
|
onClick={() => setRegion(o.key)}
|
||||||
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -138,40 +205,83 @@ export default function VisaWizard({ visas }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="wizard-nav">
|
<div className="wizard-nav">
|
||||||
{step > 0 && <button className="btn btn-ghost" onClick={() => setStep(step - 1)}>← 上一步</button>}
|
{step > 0 && (
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setStep(step - 1)}>
|
||||||
|
← {w.prev}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{step < 2 ? (
|
{step < 2 ? (
|
||||||
<button className="btn btn-primary" disabled={!canNext} onClick={() => setStep(step + 1)}>下一步 →</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!canNext}
|
||||||
|
onClick={() => setStep(step + 1)}
|
||||||
|
>
|
||||||
|
{w.next} →
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-primary" onClick={handleFinish}>查看推荐 ✨</button>
|
<button type="button" className="btn btn-primary" onClick={handleFinish}>
|
||||||
|
{w.finish}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="visa-wizard-results">
|
<div className="visa-wizard-results">
|
||||||
{results.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
<p className="wizard-empty">暂无完全匹配的签证,建议咨询专业移民顾问或放宽条件重试</p>
|
<div className="wizard-empty-wrap">
|
||||||
|
<p className="wizard-empty">{w.empty}</p>
|
||||||
|
<div className="dating-empty-actions">
|
||||||
|
<Link href="/services" className="btn btn-primary btn-sm">
|
||||||
|
{w.ctaServices}
|
||||||
|
</Link>
|
||||||
|
<Link href="/community" className="btn btn-sm">
|
||||||
|
{w.ctaCommunity}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="wizard-result-cards">
|
<div className="wizard-result-cards">
|
||||||
{results.map(({ visa, score }, i) => (
|
{results.map(({ visa, score }, i) => {
|
||||||
<div key={visa.id} className={`wizard-result-card${i === 0 ? " winner" : ""}`}>
|
const dest = VISA_DEST[visa.id];
|
||||||
{i === 0 && <span className="wizard-winner-badge">🏆 最佳匹配</span>}
|
return (
|
||||||
<div className="wizard-result-top">
|
<div key={visa.id} className={`wizard-result-card${i === 0 ? " winner" : ""}`}>
|
||||||
<span className="wizard-result-flag">{visa.flag}</span>
|
{i === 0 && <span className="wizard-winner-badge">{w.bestMatch}</span>}
|
||||||
<div>
|
<div className="wizard-result-top">
|
||||||
<h4>{visa.name}</h4>
|
<span className="wizard-result-flag">{visa.flag}</span>
|
||||||
<span className="wizard-match-score">{score}% 匹配</span>
|
<div>
|
||||||
|
<h4>{visa.name}</h4>
|
||||||
|
<span className="wizard-match-score">
|
||||||
|
{score}% {w.match}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li>💰 {visa.income_req}</li>
|
||||||
|
<li>📅 {visa.duration}</li>
|
||||||
|
<li>⏱️ {visa.approval_time}</li>
|
||||||
|
</ul>
|
||||||
|
<div className="wizard-result-actions">
|
||||||
|
{dest && (
|
||||||
|
<Link href={`/destinations/${dest}`} className="btn btn-primary btn-sm">
|
||||||
|
{w.ctaCity}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link href="/plan" className="btn btn-sm">
|
||||||
|
{w.ctaPlan}
|
||||||
|
</Link>
|
||||||
|
<Link href="/services" className="btn btn-ghost btn-sm">
|
||||||
|
{w.ctaServices}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
);
|
||||||
<li>💰 {visa.income_req}</li>
|
})}
|
||||||
<li>📅 {visa.duration}</li>
|
|
||||||
<li>⏱️ {visa.approval_time}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-ghost" onClick={reset}>🔄 重新匹配</button>
|
<button type="button" className="btn btn-ghost" onClick={reset}>
|
||||||
|
{w.reset}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -115,9 +115,14 @@ export default function WorldMap({ destinations }: Props) {
|
|||||||
<span className="map-info-icon">👆</span>
|
<span className="map-info-icon">👆</span>
|
||||||
<h3>{t.home.mapPick}</h3>
|
<h3>{t.home.mapPick}</h3>
|
||||||
<p>{t.home.mapPickHint}</p>
|
<p>{t.home.mapPickHint}</p>
|
||||||
<a href="#destinations" className="btn btn-ghost btn-sm map-browse-link">
|
<div className="dating-empty-actions">
|
||||||
{t.home.mapBrowse}
|
<a href="#destinations" className="btn btn-ghost btn-sm map-browse-link">
|
||||||
</a>
|
{t.home.mapBrowse}
|
||||||
|
</a>
|
||||||
|
<Link href="/map" className="btn btn-sm map-browse-link">
|
||||||
|
{t.home.mapMembers}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export const zh = {
|
|||||||
nav: {
|
nav: {
|
||||||
map: "🗺️ 地图",
|
map: "🗺️ 地图",
|
||||||
mapShort: "地图",
|
mapShort: "地图",
|
||||||
|
memberMap: "🧭 游民地图",
|
||||||
destinations: "🌍 目的地",
|
destinations: "🌍 目的地",
|
||||||
plan: "🗓️ 计划",
|
plan: "🗓️ 计划",
|
||||||
compare: "⚖️ 对比",
|
compare: "⚖️ 对比",
|
||||||
@ -109,6 +110,7 @@ export const zh = {
|
|||||||
mapTemp: "均温",
|
mapTemp: "均温",
|
||||||
mapRating: "评分",
|
mapRating: "评分",
|
||||||
mapBrowse: "浏览全部目的地 →",
|
mapBrowse: "浏览全部目的地 →",
|
||||||
|
mapMembers: "看看谁在那里 →",
|
||||||
destTag: "🌴 TOP DESTINATIONS",
|
destTag: "🌴 TOP DESTINATIONS",
|
||||||
destTitle: "热门旅居目的地",
|
destTitle: "热门旅居目的地",
|
||||||
destSubtitle: "筛选、收藏,点击进入城市详情了解更多",
|
destSubtitle: "筛选、收藏,点击进入城市详情了解更多",
|
||||||
@ -527,6 +529,36 @@ export const zh = {
|
|||||||
openChat: "新窗口打开聊天",
|
openChat: "新窗口打开聊天",
|
||||||
iframeHint: "若画面空白,请用上方按钮在新窗口打开",
|
iframeHint: "若画面空白,请用上方按钮在新窗口打开",
|
||||||
},
|
},
|
||||||
|
visaWizard: {
|
||||||
|
tag: "🧙 VISA WIZARD",
|
||||||
|
title: "签证智能匹配",
|
||||||
|
subtitle: "回答 3 个问题,找到最适合你的远程工作签证",
|
||||||
|
stepIncome: "月收入",
|
||||||
|
stepDuration: "停留时长",
|
||||||
|
stepRegion: "目的地",
|
||||||
|
income_low: "💰 低于 ¥8,000/月",
|
||||||
|
income_mid: "💼 ¥8,000 – ¥20,000/月",
|
||||||
|
income_high: "🚀 ¥20,000 – ¥50,000/月",
|
||||||
|
income_premium: "👑 高于 ¥50,000/月",
|
||||||
|
duration_short: "⏱️ 1-3 个月",
|
||||||
|
duration_medium: "📅 3-12 个月",
|
||||||
|
duration_long: "🏠 1 年以上",
|
||||||
|
region_any: "🌏 不限",
|
||||||
|
region_europe: "🏰 欧洲",
|
||||||
|
region_sea: "🌴 东南亚",
|
||||||
|
region_latam: "🌮 拉美",
|
||||||
|
prev: "上一步",
|
||||||
|
next: "下一步",
|
||||||
|
finish: "查看推荐 ✨",
|
||||||
|
empty: "暂无完全匹配的签证,建议咨询顾问或放宽条件重试",
|
||||||
|
bestMatch: "🏆 最佳匹配",
|
||||||
|
match: "匹配",
|
||||||
|
reset: "🔄 重新匹配",
|
||||||
|
ctaCity: "查看城市",
|
||||||
|
ctaPlan: "写入计划",
|
||||||
|
ctaServices: "签证咨询",
|
||||||
|
ctaCommunity: "去社区问问",
|
||||||
|
},
|
||||||
digital: {
|
digital: {
|
||||||
tag: "🎓 ACADEMY",
|
tag: "🎓 ACADEMY",
|
||||||
title: "数字游民学院",
|
title: "数字游民学院",
|
||||||
@ -1285,6 +1317,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
nav: {
|
nav: {
|
||||||
map: "🗺️ Map",
|
map: "🗺️ Map",
|
||||||
mapShort: "Map",
|
mapShort: "Map",
|
||||||
|
memberMap: "🧭 Who's there",
|
||||||
destinations: "🌍 Destinations",
|
destinations: "🌍 Destinations",
|
||||||
plan: "🗓️ Plan",
|
plan: "🗓️ Plan",
|
||||||
compare: "⚖️ Compare",
|
compare: "⚖️ Compare",
|
||||||
@ -1390,6 +1423,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
mapTemp: "Avg temp",
|
mapTemp: "Avg temp",
|
||||||
mapRating: "Rating",
|
mapRating: "Rating",
|
||||||
mapBrowse: "Browse all cities →",
|
mapBrowse: "Browse all cities →",
|
||||||
|
mapMembers: "See who's there →",
|
||||||
destTag: "🌴 TOP DESTINATIONS",
|
destTag: "🌴 TOP DESTINATIONS",
|
||||||
destTitle: "Top nomad cities",
|
destTitle: "Top nomad cities",
|
||||||
destSubtitle: "Filter, favorite, tap a city for full details",
|
destSubtitle: "Filter, favorite, tap a city for full details",
|
||||||
@ -1808,6 +1842,36 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string
|
|||||||
openChat: "Open chat in new tab",
|
openChat: "Open chat in new tab",
|
||||||
iframeHint: "If the embed is blank, open the rooms in a new tab above",
|
iframeHint: "If the embed is blank, open the rooms in a new tab above",
|
||||||
},
|
},
|
||||||
|
visaWizard: {
|
||||||
|
tag: "🧙 VISA WIZARD",
|
||||||
|
title: "Visa matcher",
|
||||||
|
subtitle: "Answer 3 questions to find remote-work visas that fit",
|
||||||
|
stepIncome: "Income",
|
||||||
|
stepDuration: "Stay length",
|
||||||
|
stepRegion: "Region",
|
||||||
|
income_low: "💰 Under ¥8,000/mo",
|
||||||
|
income_mid: "💼 ¥8,000 – ¥20,000/mo",
|
||||||
|
income_high: "🚀 ¥20,000 – ¥50,000/mo",
|
||||||
|
income_premium: "👑 Over ¥50,000/mo",
|
||||||
|
duration_short: "⏱️ 1–3 months",
|
||||||
|
duration_medium: "📅 3–12 months",
|
||||||
|
duration_long: "🏠 1 year+",
|
||||||
|
region_any: "🌏 Anywhere",
|
||||||
|
region_europe: "🏰 Europe",
|
||||||
|
region_sea: "🌴 SE Asia",
|
||||||
|
region_latam: "🌮 LatAm",
|
||||||
|
prev: "Back",
|
||||||
|
next: "Next",
|
||||||
|
finish: "See matches ✨",
|
||||||
|
empty: "No strong matches — try looser filters or talk to an advisor",
|
||||||
|
bestMatch: "🏆 Best match",
|
||||||
|
match: "match",
|
||||||
|
reset: "🔄 Match again",
|
||||||
|
ctaCity: "View city",
|
||||||
|
ctaPlan: "Add to plan",
|
||||||
|
ctaServices: "Visa help",
|
||||||
|
ctaCommunity: "Ask community",
|
||||||
|
},
|
||||||
digital: {
|
digital: {
|
||||||
tag: "🎓 ACADEMY",
|
tag: "🎓 ACADEMY",
|
||||||
title: "Nomad academy",
|
title: "Nomad academy",
|
||||||
|
|||||||
136
scripts/deploy_dating_fix.py
Normal file
136
scripts/deploy_dating_fix.py
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"""Upload dating hotfix deps and rebuild web."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
HOST = "107.173.30.245"
|
||||||
|
USER = "root"
|
||||||
|
PASS = "Xiao4669805"
|
||||||
|
REMOTE = "/opt/nomadweb"
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
FILES = [
|
||||||
|
"frontend/src/lib/communityDraft.ts",
|
||||||
|
"frontend/src/lib/meetupLinks.ts",
|
||||||
|
"frontend/src/lib/localDraft.ts",
|
||||||
|
"frontend/src/lib/savedGigs.ts",
|
||||||
|
"frontend/src/lib/savedJobs.ts",
|
||||||
|
"frontend/src/lib/meetupIcs.ts",
|
||||||
|
"frontend/src/lib/aiHistory.ts",
|
||||||
|
"frontend/src/lib/compareHistory.ts",
|
||||||
|
"frontend/src/lib/nextStopPrefs.ts",
|
||||||
|
"frontend/src/lib/recentDestinations.ts",
|
||||||
|
"frontend/src/lib/watchLater.ts",
|
||||||
|
"frontend/src/lib/toolCopy.ts",
|
||||||
|
"frontend/src/lib/coworking.ts",
|
||||||
|
"frontend/src/lib/authNext.ts",
|
||||||
|
"frontend/src/lib/rings.ts",
|
||||||
|
"frontend/src/lib/toast.tsx",
|
||||||
|
"frontend/src/lib/api.ts",
|
||||||
|
"frontend/src/lib/types.ts",
|
||||||
|
"frontend/src/lib/i18n/dictionaries.ts",
|
||||||
|
"frontend/src/components/ToolCityExits.tsx",
|
||||||
|
"frontend/src/components/RecentlyViewed.tsx",
|
||||||
|
"frontend/src/components/NomadDigest.tsx",
|
||||||
|
"frontend/src/components/DatingClient.tsx",
|
||||||
|
"frontend/src/components/DatingLikesClient.tsx",
|
||||||
|
"frontend/src/components/ChatClient.tsx",
|
||||||
|
"frontend/src/components/CommunityNewClient.tsx",
|
||||||
|
"frontend/src/components/DiscussionDetailClient.tsx",
|
||||||
|
"frontend/src/components/GigsClient.tsx",
|
||||||
|
"frontend/src/components/MeetupsClient.tsx",
|
||||||
|
"frontend/src/components/MeetupsHostClient.tsx",
|
||||||
|
"frontend/src/components/CommunityHubClient.tsx",
|
||||||
|
"frontend/src/components/MemberProfileClient.tsx",
|
||||||
|
"frontend/src/components/NotificationsClient.tsx",
|
||||||
|
"frontend/src/components/PricingClient.tsx",
|
||||||
|
"frontend/src/components/GlobalSearch.tsx",
|
||||||
|
"frontend/src/components/SiteShell.tsx",
|
||||||
|
"frontend/src/components/Navbar.tsx",
|
||||||
|
"frontend/src/components/Footer.tsx",
|
||||||
|
"frontend/src/app/globals.css",
|
||||||
|
"frontend/src/app/changelog/page.tsx",
|
||||||
|
]
|
||||||
|
|
||||||
|
REBUILD = r"""
|
||||||
|
set -euo pipefail
|
||||||
|
exec 9>/var/lock/nomadro-deploy.lock
|
||||||
|
if ! flock -n 9; then echo LOCK_BUSY; exit 75; fi
|
||||||
|
export NEXT_TELEMETRY_DISABLED=1
|
||||||
|
export NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1
|
||||||
|
export NEXT_PUBLIC_SITE_URL=https://nomadweb.nomadro.com
|
||||||
|
export NODE_ENV=production
|
||||||
|
cd /opt/nomadweb/frontend
|
||||||
|
echo BUILD_START
|
||||||
|
npm run build
|
||||||
|
mkdir -p .next/standalone/.next
|
||||||
|
rm -rf .next/standalone/public .next/standalone/.next/static
|
||||||
|
cp -a public .next/standalone/public
|
||||||
|
cp -a .next/static .next/standalone/.next/static
|
||||||
|
rm -rf .next/standalone/content
|
||||||
|
mkdir -p .next/standalone/content
|
||||||
|
cp -a content .next/standalone/content
|
||||||
|
systemctl restart nomadro-web
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
web=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3055/ || true)
|
||||||
|
echo "try=$i web=$web"
|
||||||
|
[ "$web" = "200" ] && exit 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(sftp: paramiko.SFTPClient, path: str) -> None:
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
cur = ""
|
||||||
|
for p in parts:
|
||||||
|
cur += "/" + p
|
||||||
|
try:
|
||||||
|
sftp.stat(cur)
|
||||||
|
except FileNotFoundError:
|
||||||
|
try:
|
||||||
|
sftp.mkdir(cur)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=30)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
for i, rel in enumerate(FILES, 1):
|
||||||
|
local = ROOT / rel
|
||||||
|
if not local.exists():
|
||||||
|
print(f"[{i}/{len(FILES)}] SKIP {rel}", flush=True)
|
||||||
|
continue
|
||||||
|
remote = f"{REMOTE}/{rel}"
|
||||||
|
print(f"[{i}/{len(FILES)}] UPLOAD {rel}", flush=True)
|
||||||
|
ensure_dir(sftp, str(Path(remote).parent).replace("\\", "/"))
|
||||||
|
sftp.put(str(local), remote)
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
print("BUILD...", flush=True)
|
||||||
|
_, stdout, stderr = client.exec_command(REBUILD, timeout=900)
|
||||||
|
while True:
|
||||||
|
line = stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
print(line.rstrip(), flush=True)
|
||||||
|
err = stderr.read().decode("utf-8", errors="replace")
|
||||||
|
if err.strip():
|
||||||
|
print("LOG:", err[-4000:], flush=True)
|
||||||
|
code = stdout.channel.recv_exit_status()
|
||||||
|
client.close()
|
||||||
|
print("exit", code, flush=True)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Reference in New Issue
Block a user