Initial commit: Premium Gold Theme with Dynamic QR and Multi-language support

This commit is contained in:
dyzulk
2026-01-12 07:57:04 +07:00
commit 7bd6589564
49 changed files with 2876 additions and 0 deletions

64
js/config.js Normal file
View File

@@ -0,0 +1,64 @@
const brandConfig = {
brandName: "TwinpathNet",
portalUrl: "http://welcome.dyzulk.com/login",
creditName: "dyzulk.com",
creditUrl: "https://dyzulk.com",
assets: {
logo: "img/logo-twinpath.svg",
icon_ticket: "svg/ticket.svg",
icon_user: "svg/user.svg",
icon_lock: "svg/lock.svg",
icon_scan: "svg/scan-line.svg",
icon_image: "svg/image.svg",
icon_external: "svg/external-link.svg",
icon_copy: "svg/copy.svg",
icon_logout: "svg/log-out.svg",
icon_success: "svg/check-circle.svg",
icon_error: "svg/alert-circle.svg",
icon_clock: "svg/clock.svg",
icon_upload: "svg/upload-cloud.svg",
icon_download: "svg/download-cloud.svg",
icon_wifi: "svg/wifi.svg"
}
};
function applyBranding() {
// Update Document Title
const currentTitle = document.title;
if (currentTitle.includes('TwinpathNet')) {
document.title = currentTitle.replace('TwinpathNet', brandConfig.brandName);
} else if (!currentTitle.includes(brandConfig.brandName)) {
document.title = `${brandConfig.brandName} > ${currentTitle}`;
}
// Update Elements with data-brand-name
document.querySelectorAll('[data-brand-name]').forEach(el => {
el.innerText = brandConfig.brandName;
if (el.tagName === 'IMG') el.alt = brandConfig.brandName;
});
// Update Elements with data-brand-credit
document.querySelectorAll('[data-brand-credit]').forEach(el => {
el.innerText = brandConfig.creditName;
});
// Update Links with data-brand-link
document.querySelectorAll('[data-brand-link="credit"]').forEach(el => {
el.href = brandConfig.creditUrl;
});
// Update Assets (Images/Icons)
document.querySelectorAll('[data-asset]').forEach(el => {
const assetKey = el.getAttribute('data-asset');
if (brandConfig.assets[assetKey]) {
el.src = brandConfig.assets[assetKey];
}
});
}
// Apply branding as soon as possible
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyBranding);
} else {
applyBranding();
}

1
js/html5-qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

158
js/languages.js Normal file
View File

