diff --git a/backend/app/data/community_data.py b/backend/app/data/community_data.py index 9e23cd0..d38a7af 100644 --- a/backend/app/data/community_data.py +++ b/backend/app/data/community_data.py @@ -386,6 +386,7 @@ GIGS = [ "deadline": "2026-09-15", "tags": ["设计", "远程"], "poster": "nomadro", + "poster_email": "demo@nomadro.com", "status": "open", "category": "设计", "location": "远程", @@ -398,6 +399,7 @@ GIGS = [ "deadline": "2026-09-20", "tags": ["活动", "深圳"], "poster": "深圳湾区主理人", + "poster_email": "demo@nomadro.com", "status": "open", "category": "活动", "location": "深圳", @@ -410,6 +412,7 @@ GIGS = [ "deadline": "2026-10-01", "tags": ["翻译", "内容"], "poster": "nomadro", + "poster_email": "demo@nomadro.com", "status": "open", "category": "内容", "location": "远程", @@ -422,6 +425,7 @@ GIGS = [ "deadline": "2026-10-10", "tags": ["前端", "Next.js"], "poster": "Marco", + "poster_email": "marco@nomadro.com", "status": "open", "category": "开发", "location": "远程", @@ -434,6 +438,7 @@ GIGS = [ "deadline": "2026-10-12", "tags": ["视频", "剪辑"], "poster": "Lina", + "poster_email": "lina@nomadro.com", "status": "open", "category": "内容", "location": "远程", @@ -446,6 +451,7 @@ GIGS = [ "deadline": "2026-10-08", "tags": ["SEO", "内容"], "poster": "nomadro", + "poster_email": "demo@nomadro.com", "status": "open", "category": "运营", "location": "远程", @@ -458,6 +464,7 @@ GIGS = [ "deadline": "2026-10-05", "tags": ["摄影", "大理"], "poster": "Lina", + "poster_email": "lina@nomadro.com", "status": "open", "category": "设计", "location": "大理", @@ -470,6 +477,7 @@ GIGS = [ "deadline": "2026-10-18", "tags": ["税务", "文档"], "poster": "Devon", + "poster_email": "devon@nomadro.com", "status": "open", "category": "内容", "location": "远程", @@ -482,6 +490,7 @@ GIGS = [ "deadline": "2026-10-08", "tags": ["摄影", "活动"], "poster": "Sofia", + "poster_email": "sofia@nomadro.com", "status": "open", "category": "活动", "location": "麦德林", @@ -494,6 +503,7 @@ GIGS = [ "deadline": "2026-10-20", "tags": ["AI", "内容"], "poster": "Alex", + "poster_email": "alex@nomadro.com", "status": "open", "category": "开发", "location": "远程", @@ -506,6 +516,7 @@ GIGS = [ "deadline": "2026-10-15", "tags": ["UX", "研究"], "poster": "Sara", + "poster_email": "sara@nomadro.com", "status": "open", "category": "产品", "location": "远程", @@ -518,6 +529,7 @@ GIGS = [ "deadline": "2026-10-30", "tags": ["社区", "兼职"], "poster": "nomadro", + "poster_email": "demo@nomadro.com", "status": "open", "category": "运营", "location": "远程", diff --git a/backend/app/data/digital_content.py b/backend/app/data/digital_content.py index 7b37c8e..63d9fe9 100644 --- a/backend/app/data/digital_content.py +++ b/backend/app/data/digital_content.py @@ -95,7 +95,7 @@ JOBS = [ "type": "全职", "salary": "$80k–120k", "tags": ["React", "TypeScript", "远程"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j2", @@ -105,7 +105,7 @@ JOBS = [ "type": "合同", "salary": "€60–90/h", "tags": ["K8s", "AWS", "异步"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j3", @@ -115,7 +115,7 @@ JOBS = [ "type": "兼职", "salary": "面议", "tags": ["社区", "写作", "游民"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j4", @@ -125,7 +125,7 @@ JOBS = [ "type": "全职", "salary": "€55k–75k", "tags": ["Figma", "B2B", "远程"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j5", @@ -135,7 +135,7 @@ JOBS = [ "type": "合同", "salary": "$4k–6k/月", "tags": ["SEO", "内容", "增长"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j6", @@ -145,7 +145,7 @@ JOBS = [ "type": "全职", "salary": "$90k–130k", "tags": ["Python", "FastAPI", "Postgres"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j7", @@ -155,7 +155,7 @@ JOBS = [ "type": "兼职", "salary": "¥8k–12k/月", "tags": ["社区", "活动", "中英"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j8", @@ -165,7 +165,7 @@ JOBS = [ "type": "全职", "salary": "$70k–100k", "tags": ["Flutter", "移动端"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j9", @@ -175,7 +175,7 @@ JOBS = [ "type": "合同", "salary": "$40–70/h", "tags": ["文档", "开发者关系"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j10", @@ -185,7 +185,7 @@ JOBS = [ "type": "全职", "salary": "$65k–95k", "tags": ["SQL", "看板", "增长"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j11", @@ -195,7 +195,7 @@ JOBS = [ "type": "兼职", "salary": "¥6k–10k/月", "tags": ["客服", "签证", "中文"], - "url": "https://nomadro.com", + "url": "/gigs", }, { "id": "j12", @@ -205,6 +205,6 @@ JOBS = [ "type": "全职", "salary": "$85k–115k", "tags": ["Next.js", "Python", "独立产品"], - "url": "https://nomadro.com", + "url": "/gigs", }, ] diff --git a/backend/app/data/platform_data.py b/backend/app/data/platform_data.py index 320c6be..09efafe 100644 --- a/backend/app/data/platform_data.py +++ b/backend/app/data/platform_data.py @@ -105,7 +105,7 @@ VIDEOS = [ "city": "清迈", "published_at": "2026-03-12", "tags": ["泰国", "成本", "社区"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "lisbon-d7", @@ -118,7 +118,7 @@ VIDEOS = [ "city": "里斯本", "published_at": "2026-02-08", "tags": ["葡萄牙", "签证"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "bali-cowork", @@ -131,7 +131,7 @@ VIDEOS = [ "city": "巴厘岛", "published_at": "2026-01-20", "tags": ["印尼", "办公"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "medellin-30d", @@ -144,7 +144,7 @@ VIDEOS = [ "city": "麦德林", "published_at": "2026-03-28", "tags": ["哥伦比亚", "试住"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "tbilisi-tax", @@ -157,7 +157,7 @@ VIDEOS = [ "city": "第比利斯", "published_at": "2026-04-05", "tags": ["格鲁吉亚", "税务"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "berlin-oss", @@ -170,7 +170,7 @@ VIDEOS = [ "city": "柏林", "published_at": "2026-04-22", "tags": ["德国", "开源"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "dali-create", @@ -183,7 +183,7 @@ VIDEOS = [ "city": "大理", "published_at": "2026-05-03", "tags": ["大理", "内容"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "dubai-hub", @@ -196,7 +196,7 @@ VIDEOS = [ "city": "迪拜", "published_at": "2026-05-18", "tags": ["迪拜", "中东"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "seoul-net", @@ -209,7 +209,7 @@ VIDEOS = [ "city": "首尔", "published_at": "2026-06-02", "tags": ["韩国", "网速"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "mexico-food", @@ -222,7 +222,7 @@ VIDEOS = [ "city": "墨西哥城", "published_at": "2026-06-15", "tags": ["墨西哥", "美食"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "first-month", @@ -235,7 +235,7 @@ VIDEOS = [ "city": "线上", "published_at": "2026-06-28", "tags": ["新手", "清单"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, { "id": "barcelona-visa", @@ -248,7 +248,7 @@ VIDEOS = [ "city": "巴塞罗那", "published_at": "2026-07-08", "tags": ["西班牙", "签证"], - "video_url": "https://www.youtube.com/embed/dQw4w9WgXcQ", + "video_url": "", }, ] diff --git a/backend/app/routers/community.py b/backend/app/routers/community.py index 3226908..a05a15c 100644 --- a/backend/app/routers/community.py +++ b/backend/app/routers/community.py @@ -100,6 +100,14 @@ async def apply_gig(gig_id: str, body: GigApplyRequest, authorization: str | Non if not res.get("ok"): raise HTTPException(400, res.get("error", "申请失败")) poster_id = res.get("poster_id") or "" + if not poster_id: + email = (res.get("poster_email") or "").strip().lower() + if email: + from app.services.auth import find_user_public_by_email + + peer = find_user_public_by_email(email) + if peer: + poster_id = peer["id"] if poster_id and poster_id != user["id"]: from app.services.notify import notify_user diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 4cc0ea6..2744488 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -505,6 +505,6 @@ else: if not _get_account_by_email("demo@nomadro.com"): register_user("demo@nomadro.com", "demo123", "演示用户") # Best-effort: ensure a few named demo accounts exist in PocketBase too - for email, password, name, _avatar in _DEMO_SEED_USERS[:6]: + for email, password, name, _avatar in _DEMO_SEED_USERS: if not _get_account_by_email(email): register_user(email, password, name) diff --git a/backend/app/services/community_store.py b/backend/app/services/community_store.py index 22a4edd..85f8b3a 100644 --- a/backend/app/services/community_store.py +++ b/backend/app/services/community_store.py @@ -361,6 +361,7 @@ def _gig_from_pb(r: dict) -> dict: "tags": r.get("tags") or [], "poster": r.get("posterName", ""), "poster_id": r.get("posterId", ""), + "poster_email": r.get("posterEmail", ""), "status": r.get("status", "open"), "category": r.get("category", ""), "location": r.get("location", ""), @@ -416,6 +417,7 @@ def apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> dict: "ok": True, "message": "申请已提交", "poster_id": g.get("posterId", ""), + "poster_email": g.get("posterEmail", ""), "title": g.get("title", ""), } @@ -568,7 +570,7 @@ _JSON_PATH = Path(__file__).resolve().parents[1] / "data" / "community_store.jso _j: dict[str, Any] = {} -_SEED_VERSION = 3 +_SEED_VERSION = 4 def _jload() -> None: @@ -753,6 +755,7 @@ def _json_apply_gig(gig_id: str, user_id: str, user_name: str, message: str) -> "ok": True, "message": "申请已提交", "poster_id": gig.get("poster_id", ""), + "poster_email": gig.get("poster_email", ""), "title": gig.get("title", ""), } diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index 1c1001e..f8487b7 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,14 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-09-04", + tag: "全站闭环增强", + items: [ + "签证向导中英文化 + 结果直达城市/计划/咨询;课程跨模块上下一课;岗位申请改走赏金页", + "赏金申请通知发布者;视频去掉无效外链;探索导航增加游民地图入口", + ], + }, { date: "2026-09-04", tag: "直播报名 · 私信角标", diff --git a/frontend/src/components/DigitalJobsClient.tsx b/frontend/src/components/DigitalJobsClient.tsx index e1d87b5..297ca9a 100644 --- a/frontend/src/components/DigitalJobsClient.tsx +++ b/frontend/src/components/DigitalJobsClient.tsx @@ -177,8 +177,13 @@ export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) { {item} ))} - - {t.digital.apply} + + {job.url.startsWith("http") ? t.digital.apply : t.digital.jobsGoGigs} ); diff --git a/frontend/src/components/DigitalLessonClient.tsx b/frontend/src/components/DigitalLessonClient.tsx index a9bd3fa..24a8208 100644 --- a/frontend/src/components/DigitalLessonClient.tsx +++ b/frontend/src/components/DigitalLessonClient.tsx @@ -24,6 +24,8 @@ export default function DigitalLessonClient({ const [missing, setMissing] = useState(false); const [loading, setLoading] = useState(true); const [hasNext, setHasNext] = useState(false); + const [nextHref, setNextHref] = useState(null); + const [prevHref, setPrevHref] = useState(null); useEffect(() => { let cancelled = false; @@ -31,18 +33,40 @@ export default function DigitalLessonClient({ (async () => { try { const course: DigitalCourse = await api.getDigitalCourse(); - const mod = course.modules[moduleIndex]; + const modules = course.modules || []; + const mod = modules[moduleIndex]; const meta = mod?.lessons?.[lessonIndex]; if (!meta) { if (!cancelled) { setMissing(true); setLocked(false); setLesson(null); + setHasNext(false); + setNextHref(null); + setPrevHref(null); } return; } + let next: string | null = null; + if (mod.lessons[lessonIndex + 1]) { + next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`; + } else if (modules[moduleIndex + 1]?.lessons?.length) { + next = `/digital/course/${moduleIndex + 1}/0`; + } + let prev: string | null = null; + if (lessonIndex > 0) { + prev = `/digital/course/${moduleIndex}/${lessonIndex - 1}`; + } else if (moduleIndex > 0) { + const prevMod = modules[moduleIndex - 1]; + const last = (prevMod?.lessons?.length || 1) - 1; + if (prevMod?.lessons?.length) { + prev = `/digital/course/${moduleIndex - 1}/${last}`; + } + } if (!cancelled) { - setHasNext(Boolean(mod.lessons[lessonIndex + 1])); + setHasNext(Boolean(next)); + setNextHref(next); + setPrevHref(prev); } try { const data = await api.getDigitalLesson(moduleIndex, lessonIndex, token || undefined); @@ -118,8 +142,8 @@ export default function DigitalLessonClient({ ); } - const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null; - const next = hasNext ? `/digital/course/${moduleIndex}/${lessonIndex + 1}` : null; + const prev = prevHref; + const next = hasNext ? nextHref : null; return (
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index cb4aa0c..3fe3b91 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -44,6 +44,7 @@ export default function Footer() {

{t.footer.explore}

{t.footer.destinations} + {t.nav.memberMap} {t.footer.nextStop} {!isHome && {t.footer.plan}} {!isHome && {t.footer.compare}} diff --git a/frontend/src/components/GigsClient.tsx b/frontend/src/components/GigsClient.tsx index 23becea..d7a1dc2 100644 --- a/frontend/src/components/GigsClient.tsx +++ b/frontend/src/components/GigsClient.tsx @@ -69,8 +69,8 @@ export default function GigsClient({ gigs }: { gigs: GigItem[] }) { return next; }); toast(t.gigs.applyOk, "success", { - href: "/digital", - label: t.nav.digital, + href: "/notifications", + label: t.nav.notifications, }); } catch { toast(t.gigs.applyFail, "error"); diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index aa48839..95f1097 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -49,6 +49,7 @@ export default function Navbar() { const explore: NavItem[] = [ { href: "/#destinations", label: t.nav.destinations, section: "destinations" }, { href: "/#map", label: t.nav.map, section: "map" }, + { href: "/map", label: t.nav.memberMap, section: "member-map" }, { href: "/next-stop", label: t.nav.nextStop, section: "next-stop" }, ...(!isHome ? [ @@ -147,6 +148,7 @@ export default function Navbar() { if (l.href === "/book") return pathname.startsWith("/book"); if (l.href === "/digital") return pathname.startsWith("/digital"); if (l.href === "/dating") return pathname.startsWith("/dating"); + if (l.href === "/map") return pathname === "/map"; if (l.href === "/meetups") return pathname.startsWith("/meetups"); if (l.href === "/community") return pathname.startsWith("/community"); if (l.href === "/gigs") return pathname.startsWith("/gigs"); diff --git a/frontend/src/components/VisaWizard.tsx b/frontend/src/components/VisaWizard.tsx index b413ab3..39828da 100644 --- a/frontend/src/components/VisaWizard.tsx +++ b/frontend/src/components/VisaWizard.tsx @@ -1,51 +1,100 @@ "use client"; import { useMemo, useState } from "react"; +import Link from "next/link"; import type { Visa } from "@/lib/types"; +import { useI18n } from "@/lib/i18n"; interface Props { visas: Visa[]; } -const INCOME_OPTIONS = [ - { key: "low", label: "💰 低于 ¥8,000/月", max: 8000 }, - { key: "mid", label: "💼 ¥8,000 – ¥20,000/月", max: 20000 }, - { key: "high", label: "🚀 ¥20,000 – ¥50,000/月", max: 50000 }, - { key: "premium", label: "👑 高于 ¥50,000/月", max: Infinity }, -]; +const INCOME_KEYS = [ + { key: "low", max: 8000 }, + { key: "mid", max: 20000 }, + { key: "high", max: 50000 }, + { key: "premium", max: Infinity }, +] as const; -const DURATION_OPTIONS = [ - { key: "short", label: "⏱️ 1-3 个月", months: 3 }, - { key: "medium", label: "📅 3-12 个月", months: 12 }, - { key: "long", label: "🏠 1 年以上", months: 24 }, -]; +const DURATION_KEYS = [ + { key: "short", months: 3 }, + { key: "medium", months: 12 }, + { key: "long", months: 24 }, +] as const; -const REGION_OPTIONS = [ - { key: "any", label: "🌏 不限" }, - { key: "europe", label: "🏰 欧洲" }, - { key: "sea", label: "🌴 东南亚" }, - { key: "latam", label: "🌮 拉美" }, -]; +const REGION_KEYS = ["any", "europe", "sea", "latam"] as const; const VISA_INCOME: Record = { - "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 = { - "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 = { + "1": "lisbon", + "2": "barcelona", + "3": "bali", + "4": "chiangmai", + "5": "mexico", + "7": "tbilisi", + "8": "dubai", + "9": "medellin", + "10": "seoul", + "11": "berlin", + "12": "chiangmai", }; export default function VisaWizard({ visas }: Props) { + const { t } = useI18n(); + const w = t.visaWizard; const [step, setStep] = useState(0); const [income, setIncome] = useState(""); const [duration, setDuration] = useState(""); const [region, setRegion] = useState("any"); const [showResults, setShowResults] = useState(false); + const incomeOptions = INCOME_KEYS.map((o) => ({ + ...o, + label: w[`income_${o.key}` as keyof typeof w] as string, + })); + const durationOptions = DURATION_KEYS.map((o) => ({ + ...o, + label: w[`duration_${o.key}` as keyof typeof w] as string, + })); + const regionOptions = REGION_KEYS.map((key) => ({ + key, + label: w[`region_${key}` as keyof typeof w] as string, + })); + const stepLabels = [w.stepIncome, w.stepDuration, w.stepRegion]; + const results = useMemo(() => { if (!income || !duration) return []; - const incomeOpt = INCOME_OPTIONS.find((o) => o.key === income)!; - const durOpt = DURATION_OPTIONS.find((o) => o.key === duration)!; + const incomeOpt = INCOME_KEYS.find((o) => o.key === income)!; + const durOpt = DURATION_KEYS.find((o) => o.key === duration)!; return visas .map((v) => { @@ -91,16 +140,19 @@ export default function VisaWizard({ visas }: Props) { return (
- 🧙 VISA WIZARD -

签证智能匹配

-

回答 3 个问题,找到最适合你的远程工作签证

+ {w.tag} +

{w.title}

+

{w.subtitle}

{!showResults ? (
- {["月收入", "停留时长", "目的地"].map((label, i) => ( -
+ {stepLabels.map((label, i) => ( +
{i < step ? "✓" : i + 1} {label}
@@ -109,8 +161,13 @@ export default function VisaWizard({ visas }: Props) { {step === 0 && (
- {INCOME_OPTIONS.map((o) => ( - ))} @@ -119,8 +176,13 @@ export default function VisaWizard({ visas }: Props) { {step === 1 && (
- {DURATION_OPTIONS.map((o) => ( - ))} @@ -129,8 +191,13 @@ export default function VisaWizard({ visas }: Props) { {step === 2 && (
- {REGION_OPTIONS.map((o) => ( - ))} @@ -138,40 +205,83 @@ export default function VisaWizard({ visas }: Props) { )}
- {step > 0 && } + {step > 0 && ( + + )} {step < 2 ? ( - + ) : ( - + )}
) : (
{results.length === 0 ? ( -

暂无完全匹配的签证,建议咨询专业移民顾问或放宽条件重试

+
+

{w.empty}

+
+ + {w.ctaServices} + + + {w.ctaCommunity} + +
+
) : (
- {results.map(({ visa, score }, i) => ( -
- {i === 0 && 🏆 最佳匹配} -
- {visa.flag} -
-

{visa.name}

- {score}% 匹配 + {results.map(({ visa, score }, i) => { + const dest = VISA_DEST[visa.id]; + return ( +
+ {i === 0 && {w.bestMatch}} +
+ {visa.flag} +
+

{visa.name}

+ + {score}% {w.match} + +
+
+
    +
  • 💰 {visa.income_req}
  • +
  • 📅 {visa.duration}
  • +
  • ⏱️ {visa.approval_time}
  • +
+
+ {dest && ( + + {w.ctaCity} + + )} + + {w.ctaPlan} + + + {w.ctaServices} +
-
    -
  • 💰 {visa.income_req}
  • -
  • 📅 {visa.duration}
  • -
  • ⏱️ {visa.approval_time}
  • -
-
- ))} + ); + })}
)} - +
)}
diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx index d00f236..aad002f 100644 --- a/frontend/src/components/WorldMap.tsx +++ b/frontend/src/components/WorldMap.tsx @@ -115,9 +115,14 @@ export default function WorldMap({ destinations }: Props) { 👆

{t.home.mapPick}

{t.home.mapPickHint}

- - {t.home.mapBrowse} - +
+ + {t.home.mapBrowse} + + + {t.home.mapMembers} + +
)}
diff --git a/frontend/src/lib/i18n/dictionaries.ts b/frontend/src/lib/i18n/dictionaries.ts index e869f7f..2e675f5 100644 --- a/frontend/src/lib/i18n/dictionaries.ts +++ b/frontend/src/lib/i18n/dictionaries.ts @@ -4,6 +4,7 @@ export const zh = { nav: { map: "🗺️ 地图", mapShort: "地图", + memberMap: "🧭 游民地图", destinations: "🌍 目的地", plan: "🗓️ 计划", compare: "⚖️ 对比", @@ -109,6 +110,7 @@ export const zh = { mapTemp: "均温", mapRating: "评分", mapBrowse: "浏览全部目的地 →", + mapMembers: "看看谁在那里 →", destTag: "🌴 TOP DESTINATIONS", destTitle: "热门旅居目的地", destSubtitle: "筛选、收藏,点击进入城市详情了解更多", @@ -527,6 +529,36 @@ export const zh = { openChat: "新窗口打开聊天", iframeHint: "若画面空白,请用上方按钮在新窗口打开", }, + visaWizard: { + tag: "🧙 VISA WIZARD", + title: "签证智能匹配", + subtitle: "回答 3 个问题,找到最适合你的远程工作签证", + stepIncome: "月收入", + stepDuration: "停留时长", + stepRegion: "目的地", + income_low: "💰 低于 ¥8,000/月", + income_mid: "💼 ¥8,000 – ¥20,000/月", + income_high: "🚀 ¥20,000 – ¥50,000/月", + income_premium: "👑 高于 ¥50,000/月", + duration_short: "⏱️ 1-3 个月", + duration_medium: "📅 3-12 个月", + duration_long: "🏠 1 年以上", + region_any: "🌏 不限", + region_europe: "🏰 欧洲", + region_sea: "🌴 东南亚", + region_latam: "🌮 拉美", + prev: "上一步", + next: "下一步", + finish: "查看推荐 ✨", + empty: "暂无完全匹配的签证,建议咨询顾问或放宽条件重试", + bestMatch: "🏆 最佳匹配", + match: "匹配", + reset: "🔄 重新匹配", + ctaCity: "查看城市", + ctaPlan: "写入计划", + ctaServices: "签证咨询", + ctaCommunity: "去社区问问", + }, digital: { tag: "🎓 ACADEMY", title: "数字游民学院", @@ -1285,6 +1317,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string nav: { map: "🗺️ Map", mapShort: "Map", + memberMap: "🧭 Who's there", destinations: "🌍 Destinations", plan: "🗓️ Plan", compare: "⚖️ Compare", @@ -1390,6 +1423,7 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string mapTemp: "Avg temp", mapRating: "Rating", mapBrowse: "Browse all cities →", + mapMembers: "See who's there →", destTag: "🌴 TOP DESTINATIONS", destTitle: "Top nomad cities", destSubtitle: "Filter, favorite, tap a city for full details", @@ -1808,6 +1842,36 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string openChat: "Open chat in new tab", iframeHint: "If the embed is blank, open the rooms in a new tab above", }, + visaWizard: { + tag: "🧙 VISA WIZARD", + title: "Visa matcher", + subtitle: "Answer 3 questions to find remote-work visas that fit", + stepIncome: "Income", + stepDuration: "Stay length", + stepRegion: "Region", + income_low: "💰 Under ¥8,000/mo", + income_mid: "💼 ¥8,000 – ¥20,000/mo", + income_high: "🚀 ¥20,000 – ¥50,000/mo", + income_premium: "👑 Over ¥50,000/mo", + duration_short: "⏱️ 1–3 months", + duration_medium: "📅 3–12 months", + duration_long: "🏠 1 year+", + region_any: "🌏 Anywhere", + region_europe: "🏰 Europe", + region_sea: "🌴 SE Asia", + region_latam: "🌮 LatAm", + prev: "Back", + next: "Next", + finish: "See matches ✨", + empty: "No strong matches — try looser filters or talk to an advisor", + bestMatch: "🏆 Best match", + match: "match", + reset: "🔄 Match again", + ctaCity: "View city", + ctaPlan: "Add to plan", + ctaServices: "Visa help", + ctaCommunity: "Ask community", + }, digital: { tag: "🎓 ACADEMY", title: "Nomad academy", diff --git a/scripts/deploy_dating_fix.py b/scripts/deploy_dating_fix.py new file mode 100644 index 0000000..e7dde81 --- /dev/null +++ b/scripts/deploy_dating_fix.py @@ -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())