PageSourceSearch

https://nghiendeutsch.app/assets/app.js?v=2.8.25-a4fbc7bd

js nghiendeutsch.app collected 2026-10-02 01:14:21 UTC 24,639 bytes, 505 lines download raw bytes

1/* NghienDeutsch website — language switch (mặc định theo IP), giá Pro động, nav */
2(function () {
3  var LANGS = ["vi", "en", "de"];
4  var WORKER = "https://nghienducchua-proxy.thoatran21012.workers.dev";
5  var _proPrice = null; // { vnd:'99.000₫', eur:'≈ 3,99 € / tháng' } lấy từ /pay-info
6  var _plans = [];
7  var _releaseLinks = null;
8  var _installCopy = null;
9
10  function fmtVnd(x) { return String(Math.round(Number(x) || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ".") + "₫"; }
11  function fmtEur(x) { return (Number(x) || 0).toFixed(2).replace(".", ",") + " €"; }
12
13  function storedLang() {
14    var q = new URLSearchParams(location.search).get("lang");
15    if (LANGS.indexOf(q) >= 0) return q;
16    var s = localStorage.getItem("nd_lang");
17    if (LANGS.indexOf(s) >= 0) return s;
18    return null;
19  }
20  function pickLangByCountry(c) {
21    c = String(c || "").toUpperCase();
22    if (c === "DE" || c === "AT" || c === "CH") return "de";
23    if (c === "VN") return "vi";
24    return "en";
25  }
26  function curLang() { return storedLang() || document.documentElement.lang || "vi"; }
27  function localizedPlan(plan, lang) {
28    var all = plan && plan.i18n || {};
29    var content = all[lang] || all.vi || {};
30    return {
31      display_name: content.display_name || plan.display_name || plan.name,
32      description: content.description || "",
33      features: Array.isArray(content.features) && content.features.length ? content.features : (plan.features || []),
34    };
35  }
36
37  // Chữ trang Cài đặt/Cập nhật mà Admin sửa trong Vận hành & bảo trì. Tải từ
38  // /install-copy; khóa nào Admin để trống thì giữ nguyên chữ đóng gói trong i18n.js,
39  // nên mất mạng hay chưa cấu hình gì thì trang vẫn đủ chữ.
40  var _installCopy = null;
41
42  function applyLang(lang) {
43    var packed = (window.I18N && window.I18N[lang]) || {};
44    var dict = packed;
45    var override = _installCopy && _installCopy[lang];
46    if (override) {
47      dict = {};
48      for (var pk in packed) if (Object.prototype.hasOwnProperty.call(packed, pk)) dict[pk] = packed[pk];
49      for (var ok in override) if (Object.prototype.hasOwnProperty.call(override, ok)) dict[ok] = override[ok];
50    }
51    document.documentElement.lang = lang;
52    var page = location.pathname.split("/").pop() || "index.html";
53    // Bảng tra cứu: MỖI trang phải có title + description riêng. Trước đây viết
54    // bằng chuỗi ba ngôi nên trang mới (update.html) rơi về meta của trang chủ,
55    // và privacy/terms cũng dùng description của trang chủ.
56    var META = {
57      "index.html": ["meta.home.title", "meta.home.description"],
58      "install.html": ["meta.install.title", "meta.install.description"],
59      "update.html": ["meta.update.title", "meta.update.description"],
60      "privacy.html": ["meta.privacy.title", "meta.privacy.description"],
61      "terms.html": ["meta.terms.title", "meta.terms.description"],
62    };
63    var meta = META[page] || META["index.html"];
64    if (dict[meta[0]]) document.title = dict[meta[0]];
65    var description = document.querySelector('meta[name="description"]');
66    if (description && dict[meta[1]]) description.setAttribute("content", dict[meta[1]]);
67    document.querySelectorAll("[data-i18n]").forEach(function (el) {
68      var k = el.getAttribute("data-i18n");
69      if (dict[k] != null) el.textContent = dict[k];
70    });
71    document.querySelectorAll("[data-i18n-html]").forEach(function (el) {
72      var k = el.getAttribute("data-i18n-html");
73      if (dict[k] != null) el.innerHTML = dict[k];
74    });
75    localStorage.setItem("nd_lang", lang);
76    var lbl = document.querySelector(".lang-switch");
77    if (lbl) lbl.textContent = lang.toUpperCase() + " ▾";
78    applyProPrice(); // giá động phải đặt SAU i18n (i18n vừa ghi đè lại textContent)
79    applyInstallCopy(lang); // chữ hướng dẫn Admin sửa, đè lên bản dựng sẵn
80    syncCompareLabels();    // bảng so sánh xếp dạng thẻ trên điện thoại cần tên cột
81    if (_releaseLinks) applyInstallLinks(_releaseLinks);
82    enhanceChromeUrlChips();     // chạy SAU i18n vì innerHTML vừa dựng lại các <code>
83    enhanceLinkboxCopy();        // nút sao chép link dài (Cách 2)
84  }
85
86  // Chữ hướng dẫn cài đặt do Admin nhập (Worker /version → install_copy). Admin gõ
87  // chữ thuần nên KHÔNG dùng innerHTML ở đây: mọi thứ dựng bằng createTextNode, còn
88  // các địa chỉ dạng chrome:// | edge:// | about: được bọc <code> để enhanceChromeUrlChips
89  // biến tiếp thành chip bấm-để-copy. Một tài khoản admin bị chiếm cũng không nhúng
90  // được thẻ script vào trang công khai.
91  var SCHEME_TOKEN = /((?:chrome|edge|brave|opera|vivaldi|about|moz-extension):\/?\/?[^\s,;)]+)/g;
92  function renderCopyInto(el, text) {
93    while (el.firstChild) el.removeChild(el.firstChild);
94    String(text).split(SCHEME_TOKEN).forEach(function (part, i) {
95      if (!part) return;
96      // split() với capture group: phần tử lẻ luôn là token khớp regex.
97      if (i % 2 === 1) {
98        var code = document.createElement("code");
99        code.textContent = part.replace(/[.,;:]+$/, "");
100        el.appendChild(code);
101        var tail = part.match(/[.,;:]+$/);
102        if (tail) el.appendChild(document.createTextNode(tail[0]));
103      } else {
104        el.appendChild(document.createTextNode(part));
105      }
106    });
107  }
108  // Dưới 640px bảng so sánh xếp lại thành thẻ, mỗi ô ✓ phải tự nói nó thuộc cột nào.
109  // Chép nhãn từ <thead> thay vì viết cứng để không phải dịch lần thứ hai — đổi ngôn
110  // ngữ là nhãn đổi theo.
111  function syncCompareLabels() {
112    document.querySelectorAll("table.compare").forEach(function (table) {
113      var heads = table.querySelectorAll("thead th");
114      if (heads.length < 2) return;
115      table.querySelectorAll("tbody tr").forEach(function (row) {
116        row.querySelectorAll("td").forEach(function (cell, i) {
117          if (i === 0 || !heads[i]) return;
118          cell.setAttribute("data-label", (heads[i].textContent || "").trim());
119          if (heads[i].classList.contains("us")) cell.setAttribute("data-us", "1");
120        });
121      });
122    });
123  }
124
125  function applyInstallCopy(lang) {
126    // Không fallback sang vi: nếu Admin mới sửa tiếng Việt mà DeepL chưa kịp dịch
127    // EN/DE, giữ nguyên bản dựng sẵn trong i18n.js thay vì hiện tiếng Việt trên
128    // trang English (đúng lỗi ngôn ngữ lẫn trong hình 1).
129    var copy = _installCopy && _installCopy[lang];
130    if (!copy) return;
131    Object.keys(copy).forEach(function (key) {
132      var text = copy[key];
133      if (!text) return;
134      document.querySelectorAll('[data-i18n="' + key + '"],[data-i18n-html="' + key + '"]').forEach(function (el) {
135        renderCopyInto(el, text);
136      });
137    });
138  }
139
140  // Trang web KHÔNG thể mở link chrome:// (Chrome chặn vì bảo mật). Thay vì link
141  // chết, biến mỗi <code>chrome://…</code> thành chip bấm-để-copy: user dán vào
142  // thanh địa chỉ là xong. i18n dựng lại innerHTML mỗi lần đổi ngôn ngữ nên hàm
143  // này chạy lại sau đó và bỏ qua chip đã tạo (data-copy-ready).
144  //
145  // edge:// và about:debugging bị chặn y hệt, nên Cách 3 (Firefox) và Cách 4 (Edge)
146  // cũng cần chip — không riêng gì Chrome.
147  var COPY_LABEL = { vi: "Sao chép", en: "Copy", de: "Kopieren" };
148  var COPIED_LABEL = { vi: "Đã sao chép!", en: "Copied!", de: "Kopiert!" };
149  var CHIP_SCHEMES = /^(?:chrome|edge|brave|opera|vivaldi):\/\/|^about:/;
150  function enhanceChromeUrlChips() {
151    var lang = curLang();
152    var codes = document.querySelectorAll(".legal code");
153    codes.forEach(function (code) {
154      var url = (code.textContent || "").trim();
155      if (!CHIP_SCHEMES.test(url)) return;                 // chỉ địa chỉ trình duyệt chặn
156      if (code.closest && code.closest(".copy-chip")) return; // <code> bên trong chip → bỏ qua
157      var chip = document.createElement("button");
158      chip.type = "button";
159      chip.className = "copy-chip";
160      chip.setAttribute("data-copy-ready", "1");
161      chip.title = COPY_LABEL[lang] || COPY_LABEL.vi;
162      var codeInner = document.createElement("code");
163      codeInner.textContent = url;
164      var ico = document.createElement("span");
165      ico.className = "copy-ico";
166      ico.setAttribute("aria-hidden", "true");
167      ico.textContent = "⧉";
168      chip.appendChild(codeInner);
169      chip.appendChild(ico);
170      chip.addEventListener("click", function () {
171        copyText(url).then(function (ok) {
172          if (!ok) return;
173          chip.classList.add("copied");
174          var prev = ico.textContent;
175          ico.textContent = "✓";
176          chip.title = COPIED_LABEL[lang] || COPIED_LABEL.vi;
177          setTimeout(function () {
178            chip.classList.remove("copied");
179            ico.textContent = prev;
180            chip.title = COPY_LABEL[lang] || COPY_LABEL.vi;
181          }, 1600);
182        });
183      });
184      code.parentNode.replaceChild(chip, code);
185    });
186  }
187  function copyText(text) {
188    if (navigator.clipboard && navigator.clipboard.writeText) {
189      return navigator.clipboard.writeText(text).then(function () { return true; }, function () { return legacyCopy(text); });
190    }
191    return Promise.resolve(legacyCopy(text));
192  }
193  // Nút sao chép trong .pg-linkbox: lấy link từ phần tử mà data-copy-from trỏ tới,
194  // nên không nhân bản URL vào HTML lần thứ hai (sai một chỗ là lệch nhau ngay).
195  function enhanceLinkboxCopy() {
196    var lang = curLang();
197    document.querySelectorAll(".pg-linkbox__copy[data-copy-from]").forEach(function (btn) {
198      if (btn.getAttribute("data-copy-ready")) return;
199      btn.setAttribute("data-copy-ready", "1");
200      var target = document.getElementById(btn.getAttribute("data-copy-from"));
201      if (!target) { btn.hidden = true; return; }
202      var ico = btn.querySelector("span") || btn;
203      btn.title = COPY_LABEL[lang] || COPY_LABEL.vi;
204      btn.addEventListener("click", function () {
205        copyText(target.getAttribute("href") || target.textContent || "").then(function (ok) {
206          if (!ok) return;
207          btn.classList.add("copied");
208          ico.textContent = "✓";
209          btn.title = COPIED_LABEL[lang] || COPIED_LABEL.vi;
210          setTimeout(function () {
211            btn.classList.remove("copied");
212            ico.textContent = "⧉";
213            btn.title = COPY_LABEL[lang] || COPY_LABEL.vi;
214          }, 1600);
215        });
216      });
217    });
218  }
219  function legacyCopy(text) {
220    try {
221      var ta = document.createElement("textarea");
222      ta.value = text; ta.setAttribute("readonly", "");
223      ta.style.position = "absolute"; ta.style.left = "-9999px";
224      document.body.appendChild(ta); ta.select();
225      var ok = document.execCommand("copy");
226      document.body.removeChild(ta);
227      return ok;
228    } catch (_) { return false; }
229  }
230
231  // Giá Pro động từ admin (/pay-info): ghi đè số niêm yết cứng khi có dữ liệu.
232  function applyProPrice() {
233    if (!_proPrice) return;
234    var v = document.getElementById("land-pro-vnd");
235    var e = document.getElementById("land-pro-eur");
236    if (v && _proPrice.vnd) v.textContent = _proPrice.vnd;
237    if (e && _proPrice.eur) e.textContent = _proPrice.eur;
238    applyPlanCatalog();
239  }
240  function applyPlanCatalog() {
241    var wrap = document.querySelector("#pricing .plans");
242    if (!wrap || !_plans.length) return;
243    var lang = curLang();
244    var perMonth = { vi: "/tháng", en: "/month", de: "/Monat" }[lang] || "/tháng";
245    _plans.forEach(function (plan) {
246      if (!plan || !plan.name) return;
247      var card = wrap.querySelector('[data-plan="' + plan.name + '"]');
248      if (!card) {
249        card = document.createElement("div");
250        card.className = "plan";
251        card.setAttribute("data-plan", plan.name);
252        card.innerHTML = '<h3></h3><p class="plan-description" style="color:var(--ink-soft);font-size:14px"></p><div class="price"><span></span><small></small></div><div class="price-eur"></div><ul></ul><a class="btn btn-primary"></a>';
253        wrap.appendChild(card);
254      }
255      var content = localizedPlan(plan, lang);
256      var title = card.querySelector("h3");
257      var description = card.querySelector(".plan-description");
258      var primary = card.querySelector(".price span");
259      var unit = card.querySelector(".price small");
260      var secondary = card.querySelector(".price-eur");
261      var list = card.querySelector("ul");
262      var action = card.querySelector("a.btn");
263      var price = plan.price || {};
264      if (title) title.textContent = content.display_name;
265      if (description && content.description) description.textContent = content.description;
266      if (primary) primary.textContent = lang === "vi" ? fmtVnd(price.VND || 0) : fmtEur(price.EUR || 0);
267      if (unit) unit.textContent = plan.name === "free" ? ({ vi: "/mãi mãi", en: "/forever", de: "/für immer" }[lang] || "/mãi mãi") : perMonth;
268      if (secondary) secondary.textContent = plan.name === "free" ? "\u00a0" : (lang === "vi" ? "≈ " + fmtEur(price.EUR || 0) + " " + perMonth : "≈ " + fmtVnd(price.VND || 0) + " " + perMonth);
269      if (list && Array.isArray(content.features) && content.features.length) {
270        list.innerHTML = "";
271        content.features.forEach(function (feature) {
272          var li = document.createElement("li");
273          li.textContent = String(feature);
274          list.appendChild(li);
275        });
276      }
277      if (action) {
278        action.href = plan.name === "free" ? "install.html" : "app/?plan=" + encodeURIComponent(plan.name);
279        action.textContent = plan.name === "free"
280          ? ({ vi: "Cài đặt miễn phí", en: "Install free", de: "Kostenlos installieren" }[lang] || "Cài đặt miễn phí")
281          : ({ vi: "Chọn gói", en: "Choose plan", de: "Plan wählen" }[lang] || "Chọn gói");
282      }
283    });
284  }
285  function loadProPrice() {
286    if (!document.getElementById("land-pro-vnd")) return; // chỉ trang có bảng giá
287    fetch(WORKER + "/pay-info", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
288      .then(function (r) { return r.json(); })
289      .then(function (d) {
290        _plans = (d && Array.isArray(d.plans)) ? d.plans : [];
291        var pro = (d && d.price_table && d.price_table.pro) || {};
292        if (!pro.EUR && !pro.VND && !_plans.length) return;
293        var perMonth = { vi: " / tháng", en: " / month", de: " / Monat" }[curLang()] || " / tháng";
294        _proPrice = {
295          vnd: pro.VND ? fmtVnd(pro.VND) : null,
296          eur: pro.EUR ? "≈ " + fmtEur(pro.EUR) + perMonth : null,
297        };
298        applyProPrice();
299        applyPlanCatalog();
300      })
301      .catch(function () {});
302  }
303
304  var DEFAULT_CONTACTS = [
305    { type: "email", label: "", value: "[email protected]" },
306  ];
307
308  function renderContacts(box, contacts) {
309    var seen = {};
310    var list = (Array.isArray(contacts) ? contacts : []).filter(function (ct) {
311      if (!ct || !String(ct.value || "").trim()) return false;
312      var key = String(ct.type || "text") + ":" + String(ct.value).trim().toLowerCase();
313      if (seen[key]) return false;
314      seen[key] = true;
315      return true;
316    });
317    if (!list.length) list = DEFAULT_CONTACTS;
318    box.innerHTML = "";
319    list.forEach(function (ct) {
320      var value = String(ct.value || "").trim();
321      var type = ct.type || (/@/.test(value) ? "email" : "text");
322      var node = document.createElement(type === "email" || type === "phone" ? "a" : "div");
323      if (type === "email") node.href = "mailto:" + value;
324      if (type === "phone") node.href = "tel:" + value.replace(/[^+\d]/g, "");
325      node.textContent = (ct.label ? String(ct.label).trim() + ": " : "") + value;
326      box.appendChild(node);
327    });
328  }
329
330  // Kontakt động ở footer: lấy từ admin (/pay-info contacts). HTML giữ một
331  // địa chỉ dự phòng để footer vẫn dùng được nếu mạng hoặc Worker tạm lỗi.
332  function loadContacts() {
333    var box = document.getElementById("foot-contacts");
334    if (!box) return;
335    fetch(WORKER + "/pay-info", { method: "POST", cache: "no-store", headers: { "Content-Type": "application/json" }, body: "{}" })
336      .then(function (r) {
337        if (!r.ok) throw new Error("contact-config-unavailable");
338        return r.json();
339      })
340      .then(function (d) {
341        var list = (d && Array.isArray(d.contacts)) ? d.contacts : [];
342        renderContacts(box, list);
343      })
344      .catch(function () { renderContacts(box, DEFAULT_CONTACTS); });
345  }
346
347  function applyInstallLinks(release) {
348    release = release || {};
349    _releaseLinks = release;
350    var chromeUrl = String(release.chrome_store_url || "").trim();
351    var manualUrl = String(release.manual_download_url || release.download_url || "").trim();
352    var cws = document.getElementById("install-cws-btn");
353    var manualBtn = document.getElementById("install-manual-btn");
354    var manualLink = document.getElementById("install-manual-link");
355    // Phiên bản mới nhất trong mục "Cập nhật": lấy từ /version (Admin là nguồn
356    // chuẩn) để hướng dẫn không bao giờ ghi cứng một số cũ.
357    var latestVer = document.getElementById("install-latest-version");
358    var stable = String(release.stable || release.version || "").trim();
359    if (latestVer && stable) latestVer.textContent = stable;
360    if (cws) {
361      // Ưu tiên link Chrome Web Store;
361 khi chưa lên CWS nhưng đã có bản cài
362      // (Google Drive/GitHub qua manual_download_url) thì nút vẫn HOẠT ĐỘNG và
363      // bỏ nhãn "(sắp có)" — bản cài đã sẵn sàng nên không còn "bald verfügbar".
364      var liveUrl = chromeUrl || manualUrl;
365      if (liveUrl) {
366        cws.href = liveUrl;
367        cws.target = "_blank";
368        cws.rel = "noopener";
369        cws.removeAttribute("aria-disabled");
370        var dict = (window.I18N && window.I18N[curLang()]) || {};
371        if (dict["install.m1btn_live"]) cws.textContent = dict["install.m1btn_live"];
372      } else {
373        cws.href = "#";
374        cws.setAttribute("aria-disabled", "true");
375      }
376    }
377    if (manualUrl) {
378      if (manualBtn) manualBtn.href = manualUrl;
379      if (manualLink) {
380        manualLink.href = manualUrl;
381        manualLink.textContent = manualUrl;
382      }
383    }
384
385    // ── Nút theo từng trình duyệt ───────────────────────────────────────────
386    // Nguyên tắc: KHÔNG BAO GIỜ hiện link chết — bấm vào link rỗng thì người dùng
387    // tưởng sản phẩm hỏng. Ô Admin trống thì nút nào có đường dự phòng dùng được sẽ
388    // chuyển sang đường đó (Firefox → gói trên Google Drive, Edge → Chrome Web Store);
389    // nút nào không có gì thay thế thì bị ẩn.
390    var firefoxStore = String(release.firefox_store_url || "").trim();
391    var edgeStore = String(release.edge_store_url || "").trim();
392    var ffBtn = document.getElementById("install-firefox-btn");
393    var edgeBtn = document.getElementById("install-edge-btn");
394    var ffSoon = document.getElementById("install-firefox-soon");
395
396    function wire(el, url) {
397      if (!el) return false;
398      if (url) {
399        el.href = url;
400        el.target = "_blank";
401        el.rel = "noopener";
402        el.hidden = false;
403        el.removeAttribute("aria-disabled");
404        return true;
405      }
406      el.href = "#";
407      el.hidden = true;
408      el.setAttribute("aria-disabled", "true");
409      return false;
410    }
411
412    // Firefox: nút LUÔN dùng được, cùng nguyên tắc với nút Chrome ở trên
413    // (chromeUrl || manualUrl). Hai trạng thái, và nhãn phải khớp việc nút thật sự làm:
414    //   • Admin đã dán link addons.mozilla.org → mở trang AMO, nhãn "Thêm vào Firefox".
415    //   • Chưa có  → tải bản Firefox từ Google Drive, nhãn "Tải bản Firefox".
416    // Vì sao KHÔNG ẩn nút khi ô AMO trống (khác Edge): bước 1 trong chính thẻ này viết
417    // "tải bản Firefox Ở TRÊN". Ẩn nút thì chẳng còn gì ở trên để bấm và người dùng bế
418    // tắc, dù đường cài thủ công vẫn chạy tốt. Nút này cũng không đi qua wire() vì
419    // wire() ẩn nút khi không có URL — ở đây HTML đã có href Drive cứng làm lưới an toàn
420    // cho trường hợp /version không gọi được.
421    if (ffBtn) {
422      var ffDict = (window.I18N && window.I18N[curLang()]) || {};
423      var ffUrl = firefoxStore || manualUrl;
424      if (ffUrl) ffBtn.href = ffUrl;
425      ffBtn.target = "_blank";
426      ffBtn.rel = "noopener";
427      ffBtn.hidden = false;
428      ffBtn.removeAttribute("aria-disabled");
429      var ffKey = firefoxStore ? "install.bfirefox" : "install.bfirefox_dl";
430      if (ffDict[ffKey]) ffBtn.textContent = ffDict[ffKey];
431    }
432    // Edge cài được cả từ Chrome Web Store, nên khi Admin chưa điền link Edge riêng
433    // thì nút vẫn dùng được bằng link Chromium thay vì bị ẩn.
434    wire(edgeBtn, edgeStore || chromeUrl || manualUrl);
435    // Dòng "đang chờ duyệt trên addons.mozilla.org" chỉ ẩn khi Admin ĐÃ có link AMO.
436    // Trước đây nó ẩn theo "nút có sống không", mà giờ nút luôn sống nhờ link Google
437    // Drive — bám vào đó thì dòng nhắc biến mất trong khi bản AMO vẫn chưa có.
438    if (ffSoon) ffSoon.hidden = !!firefoxStore;
439    // Dòng "đang chờ duyệt trên Edge Add-ons" chỉ hiện khi CHƯA có link Edge riêng —
440    // lúc đó nút đang dùng link Chromium, nên người dùng cần biết vì sao.
441    var edgeSoon = document.getElementById("install-edge-soon");
442    if (edgeSoon) edgeSoon.hidden = !!edgeStore;
443
444  }
445
446  function loadReleaseLinks() {
447    if (!document.getElementById("install-cws-btn") && !document.getElementById("install-manual-btn")) return;
448    // Chỉ hai trang này dùng chữ ghi đè — trang chủ không cần gọi thêm request.
449    if (/install\.html$|update\.html$/.test(location.pathname)) {
450      fetch(WORKER + "/install-copy", { cache: "no-store" })
451        .then(function (r) { return r.ok ? r.json() : null; })
452        .then(function (d) {
453          if (!d) return;
454          _installCopy = d;
455          applyLang(curLang());   // vẽ lại bằng chữ của Admin
456        })
457        .catch(function () { /* giữ chữ đóng gói */ });
458    }
459
460    fetch(WORKER + "/version", { cache: "no-store" })
461      .then(function (r) {
462        if (!r.ok) throw new Error("release-config-unavailable");
463        return r.json();
464      })
465      .then(function (d) {
466        _installCopy = (d && d.install_copy) || null;
467        applyInstallLinks(d && d.extension);
468        // /version về sau lần applyLang đầu tiên nên phải vẽ lại chữ ở đây.
469        applyInstallCopy(curLang());
470        enhanceChromeUrlChips();
471      })
472      .catch(function () {});
473  }
474
475  document.addEventListener("DOMContentLoaded", function () {
476    var s = storedLang();
477    applyLang(s || "vi");
478    loadProPrice();
479    loadContacts();
480    loadReleaseLinks();
481    setInterval(loadReleaseLinks, 60000);
482    // Chưa có lựa chọn rõ ràng → dùng ngôn ngữ theo IP (quốc gia từ Cloudflare edge).
483    if (!s && !localStorage.getItem("nd_lang_userset")) {
484      fetch(WORKER + "/version").then(function (r) { return r.json(); }).then(function (d) {
485        if (localStorage.getItem("nd_lang_userset")) return;
486        applyLang(pickLangByCountry(d && d.country));
487      }).catch(function () {});
488    }
489
490    var ls = document.querySelector(".lang-switch");
491    if (ls) ls.addEventListener("click", function () {
492      localStorage.setItem("nd_lang_userset", "1"); // người dùng tự chọn → không ghi đè bằng IP nữa
493      var cur = curLang();
494      var next = LANGS[(LANGS.indexOf(cur) + 1) % LANGS.length];
495      applyLang(next);
496    });
497
498    var nt = document.querySelector(".nav-toggle");
499    var nl = document.querySelector(".nav-links");
500    if (nt && nl) {
501      nt.addEventListener("click", function () { nl.classList.toggle("open"); });
502      nl.querySelectorAll("a").forEach(function (a) { a.addEventListener("click", function () { nl.classList.remove("open"); }); });
503    }
504  });
505})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.