@@ -0,0 +1,158 @@
const translations = {
id: {
lang_name: "Bahasa Indonesia",
operational: "Beroperasi",
online: "Terhubung",
tab_voucher: "Voucher",
tab_member: "Member",
voucher_label: "Kode Voucher",
voucher_placeholder: "Masukkan kode voucher...",
user_label: "Nama Pengguna",
pass_label: "Kata Sandi",
login_voucher: "Gunakan Voucher",
login_member: "Masuk Member",
connect_btn: "Hubungkan",
scan_btn: "Scan QR Code",
trial_btn: "Coba Gratis",
status_dashboard_msg: "Kelola sesi aktif dan pantau penggunaan anda.",
connection_stats: "Statistik Koneksi",
quota_left: "Sisa Kuota",
unlimited: "Tanpa Batas",
or_text: "Atau",
scan_file_btn: "Scan dari Galeri",
point_camera: "Arahkan kamera ke QR Code",
confirm_title: "Konfirmasi Voucher",
confirm_msg: "Apakah Anda ingin masuk dengan kode ini?",
open_browser: "Buka di Browser",
copy_url: "Salin Link Portal",
manual_tip: "Kamera/Galeri diblokir sistem? Silakan buka di Chrome/Safari atau salin link portal di bawah.",
hour: "JAM",
hours: "JAM",
day: "HARI",
week: "MINGGU",
pricing_title: "Paket Tersedia",
session_info: "Informasi Sesi",
ip_address: "Alamat IP",
upload: "Unggah",
download: "Unduh",
time_left: "Sisa Waktu",
logout_btn: "Keluar (Log Out)",
logged_out: "Sudah Keluar",
logged_out_msg: "Anda telah berhasil memutuskan koneksi dari jaringan.",
login_again: "Masuk Kembali",
failed_title: "Koneksi Gagal",
try_again: "Coba Lagi",
success_title: "Berhasil",
success_msg: "Anda sekarang terhubung ke jaringan.",
redirect_msg: "Mengalihkan...",
redirect_tip: "Klik di sini jika tidak beralih otomatis",
adv_title: "Iklan",
adv_msg: "Jika tidak terjadi apa-apa, buka iklan secara manual.",
adv_link: "iklan",
adv_manually: "secara manual",
powered_by: "Didukung oleh"
},
en: {
lang_name: "English",
operational: "Operational",
online: "Online",
tab_voucher: "Voucher",
tab_member: "Member",
voucher_label: "Voucher Code",
voucher_placeholder: "Enter code received...",
user_label: "Username",
pass_label: "Password",
login_voucher: "Use Voucher",
login_member: "Member Login",
connect_btn: "Connect",
scan_btn: "Scan QR Code",
trial_btn: "Free Trial Access",
status_dashboard_msg: "Manage your active session and monitor your usage.",
connection_stats: "Connection Stats",
quota_left: "Quota Remaining",
unlimited: "Unlimited",
or_text: "Or",
scan_file_btn: "Scan from Gallery",
point_camera: "Point camera at QR Code",
confirm_title: "Confirm Voucher",
confirm_msg: "Do you want to log in with this code?",
open_browser: "Open in Browser",
copy_url: "Copy Portal Link",
manual_tip: "Camera/Gallery restricted? Please open in Chrome/Safari or copy the portal link below.",
hour: "HOUR",
hours: "HOURS",
day: "DAY",
week: "WEEK",
pricing_title: "Available Packages",
session_info: "Session Information",
ip_address: "IP Address",
upload: "Upload",
download: "Download",
time_left: "Time Remaining",
logout_btn: "Log Out",
logged_out: "Logged Out",
logged_out_msg: "You have successfully disconnected from the network.",
login_again: "Log In Again",
failed_title: "Connection Failed",
try_again: "Try Again",
success_title: "Success",
success_msg: "You are now connected to the network.",
redirect_msg: "Redirecting...",
redirect_tip: "Click here if not redirected automatically",
adv_title: "Advertisement",
adv_msg: "If nothing happens, open advertisement manually.",
adv_link: "advertisement",
adv_manually: "manually",
powered_by: "Powered by"
}
};
function applyLanguage(lang) {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (translations[lang] && translations[lang][key]) {
if (el.tagName === 'INPUT' && el.placeholder) {
el.placeholder = translations[lang][key];
} else {
el.innerText = translations[lang][key];
}
}
});
localStorage.setItem('twinpath_lang', lang);
document.documentElement.lang = lang;
// Update active state in custom menu
document.querySelectorAll('.lang-option').forEach(opt => {
opt.classList.toggle('active', opt.getAttribute('data-lang') === lang);
});
// Update button text
const langLabel = document.getElementById('lang-label');
if (langLabel) langLabel.innerText = lang.toUpperCase();
// Refresh dynamic content if on dashboard
if (typeof initDashboard === 'function') {
initDashboard();
}
}
function toggleLangMenu() {
const menu = document.getElementById('lang-menu');
if (menu) menu.classList.toggle('show');
}
// Close menu when clicking outside
window.addEventListener('click', (e) => {
const dropdown = document.querySelector('.lang-dropdown');
const menu = document.getElementById('lang-menu');
if (menu && dropdown && !dropdown.contains(e.target)) {
menu.classList.remove('show');
}
});
function initLanguage() {
const savedLang = localStorage.getItem('twinpath_lang') || 'en';
applyLanguage(savedLang);
}
document.addEventListener('DOMContentLoaded', initLanguage);

