/** * nomadro XHS offline mirror * Reuses the same CSS classes / visual structure as frontend H5 (localhost:3000). * No network — data from window.NOMADRO_DATA. */ (function () { "use strict"; var D = window.NOMADRO_DATA || {}; var Bridge = window.XhsBridge; var FAV_KEY = "nomadro_xhs_favs"; var root = document.getElementById("app-root"); var backBtn = document.getElementById("xhs-back"); var route = { name: "home", params: {} }; var favs = []; var destFilter = "all"; var destSort = "rating"; var destSearch = ""; var visaFilter = "all"; function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function on(el, ev, fn) { if (el) el.addEventListener(ev, fn); } function storageGet(key, fb) { if (Bridge && Bridge.getLocalData) { return Bridge.getLocalData(key).then(function (v) { return v == null ? fb : v; }); } try { var raw = localStorage.getItem(key); return Promise.resolve(raw ? JSON.parse(raw) : fb); } catch (e) { return Promise.resolve(fb); } } function storageSet(key, val) { if (Bridge && Bridge.setLocalData) return Bridge.setLocalData(key, val); try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {} return Promise.resolve(true); } function go(name, params) { route = { name: name, params: params || {} }; render(); window.scrollTo(0, 0); } function destBySlug(slug) { var list = D.destinations || []; for (var i = 0; i < list.length; i++) if (list[i].slug === slug) return list[i]; return null; } function isFav(slug) { return favs.indexOf(slug) >= 0; } function toggleFav(slug) { var i = favs.indexOf(slug); if (i >= 0) favs.splice(i, 1); else favs.push(slug); storageSet(FAV_KEY, favs); render(); } function logoSvg(id) { return ( '" ); } /* ===== chrome shared with H5 ===== */ function renderNavbar() { return ( '" ); } function renderTicker() { var msgs = D.ticker || []; if (!msgs.length) return ""; return ( '
' + msgs.concat(msgs).map(function (m) { return '' + esc(m) + ""; }).join("") + "
" ); } function renderHero() { var stats = D.stats || {}; return ( '
' + '
' + '
' + '
' + '
' + '
nomadro · ' + esc(stats.total_nomads || "35.6M") + " 全球数字游民正在路上
" + '

用一行代码
环游世界 🌏

' + '

|

' + "
" + '
' + '
' + '' + "" + '' + '' + "" + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + "" + '' + "" + '
' + '
🏝️ Bali
WiFi 98Mbps
' + '
☕ Lisbon
€800/mo
' + '
🏔️ Chiang Mai
⭐ 4.9
' + "
" + "
" + "
" + "
" + '
' + '
' + '' + '从城市开始' + "
" + "
" + "
" + '
向下滚动
' + "
" ); } function renderJourney() { return ( '
' + '
' + '
' + '

三条环,走通旅居

' + "

探索去哪 → 遇见同行 → 路上成长

" + "
" + '
    ' + '
  1. ' + '
    01🌍
    ' + "

    探索

    目的地、签证与智能匹配

    " + '
    ' + "
  2. " + '
  3. ' + '
    02🤝
    ' + "

    连接

    活动、社区与游民心声

    " + '
    ' + "
  4. " + '
  5. ' + '
    03🚀
    ' + "

    成长

    学院、博客与工具箱

    " + '
    ' + "
  6. " + "
" + "
" + "
" ); } function filteredDests() { var list = (D.destinations || []).slice(); if (destFilter !== "all") list = list.filter(function (d) { return d.region === destFilter; }); if (destSearch) { var q = destSearch.toLowerCase(); list = list.filter(function (d) { return String(d.name).toLowerCase().indexOf(q) >= 0 || String(d.country).toLowerCase().indexOf(q) >= 0; }); } if (destSort === "cost-asc") list.sort(function (a, b) { return a.cost - b.cost; }); else if (destSort === "cost-desc") list.sort(function (a, b) { return b.cost - a.cost; }); else if (destSort === "speed") list.sort(function (a, b) { return b.speed - a.speed; }); else list.sort(function (a, b) { return b.rating - a.rating; }); return list; } function destCard(d) { var hue = d.hue || 170; return ( '
' + '' + '
' + '
' + esc(d.emoji) + "
" + '
' + esc(d.tag) + "
" + "
" + '
' + "

" + esc(d.name) + ", " + esc(d.country) + (isFav(d.slug) ? " ★" : "") + "

" + "

" + esc(d.description) + "

" + '
' + "💰 ¥" + Number(d.cost).toLocaleString() + "" + "📶 " + d.speed + "Mbps" + "🌡️ " + d.temperature + "°C" + "
" + '
' + "⭐".repeat(Math.max(1, Math.round((d.rating || 8) / 2))) + "" + d.rating + "
" + "
" + "
" + "
" ); } function renderDestinations(limit) { var list = filteredDests(); var visible = typeof limit === "number" ? list.slice(0, limit) : list; var filters = [ { key: "all", label: "全部" }, { key: "sea", label: "东南亚" }, { key: "europe", label: "欧洲" }, { key: "latam", label: "拉美" }, { key: "asia", label: "东亚" }, ]; return ( '
' + '
' + '
' + '' + "

热门旅居目的地

" + "

按预算、网速与气候筛选,点进城市看详情

" + '' + "
" + '
' + '" + '
' + filters.map(function (f) { return '"; }).join("") + "
" + '
" + "
" + '
' + visible.map(destCard).join("") + "
" + (typeof limit === "number" && list.length > limit ? '
" : "") + "
" + "
" ); } function renderVisas() { var list = (D.visas || []).slice(); if (visaFilter === "easy") list = list.filter(function (v) { return v.difficulty <= 35; }); else if (visaFilter === "medium") list = list.filter(function (v) { return v.difficulty > 35 && v.difficulty <= 55; }); else if (visaFilter === "budget") list = list.filter(function (v) { return v.badge_type === "budget"; }); var filters = [ { key: "all", label: "🌏 全部" }, { key: "easy", label: "✅ 简单" }, { key: "medium", label: "📋 中等" }, { key: "budget", label: "💰 低成本" }, ]; return ( '
' + '
' + '
' + '

远程友好签证

' + "

离线快照,出发前请再核对官方政策

" + "
" + '
' + filters.map(function (f) { return '"; }).join("") + "
" + '
' + list.map(function (v, i) { return ( '
' + '
' + esc(v.flag) + "
" + "

" + esc(v.name) + "

" + '
' + esc(v.badge) + "
" + '
    ' + "
  • 📅 " + esc(v.duration) + "
  • " + "
  • 💰 " + esc(v.income_req) + "
  • " + "
  • ⏱️ " + esc(v.approval_time) + "
  • " + "
  • " + esc(v.extra) + "
  • " + "
" + '
难度' + '
' + "" + esc(v.difficulty_label) + "" + "
" + "
" ); }).join("") + "
" + "
" + "
" ); } function renderVoices() { return ( '
' + '
' + '

游民心声

真实旅居反馈

' + '
' + (D.testimonials || []).map(function (t) { return ( '
' + '
' + esc(t.avatar) + "
" + "

" + esc(t.content) + "

" + '
' + esc(t.author) + "" + esc(t.role) + "
" + "
" ); }).join("") + "
" + "
" + "
" ); } function renderLifestyle() { var items = D.lifestyle || []; return ( '
' + '
' + '

游民的一天

点选时段看细节

' + '
' + items.map(function (it, idx) { return ( '" ); }).join("") + "
" + "
" + "
" ); } function renderFaq() { return ( '
' + '
' + '

常见问题

' + '
' + (D.faqs || []).map(function (f) { return ( '
' + "" + esc(f.question) + "" + "

" + esc(f.answer) + "

" + "
" ); }).join("") + "
" + "
" + "
" ); } function renderFooter() { return ( '" ); } function pageHome() { return ( '
' + renderNavbar() + renderTicker() + renderHero() + renderJourney() + renderDestinations(6) + renderLifestyle() + renderVisas() + renderVoices() + renderFaq() + renderFooter() + "
" ); } function pageExplore() { return ( '
' + renderNavbar() + '
' + '

探索

发现去哪

' + '
' + '' + '' + '' + "
" + "
" + renderDestinations() + renderFooter() + "
" ); } function pageDestination(slug) { var d = destBySlug(slug); if (!d) return '

未找到城市

'; return ( '
' + renderNavbar() + '
' + '' + '
' + '
' + esc(d.emoji) + "
" + "

" + esc(d.name) + " · " + esc(d.country) + "

" + '

' + esc(d.tag) + " · " + esc(d.nomads_count) + " 游民

" + "

" + esc(d.description) + "

" + '
' + '" + '' + "
" + "
" + '
' + "💰 ¥" + Number(d.cost).toLocaleString() + "/月" + "📶 " + d.speed + "Mbps" + "🌡️ " + d.temperature + "°C" + "⭐ " + d.rating + "" + "
" + '
' + (d.highlights || []).map(function (h) { return '' + esc(h) + ""; }).join("") + "
" + "
" + renderFooter() + "
" ); } function pageConnect() { return ( '
' + renderNavbar() + '
' + '

连接

遇见同行(离线可浏览)

' + '
' + '' + '' + '' + "
" + renderFooter() + "
" ); } function pageMeetups() { return ( '
' + renderNavbar() + '
' + '

游民活动

离线快照 · RSVP 请到网页版

' + '
' + (D.meetups || []).map(function (m) { return ( '
' + '
' + esc(m.emoji || "🎉") + "
" + "

" + esc(m.title) + "

" + "

" + esc(m.description) + "

" + '
' + esc(m.city) + "" + esc(m.date) + " " + esc(m.time || "") + "" + (m.rsvp_count || 0) + " 人
" + "
" ); }).join("") + "
" + renderFooter() + "
" ); } function pageCommunity() { return ( '
' + renderNavbar() + '
' + '

社区讨论

' + '
' + (D.discussions || []).map(function (d) { return ( '
' + "

" + (d.is_pinned ? "📌 " : "") + esc(d.title) + "

" + "

" + esc(d.excerpt) + "

" + '
' + esc(d.author) + "" + (d.reply_count || 0) + " 回复♥ " + (d.like_count || 0) + "
" + "
" ); }).join("") + "
" + renderFooter() + "
" ); } function pageGrow() { return ( '
' + renderNavbar() + '
' + '

成长

路上做事

' + '
' + '' + '' + '' + '' + "
" + renderFooter() + "
" ); } function pageDigital() { var modules = D.course || []; return ( '
' + renderNavbar() + '
' + '

游民学院

' + modules.map(function (mod, mi) { return ( '

' + esc(mod.title) + "

" + '
' + (mod.lessons || []).map(function (les, li) { var key = mi + "-" + li; var lesson = (D.lessons || {})[key] || {}; return ( '
' + "

" + (les.free ? "🆓 " : "🔒 ") + esc(les.title) + "

" + "

" + esc(les.duration) + "

" + "

" + esc(lesson.content || "") + "

" + "
" ); }).join("") + "
" ); }).join("") + "
" + renderFooter() + "
" ); } function pageBlog() { return ( '
' + renderNavbar() + '
' + '

博客

' + '
" + renderFooter() + "
" ); } function pageBlogDetail(slug) { var b = null; var list = D.blog || []; for (var i = 0; i < list.length; i++) if (list[i].slug === slug) b = list[i]; if (!b) return '

文章不存在

'; var content = (D.blogContent && D.blogContent[slug]) || b.excerpt || ""; return ( '
' + renderNavbar() + '
' + '' + '
" + "

" + esc(b.title) + "

" + '

' + esc(b.author) + " · " + esc(b.published_at) + " · " + (b.read_time || "?") + " 分钟

" + '
' + esc(content) + "
" + "
" + renderFooter() + "
" ); } function pageTools() { return ( '
' + renderNavbar() + '
' + '

工具箱

' + '
' + (D.tools || []).map(function (t) { return ( '
' + esc(t.emoji) + "" + "

" + esc(t.name) + "

" + esc(t.description) + "

" ); }).join("") + '
🧮

月费用估算

' + '
' + '' + '' + '' + '' + '' + '' + '' + '

' + "
" + "
" + renderFooter() + "
" ); } function pageMatcher() { var budget = 6000; var scored = (D.destinations || []).map(function (d) { var score = 40 + (d.rating || 0) * 4 + Math.min(20, (d.speed || 0) / 10); if (d.cost <= budget) score += 20; else score -= 10; return { d: d, score: Math.round(score) }; }).sort(function (a, b) { return b.score - a.score; }); return ( '
' + renderNavbar() + '
' + '

智能匹配

离线本地打分 · 默认预算 ¥6000

' + '
' + scored.map(function (item) { return destCard(item.d).replace("", " · 匹配 " + item.score + ""); }).join("") + "
" + "
" + renderFooter() + "
" ); } function pageCompare() { var list = D.destinations || []; var a = list[0] || {}; var b = list[1] || {}; return ( '
' + renderNavbar() + '
' + '

城市对比

' + '
' + (a.slug ? destCard(a) : "") + (b.slug ? destCard(b) : "") + "
" + '
' + '' + "" + "" + "" + "" + "" + "
指标" + esc(a.name) + "" + esc(b.name) + "
月费¥" + a.cost + "¥" + b.cost + "
网速" + a.speed + "" + b.speed + "
均温" + a.temperature + "" + b.temperature + "
评分" + a.rating + "" + b.rating + "
" + "
" + renderFooter() + "
" ); } function pageMine() { var cards = favs.map(function (slug) { var d = destBySlug(slug); return d ? destCard(d) : ""; }).join(""); return ( '
' + renderNavbar() + '
' + '

我的

' + "

" + esc(D.offlineNote || "") + "

" + '

收藏的城市

' + '
' + (cards || '

还没有收藏

') + "
" + "
" + renderFooter() + "
" ); } function pageVisasOnly() { return '
' + renderNavbar() + '
' + renderVisas() + "
" + renderFooter() + "
"; } function pageVoicesOnly() { return '
' + renderNavbar() + '
' + renderVoices() + "
" + renderFooter() + "
"; } function pageFaqOnly() { return '
' + renderNavbar() + '
' + renderFaq() + "
" + renderFooter() + "
"; } function render() { var html = ""; switch (route.name) { case "home": html = pageHome(); break; case "explore": html = pageExplore(); break; case "destination": html = pageDestination(route.params.slug); break; case "connect": html = pageConnect(); break; case "meetups": html = pageMeetups(); break; case "community": html = pageCommunity(); break; case "voices": html = pageVoicesOnly(); break; case "grow": html = pageGrow(); break; case "digital": html = pageDigital(); break; case "blog": html = pageBlog(); break; case "blog-detail": html = pageBlogDetail(route.params.slug); break; case "tools": html = pageTools(); break; case "matcher": html = pageMatcher(); break; case "compare": html = pageCompare(); break; case "visas": html = pageVisasOnly(); break; case "faq": html = pageFaqOnly(); break; case "mine": html = pageMine(); break; default: html = pageHome(); } root.innerHTML = html; backBtn.className = "xhs-back" + (route.name === "home" ? "" : " is-on"); bind(); startTypewriter(); } function bind() { Array.prototype.forEach.call(document.querySelectorAll("[data-nav]"), function (el) { on(el, "click", function (e) { e.preventDefault(); go(el.getAttribute("data-nav")); }); }); Array.prototype.forEach.call(document.querySelectorAll("[data-dest]"), function (el) { on(el, "click", function (e) { e.preventDefault(); go("destination", { slug: el.getAttribute("data-dest") }); }); }); Array.prototype.forEach.call(document.querySelectorAll("[data-blog]"), function (el) { on(el, "click", function (e) { e.preventDefault(); go("blog-detail", { slug: el.getAttribute("data-blog") }); }); }); Array.prototype.forEach.call(document.querySelectorAll("[data-dest-filter]"), function (el) { on(el, "click", function () { destFilter = el.getAttribute("data-dest-filter"); render(); }); }); Array.prototype.forEach.call(document.querySelectorAll("[data-visa-filter]"), function (el) { on(el, "click", function () { visaFilter = el.getAttribute("data-visa-filter"); render(); }); }); Array.prototype.forEach.call(document.querySelectorAll("[data-fav]"), function (el) { on(el, "click", function () { toggleFav(el.getAttribute("data-fav")); }); }); Array.prototype.forEach.call(document.querySelectorAll("[data-scroll]"), function (el) { on(el, "click", function (e) { e.preventDefault(); var id = el.getAttribute("data-scroll"); var node = document.getElementById(id); if (node) node.scrollIntoView({ behavior: "smooth", block: "start" }); }); }); var search = document.getElementById("dest-search"); if (search) { on(search, "change", function () { destSearch = search.value || ""; render(); }); on(search, "keydown", function (e) { if (e.key === "Enter") { destSearch = search.value || ""; render(); } }); } var sort = document.getElementById("dest-sort"); if (sort) on(sort, "change", function () { destSort = sort.value; render(); }); var cRun = document.getElementById("c-run"); if (cRun) { on(cRun, "click", function () { function n(id) { var el = document.getElementById(id); return Number(el && el.value) || 0; } var out = document.getElementById("c-out"); if (out) out.textContent = "预估月开销约 ¥" + (n("c-house") + n("c-food") + n("c-other")); }); } } var typeTimer = null; function startTypewriter() { var el = document.getElementById("typewriter"); if (!el) return; var PHRASES = [ "数字游民不是逃离生活,而是用科技重新定义生活。", "在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️", "世界那么大,你的办公室可以在任何地方 🌍", "远程工作 + 全球旅居 = 无限可能 ✨", ]; var phraseIdx = 0, charIdx = 0, deleting = false; if (typeTimer) clearTimeout(typeTimer); function type() { var current = PHRASES[phraseIdx]; if (!deleting) { el.textContent = current.substring(0, charIdx + 1); charIdx++; if (charIdx === current.length) { deleting = true; typeTimer = setTimeout(type, 2500); return; } typeTimer = setTimeout(type, 60); } else { el.textContent = current.substring(0, charIdx - 1); charIdx--; if (charIdx === 0) { deleting = false; phraseIdx = (phraseIdx + 1) % PHRASES.length; } typeTimer = setTimeout(type, 30); } } typeTimer = setTimeout(type, 400); } function parseHash() { var h = (location.hash || "#/").replace(/^#\/?/, ""); var parts = h.split("/"); if (!parts[0] || parts[0] === "") return go("home"); if (parts[0] === "destination" && parts[1]) return go("destination", { slug: parts[1] }); if (parts[0] === "blog" && parts[1]) return go("blog-detail", { slug: parts[1] }); return go(parts[0]); } on(backBtn, "click", function () { if (history.length > 1) history.back(); else go("home"); }); on(window, "hashchange", parseHash); // enrich stats like H5 API if (!D.stats) D.stats = {}; D.stats.total_nomads = D.stats.total_nomads || "35.6M"; D.stats.countries = D.stats.countries || 195; D.stats.satisfaction = D.stats.satisfaction || 87; storageGet(FAV_KEY, []).then(function (v) { favs = Array.isArray(v) ? v : []; if (location.hash && location.hash.length > 2) parseHash(); else render(); }); })();