217
js/md5.js Normal file
View File

@@ -0,0 +1,217 @@
/*
* A JavaScript implementation of the RSA Data Security, Inc. MD5 Message
* Digest Algorithm, as defined in RFC 1321.
* Version 1.1 Copyright (C) Paul Johnston 1999 - 2002.
* Code also contributed by Greg Holt
* See http://pajhome.org.uk/site/legal.html for details.
*/
/*
* Add integers, wrapping at 2^32. This uses 16-bit operations internally
* to work around bugs in some JS interpreters.
*/
function safe_add(x, y)
{
var lsw = (x & 0xFFFF) + (y & 0xFFFF)
var msw = (x >> 16) + (y >> 16) + (lsw >> 16)
return (msw << 16) | (lsw & 0xFFFF)
}
/*
* Bitwise rotate a 32-bit number to the left.
*/
function rol(num, cnt)
{
return (num << cnt) | (num >>> (32 - cnt))
}
/*
* These functions implement the four basic operations the algorithm uses.
*/
function cmn(q, a, b, x, s, t)
{
return safe_add(rol(safe_add(safe_add(a, q), safe_add(x, t)), s), b)
}
function ff(a, b, c, d, x, s, t)
{
return cmn((b & c) | ((~b) & d), a, b, x, s, t)
}
function gg(a, b, c, d, x, s, t)
{
return cmn((b & d) | (c & (~d)), a, b, x, s, t)
}
function hh(a, b, c, d, x, s, t)
{
return cmn(b ^ c ^ d, a, b, x, s, t)
}
function ii(a, b, c, d, x, s, t)
{
return cmn(c ^ (b | (~d)), a, b, x, s, t)
}
/*
* Calculate the MD5 of an array of little-endian words, producing an array
* of little-endian words.
*/
function coreMD5(x)
{
var a = 1732584193
var b = -271733879
var c = -1732584194
var d = 271733878
for(i = 0; i < x.length; i += 16)
{
var olda = a
var oldb = b
var oldc = c
var oldd = d
a = ff(a, b, c, d, x[i+ 0], 7 , -680876936)
d = ff(d, a, b, c, x[i+ 1], 12, -389564586)
c = ff(c, d, a, b, x[i+ 2], 17, 606105819)
b = ff(b, c, d, a, x[i+ 3], 22, -1044525330)
a = ff(a, b, c, d, x[i+ 4], 7 , -176418897)
d = ff(d, a, b, c, x[i+ 5], 12, 1200080426)
c = ff(c, d, a, b, x[i+ 6], 17, -1473231341)
b = ff(b, c, d, a, x[i+ 7], 22, -45705983)
a = ff(a, b, c, d, x[i+ 8], 7 , 1770035416)
d = ff(d, a, b, c, x[i+ 9], 12, -1958414417)
c = ff(c, d, a, b, x[i+10], 17, -42063)
b = ff(b, c, d, a, x[i+11], 22, -1990404162)
a = ff(a, b, c, d, x[i+12], 7 , 1804603682)
d = ff(d, a, b, c, x[i+13], 12, -40341101)
c = ff(c, d, a, b, x[i+14], 17, -1502002290)
b = ff(b, c, d, a, x[i+15], 22, 1236535329)
a = gg(a, b, c, d, x[i+ 1], 5 , -165796510)
d = gg(d, a, b, c, x[i+ 6], 9 , -1069501632)
c = gg(c, d, a, b, x[i+11], 14, 643717713)
b = gg(b, c, d, a, x[i+ 0], 20, -373897302)
a = gg(a, b, c, d, x[i+ 5], 5 , -701558691)
d = gg(d, a, b, c, x[i+10], 9 , 38016083)
c = gg(c, d, a, b, x[i+15], 14, -660478335)
b = gg(b, c, d, a, x[i+ 4], 20, -405537848)
a = gg(a, b, c, d, x[i+ 9], 5 , 568446438)
d = gg(d, a, b, c, x[i+14], 9 , -1019803690)
c = gg(c, d, a, b, x[i+ 3], 14, -187363961)
b = gg(b, c, d, a, x[i+ 8], 20, 1163531501)
a = gg(a, b, c, d, x[i+13], 5 , -1444681467)
d = gg(d, a, b, c, x[i+ 2], 9 , -51403784)
c = gg(c, d, a, b, x[i+ 7], 14, 1735328473)
b = gg(b, c, d, a, x[i+12], 20, -1926607734)
a = hh(a, b, c, d, x[i+ 5], 4 , -378558)
d = hh(d, a, b, c, x[i+ 8], 11, -2022574463)
c = hh(c, d, a, b, x[i+11], 16, 1839030562)
b = hh(b, c, d, a, x[i+14], 23, -35309556)
a = hh(a, b, c, d, x[i+ 1], 4 , -1530992060)
d = hh(d, a, b, c, x[i+ 4], 11, 1272893353)
c = hh(c, d, a, b, x[i+ 7], 16, -155497632)
b = hh(b, c, d, a, x[i+10], 23, -1094730640)
a = hh(a, b, c, d, x[i+13], 4 , 681279174)
d = hh(d, a, b, c, x[i+ 0], 11, -358537222)
c = hh(c, d, a, b, x[i+ 3], 16, -722521979)
b = hh(b, c, d, a, x[i+ 6], 23, 76029189)
a = hh(a, b, c, d, x[i+ 9], 4 , -640364487)
d = hh(d, a, b, c, x[i+12], 11, -421815835)
c = hh(c, d, a, b, x[i+15], 16, 530742520)
b = hh(b, c, d, a, x[i+ 2], 23, -995338651)
a = ii(a, b, c, d, x[i+ 0], 6 , -198630844)
d = ii(d, a, b, c, x[i+ 7], 10, 1126891415)
c = ii(c, d, a, b, x[i+14], 15, -1416354905)
b = ii(b, c, d, a, x[i+ 5], 21, -57434055)
a = ii(a, b, c, d, x[i+12], 6 , 1700485571)
d = ii(d, a, b, c, x[i+ 3], 10, -1894986606)
c = ii(c, d, a, b, x[i+10], 15, -1051523)
b = ii(b, c, d, a, x[i+ 1], 21, -2054922799)
a = ii(a, b, c, d, x[i+ 8], 6 , 1873313359)
d = ii(d, a, b, c, x[i+15], 10, -30611744)
c = ii(c, d, a, b, x[i+ 6], 15, -1560198380)
b = ii(b, c, d, a, x[i+13], 21, 1309151649)
a = ii(a, b, c, d, x[i+ 4], 6 , -145523070)
d = ii(d, a, b, c, x[i+11], 10, -1120210379)
c = ii(c, d, a, b, x[i+ 2], 15, 718787259)
b = ii(b, c, d, a, x[i+ 9], 21, -343485551)
a = safe_add(a, olda)
b = safe_add(b, oldb)
c = safe_add(c, oldc)
d = safe_add(d, oldd)
}
return [a, b, c, d]
}
/*
* Convert an array of little-endian words to a hex string.
*/
function binl2hex(binarray)
{
var hex_tab = "0123456789abcdef"
var str = ""
for(var i = 0; i < binarray.length * 4; i++)
{
str += hex_tab.charAt((binarray[i>>2] >> ((i%4)*8+4)) & 0xF) +
hex_tab.charAt((binarray[i>>2] >> ((i%4)*8)) & 0xF)
}
return str
}
/*
* Convert an array of little-endian words to a base64 encoded string.
*/
function binl2b64(binarray)
{
var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
var str = ""
for(var i = 0; i < binarray.length * 32; i += 6)
{
str += tab.charAt(((binarray[i>>5] << (i%32)) & 0x3F) |
((binarray[i>>5+1] >> (32-i%32)) & 0x3F))
}
return str
}
/*
* Convert an 8-bit character string to a sequence of 16-word blocks, stored
* as an array, and append appropriate padding for MD4/5 calculation.
* If any of the characters are >255, the high byte is silently ignored.
*/
function str2binl(str)
{
var nblk = ((str.length + 8) >> 6) + 1 // number of 16-word blocks
var blks = new Array(nblk * 16)
for(var i = 0; i < nblk * 16; i++) blks[i] = 0
for(var i = 0; i < str.length; i++)
blks[i>>2] |= (str.charCodeAt(i) & 0xFF) << ((i%4) * 8)
blks[i>>2] |= 0x80 << ((i%4) * 8)
blks[nblk*16-2] = str.length * 8
return blks
}
/*
* Convert a wide-character string to a sequence of 16-word blocks, stored as
* an array, and append appropriate padding for MD4/5 calculation.
*/
function strw2binl(str)
{
var nblk = ((str.length + 4) >> 5) + 1 // number of 16-word blocks
var blks = new Array(nblk * 16)
for(var i = 0; i < nblk * 16; i++) blks[i] = 0
for(var i = 0; i < str.length; i++)
blks[i>>1] |= str.charCodeAt(i) << ((i%2) * 16)
blks[i>>1] |= 0x80 << ((i%2) * 16)
blks[nblk*16-2] = str.length * 16
return blks
}
/*
* External interface
*/
function hexMD5 (str) { return binl2hex(coreMD5( str2binl(str))) }
function hexMD5w(str) { return binl2hex(coreMD5(strw2binl(str))) }
function b64MD5 (str) { return binl2b64(coreMD5( str2binl(str))) }
function b64MD5w(str) { return binl2b64(coreMD5(strw2binl(str))) }
/* Backward compatibility */
function calcMD5(str) { return binl2hex(coreMD5( str2binl(str))) }

212
js/qr-scanner.js Normal file
View File

@@ -0,0 +1,212 @@
let html5QrCode;
let scannedUrl = "";
function safePause() {
try {
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.pause();
}
} catch (e) {
console.warn("SafePause: Scanner already paused or not scanning", e);
}
}
function safeResume() {
try {
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.resume();
}
} catch (e) {
console.warn("SafeResume: Scanner not paused or not scanning", e);
}
}
function handleDecodedText(decodedText) {
console.log(`Scan result: ${decodedText}`);
let username = decodedText;
let password = "";
scannedUrl = "";
// Check if result is a URL (common for Mikhmon vouchers)
try {
if (decodedText.startsWith('http://') || decodedText.startsWith('https://')) {
scannedUrl = decodedText; // Store for redirection
const url = new URL(decodedText);
const searchParams = url.search || (decodedText.includes('?') ? '?' + decodedText.split('?')[1] : '');
const params = new URLSearchParams(searchParams);
if (params.has('username')) {
username = params.get('username');
}
if (params.has('password')) {
password = params.get('password');
}
}
} catch (e) {
console.error("Error parsing QR URL:", e);
}
// Fill inputs
const voucherInput = document.getElementById('voucher-input');
const passField = document.getElementById('voucher-pass');
if (voucherInput) voucherInput.value = username;
if (passField) passField.value = password || username;
// Show confirmation overlay
const overlay = document.getElementById('qr-confirm-overlay');
const confirmUser = document.getElementById('confirm-user');
if (overlay && confirmUser) {
confirmUser.innerText = username;
overlay.classList.remove('hidden');
}
// Pause camera scanning while confirming
safePause();
}
function cancelConfirm() {
const overlay = document.getElementById('qr-confirm-overlay');
if (overlay) overlay.classList.add('hidden');
safeResume();
}
function proceedSubmit() {
// If it's a URL, redirect directly
if (scannedUrl) {
console.log("Redirecting to scanned URL:", scannedUrl);
window.location.href = scannedUrl;
return;
}
// Switch to voucher mode for manual codes
setMode('voucher');
// Sync values to the actual form fields
const voucherInput = document.getElementById('voucher-input');
const voucherPass = document.getElementById('voucher-pass');
const form = document.login;
if (form && voucherInput) {
form.username.value = voucherInput.value;
form.password.value = voucherPass ? voucherPass.value : voucherInput.value;
}
// Close scanner
closeQR();
// Submit
const submitBtn = document.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.click();
}
}
function scanFromFile(event) {
try {
const file = event.target.files[0];
if (!file) {
console.log("No file selected.");
return;
}
console.log("File selected:", file.name, file.type, file.size);
// Ensure library is available
if (typeof Html5Qrcode === 'undefined') {
alert("QR Scanner library not loaded. Please wait or refresh.");
return;
}
// Pause camera gracefully
safePause();
// Show a loading state
const confirmUser = document.getElementById('confirm-user');
if (confirmUser) confirmUser.innerText = "Scanning file...";
// Hide overlay if it was open
const overlay = document.getElementById('qr-confirm-overlay');
if (overlay) overlay.classList.add('hidden');
// Reuse instance if possible, or create temporary one for file
const fileScanner = new Html5Qrcode("qr-file-reader");
fileScanner.scanFile(file, true)
.then(decodedText => {
console.log("Success scanning file:", decodedText);
handleDecodedText(decodedText);
fileScanner.clear(); // Cleanup
})
.catch(err => {
console.error(`Error scanning file: ${err}`);
let msg = "No QR Code found.";
if (typeof err === "string" && err.includes("not found")) {
msg = "QR Code not detected. Try a clearer or closer photo.";
}
alert(msg);
if (confirmUser) confirmUser.innerText = "";
// Resume camera
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.resume();
}
fileScanner.clear(); // Cleanup
});
// Reset input so searching for the same file again triggers change
event.target.value = "";
} catch (e) {
console.error("Fatal error in scanFromFile:", e);
const errorMsg = e.message || JSON.stringify(e) || e;
alert("An error occurred while opening the file: " + errorMsg);
// Try to resume camera if it crashed here
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.resume();
}
}
}
function openQR() {
const modal = document.getElementById('qr-scanner-modal');
modal.style.display = 'flex';
if (!html5QrCode) {
html5QrCode = new Html5Qrcode("reader");
}
const config = { fps: 10, qrbox: { width: 250, height: 250 } };
html5QrCode.start(
{ facingMode: "environment" },
config,
handleDecodedText
).catch(err => {
console.error("Scanner start error:", err);
});
}
function closeQR() {
const modal = document.getElementById('qr-scanner-modal');
const overlay = document.getElementById('qr-confirm-overlay');
if (modal) modal.style.display = 'none';
if (overlay) overlay.classList.add('hidden');
if (html5QrCode) {
html5QrCode.stop().then(() => {
console.log("Scanner stopped");
}).catch((err) => {
// Ignore error if already stopped
});
}
}

255
js/script.js Normal file
View File

@@ -0,0 +1,255 @@
function setMode(mode) {
const voucherMode = document.getElementById('voucher-mode');
const memberMode = document.getElementById('member-mode');
const voucherTab = document.querySelector('.tab-btn:nth-child(1)');
const memberTab = document.querySelector('.tab-btn:nth-child(2)');
const form = document.login;
if (mode === 'voucher') {
voucherMode.classList.remove('hidden');
memberMode.classList.add('hidden');
voucherTab.classList.add('active');
memberTab.classList.remove('active');
// Sync to form immediately
if (form) {
const code = document.getElementById('voucher-input').value;
form.username.value = code;
form.password.value = document.getElementById('voucher-pass').value || code;
}
} else {
voucherMode.classList.add('hidden');
memberMode.classList.remove('hidden');
voucherTab.classList.remove('active');
memberTab.classList.add('active');
// Sync to form immediately
if (form) {
form.username.value = document.getElementById('member-user').value;
form.password.value = document.getElementById('member-pass').value;
}
}
// Update login button text based on mode
const loginBtn = document.getElementById('login-btn');
if (loginBtn) {
const lang = localStorage.getItem('twinpath_lang') || 'en';
const key = mode === 'voucher' ? 'login_voucher' : 'login_member';
loginBtn.setAttribute('data-i18n', key);
if (typeof translations !== 'undefined' && translations[lang] && translations[lang][key]) {
loginBtn.innerText = translations[lang][key];
}
}
}
function doLogin() {
const form = document.login;
const mode = document.querySelector('.tab-btn.active').innerText.toLowerCase();
// Sync inputs based on mode
if (mode === 'voucher') {
const code = document.getElementById('voucher-input').value;
form.username.value = code;
form.password.value = code; // Voucher usually uses same code for user/pass or just user with empty pass (depends on config)
// Note: Check your hotspot config. Often Vouchers are "Username = Password"
} else {
form.username.value = document.getElementById('member-user').value;
form.password.value = document.getElementById('member-pass').value;
}
// Handle CHAP security if available
// Note: This relies on variables injected by TwinpathNet (Mikrotik) into the HTML/JS context
// We assume 'hexMD5' is available from md5.js
/*
TwinpathNet usually puts this logic in the <script> block directly in login.html
to access $(chap-id) and $(chap-challenge).
Since we extracted this to an external file, we need to ensure those vars are accessible.
However, usually $(variables) are NOT replaced in .js files by TwinpathNet.
They are ONLY replaced in .html files.
So, strict MD5 hashing MUST be done inside login.html <script> block, OR
we submit plain text and let RouterOS handle PAP (if enabled).
For this "Cool" template, we will assume PAP is enabled for simplicity,
OR we can trust the form to submit properly.
*/
return true;
}
function copyToClipboard(text) {
const el = document.createElement('textarea');
el.value = text;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
alert("URL Berhasil Disalin! Silakan buka Chrome dan tempel (paste) di sana.");
}
function openInExternalBrowser() {
const url = brandConfig.portalUrl;
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
if (/android/i.test(userAgent)) {
// Android Intent for Chrome
const domain = new URL(brandConfig.portalUrl).hostname;
window.location.href = `intent://${domain}#Intent;scheme=http;package=com.android.chrome;end`;
} else if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
// iOS: No magic intent from CNA usually works, so we guide them to use the copy button
alert("Untuk pengguna iPhone/iPad: Silakan klik tombol 'Salin Link Portal' lalu buka Safari dan tempel alamatnya.");
} else {
// Other devices
window.open(url, '_blank');
}
}
function formatMikrotikTime(timeStr) {
if (!timeStr || timeStr === 'unlimited' || timeStr.includes('$(')) return timeStr;
const regex = /(\d+)([wdhms])/g;
let parts = [];
let match;
const unitMap = {
'w': { 'en': 'w', 'id': ' Minggu' },
'd': { 'en': 'd', 'id': ' Hari' },
'h': { 'en': 'h', 'id': ' Jam' },
'm': { 'en': 'm', 'id': ' Menit' },
's': { 'en': 's', 'id': ' Detik' }
};
const lang = localStorage.getItem('twinpath_lang') || 'en';
while ((match = regex.exec(timeStr)) !== null) {
const val = match[1];
const unit = match[2];
parts.push(val + (unitMap[unit][lang] || unit));
}
if (parts.length === 0) return timeStr;
return parts.slice(0, 3).join(' ');
}
function parseMikrotikTimeToSeconds(timeStr) {
if (!timeStr || timeStr.includes('$(')) return 0;
const regex = /(\d+)([wdhms])/g;
let totalSeconds = 0;
let match;
const multipliers = { 'w': 604800, 'd': 86400, 'h': 3600, 'm': 60, 's': 1 };
while ((match = regex.exec(timeStr)) !== null) {
totalSeconds += parseInt(match[1]) * (multipliers[match[2]] || 0);
}
return totalSeconds;
}
function updateProgressBars() {
const data = document.getElementById('dashboard-data');
if (!data) return;
const limitUptime = parseMikrotikTimeToSeconds(data.getAttribute('data-limit-uptime'));
const uptime = parseMikrotikTimeToSeconds(data.getAttribute('data-uptime'));
const limitBytes = parseInt(data.getAttribute('data-limit-bytes')) || 0;
const bytesOut = parseInt(data.getAttribute('data-bytes-out')) || 0;
// Time Progress: (Total - Used) / Total -> Remaining %
if (limitUptime > 0) {
const remainingTime = Math.max(0, limitUptime - uptime);
const timePercent = (remainingTime / limitUptime) * 100;
const timeBar = document.querySelector('.progress-time');
if (timeBar) timeBar.style.width = timePercent + '%';
} else {
const timeBar = document.querySelector('.progress-time');
if (timeBar) timeBar.style.width = '100%'; // Unlimited
}
// Quota Progress: (Total - Used) / Total -> Remaining %
if (limitBytes > 0) {
const remainingBytes = Math.max(0, limitBytes - bytesOut);
const quotaPercent = (remainingBytes / limitBytes) * 100;
const quotaBar = document.querySelector('.progress-quota');
if (quotaBar) quotaBar.style.width = quotaPercent + '%';
} else {
const quotaBar = document.querySelector('.progress-quota');
if (quotaBar) quotaBar.style.width = '100%'; // Unlimited
}
}
function initDashboard() {
// 1. Set Greetings
const greetingEl = document.getElementById('greeting-text');
if (greetingEl) {
const hour = new Date().getHours();
const lang = localStorage.getItem('twinpath_lang') || 'en';
let greeting = "";
if (lang === 'id') {
if (hour < 11) greeting = "Selamat Pagi";
else if (hour < 15) greeting = "Selamat Siang";
else if (hour < 19) greeting = "Selamat Sore";
else greeting = "Selamat Malam";
} else {
if (hour < 12) greeting = "Good Morning";
else if (hour < 17) greeting = "Good Afternoon";
else greeting = "Good Evening";
}
greetingEl.innerText = greeting + "!";
}
// 2. Format Time Components
document.querySelectorAll('[data-type="mikrotik-time"]').forEach(el => {
let val = el.innerText.trim();
// If empty or Mikrotik failed to replace (still contains $), it's Unlimited
if (!val || val.includes('$(')) {
const lang = localStorage.getItem('twinpath_lang') || 'en';
el.innerText = (translations[lang] && translations[lang]['unlimited']) || 'Unlimited';
} else {
el.innerText = formatMikrotikTime(val);
}
});
// 3. Dynamic Progress Bars
updateProgressBars();
}
// Update DOMContentLoaded to include initDashboard
document.addEventListener('DOMContentLoaded', () => {
// Determine page type
const isLogin = !!document.getElementById('voucher-input');
const isStatus = !!document.getElementById('greeting-text');
if (isLogin) {
setMode('voucher');
}
initDashboard();
// Escape modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const modal = document.getElementById('qr-scanner-modal');
if (modal && modal.style.display === 'flex') {
if (typeof closeQR === 'function') {
closeQR();
}
}
}
});
});
function togglePassword(inputId, button) {
const passwordInput = document.getElementById(inputId);
const toggleIcon = button.querySelector('img');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleIcon.src = 'svg/eye-off.svg';
} else {
passwordInput.type = 'password';
toggleIcon.src = 'svg/eye.svg';
}
}