10 Commits

Author SHA1 Message Date
dyzulk
d09cc773e8 chore: update mobile screenshots 2026-01-12 13:05:03 +07:00
dyzulk
c083143612 fix(ui): implement sticky footer and update docs 2026-01-12 12:57:48 +07:00
dyzulk
877f8b44f3 docs: add screenshots assets and update config structure 2026-01-12 12:52:48 +07:00
dyzulk
d4bbed9137 docs: add allowedDomains config snippet 2026-01-12 12:48:08 +07:00
dyzulk
b6a1ea65a6 docs: add screenshots gallery and walled garden config 2026-01-12 12:46:50 +07:00
dyzulk
a8349e1f4f docs: update readme with vercel style badges 2026-01-12 12:24:51 +07:00
dyzulk
8f62c68a69 chore: release v1.1.0 2026-01-12 12:10:25 +07:00
dyzulk
98a6f4304c feat: isolate voucher check ui and qr scanner modes 2026-01-12 10:47:22 +07:00
dyzulk
06205ee56b Fix: Implement strict Unlimited display logic in status page 2026-01-12 10:28:33 +07:00
dyzulk
77ec4f68c4 Update: Call dedicated API endpoint /api/check.php for voucher checking 2026-01-12 10:10:33 +07:00
17 changed files with 463 additions and 178 deletions

160
README.md
View File

@@ -1,40 +1,126 @@
# Twinpath Hotspot Themes 🎨📶
<div align="center">
<img src="img/logo-twinpath.svg" alt="TwinpathNet Logo" width="120" height="auto" />
<br/>
<h1>TwinpathNet Hotspot Theme</h1>
<p>A high-performance, Vercel-inspired Mikrotik Hotspot template designed for modern ISPs.</p>
Professional, modern, and multi-themed captive portal templates for MikroTik Hotspot.
## ✨ Features
- **Dynamic Branching**: Every branch in this repository represents a different theme/variant.
- **Premium Aesthetics**: Dark modes, vibrant accents, and modern typography.
- **Multi-language Support**: Built-in Indonesian (ID) and English (EN) localization.
- **QR Code Connectivity**: Integrated QR scanner for quick login from vouchers.
## 🚀 How to Use
### 1. Choose Your Theme
Check out the available branches to find the theme that suits your brand:
- `theme/modern-dark` (Current active theme: Vercel-style dark with teal accents)
### 2. Implementation
It is highly recommended to use a specific **Tag** for a stable release:
1. Clone using a stable tag (e.g., `v1.0-dark`):
```bash
git clone --branch v1.0-dark https://github.com/dyzulk/twinpath-hotspot-themes.git
```
2. Or, if you want the latest (possibly experimental) version of a branch:
```bash
git clone -b theme/modern-dark https://github.com/dyzulk/twinpath-hotspot-themes.git
```
3. Upload the contents of the folder to your MikroTik **`flash/hotspot`** directory.
## 📁 Repository Structure
Each branch is self-contained. To switch themes in your local development:
```bash
git checkout theme/[branch-name]
```
## 🛠️ Customization
Edit `js/config.js` to change branding, URLs, and basic settings without touching the core HTML/CSS.
<p>
<a href="https://github.com/dyzulk/twinpath-hotspot-themes/releases">
<img src="https://img.shields.io/github/v/release/dyzulk/twinpath-hotspot-themes?style=for-the-badge&logo=github&color=000000&labelColor=000000" alt="Release" />
</a>
<a href="#">
<img src="https://img.shields.io/badge/Mikrotik-RouterOS%20v6-000000?style=for-the-badge&logo=mikrotik&logoColor=white&labelColor=000000" alt="Mikrotik" />
</a>
<a href="#">
<img src="https://img.shields.io/badge/Design-Vercel%20Style-000000?style=for-the-badge&logo=vercel&logoColor=white&labelColor=000000" alt="Design System" />
</a>
<a href="LICENSE">
<img src="https://img.shields.io/badge/License-MIT-000000?style=for-the-badge&labelColor=000000" alt="License" />
</a>
</p>
</div>
---
Developed with ❤️ by [dyzulk](https://github.com/dyzulk)
## ✨ Features
### 🎨 **Vercel-Inspired UI**
Built with a sleek, dark-mode-first prioritization. Minimalist aesthetics that focus on content and typography, providing a premium feel for end-users.
### 🛡️ **Advanced Security**
- **API Hardening**: Automatically hides details of unused vouchers (0 uptime/bytes) to prevent enumeration attacks.
- **Form Isolation**: 'Info Check' logic is physically separated from the main login form, preventing accidental logins.
- **Input Guard**: Native `Enter` key handling preventing unwanted form submissions.
### ⚡ **Smart QR Scanner**
- **Context-Aware**: Intelligently switches between "Login Mode" and "Check Status Mode".
- **Fast Path**: Instantly processes scanned codes without unnecessary confirmation dialogs when checking status.
- **Auto Cleanup**: Modals automatically close when switching tabs or pressing `Escape`.
### 🌐 **Multi-Language Support**
- **Native Localization**: Built-in JSON-based translation engine (ID/EN).
- **Auto-Detect**: Remembers user preference via LocalStorage.
---
## 🚀 Installation
1. **Download Release**
Grab the latest `.zip` from the [Releases Page](https://github.com/dyzulk/twinpath-hotspot-themes/releases).
2. **Drag & Drop**
Extract the folder and upload it to your Mikrotik's `Files` menu via Winbox or FTP.
3. **Configure Profile**
Go to `IP > Hotspot > Server Profiles`, select your profile, and point `HTML Directory` to the uploaded folder.
---
## 🛠️ Configuration
### 1. **Edit Config File**
Open `js/config.js` to customize your brand:
```javascript
const brandConfig = {
brandName: "TwinpathNet",
portalUrl: "http://h.twinpath.net", // Your Hotspot DNS Name
mikhmonUrl: "https://mikhmon.dyzulk.com", // Your Mikhmon API URL
mikhmonSession: "Twinpath-Net", // Your Mikhmon Session Name
allowedDomains: [ // Whitelist Domains for QR Scanner
"h.twinpath.net",
"welcome.wifi.id",
"yourportal.net"
]
};
```
### 2. **Walled Garden (Important!)**
To ensure the **Voucher Check** feature works *before* users log in, you must allow access to your Mikhmon API domain in Mikrotik.
Copy and paste this script into your Mikrotik Terminal:
```mikrotik
/ip hotspot walled-garden
add dst-host=mikhmon.dyzulk.com comment="Allow Mikhmon API for Voucher Check"
add dst-host=unpkg.com comment="Allow External Icons/Fonts (If used)"
```
*(Replace `mikhmon.dyzulk.com` with your actual Mikhmon domain)*
---
## 📸 Gallery
### **Desktop Experience**
| **Login Screen** | **Voucher Check** |
|:---:|:---:|
| <img src="img/ss-desktop-01-login-voucher.png" width="100%" alt="Desktop Login"> | <img src="img/ss-desktop-03-login-ceck-validity.png" width="100%" alt="Desktop Check"> |
| *Clean, focused login interface with Voucher & Member tabs.* | *Dedicated modal to verify voucher status/validity without logging in.* |
| **Status Dashboard** |
|:---:|
| <img src="img/ss-desktop-04-status.png" width="100%" alt="Desktop Status"> |
| *Rich post-login dashboard displaying real-time quota, validity, and greeting.* |
### **Mobile Experience**
<div align="center">
<img src="img/ss-mobile-01-login-voucher.png" width="30%" alt="Mobile Login" />
<img src="img/ss-mobile-03-login-check-validity.png" width="30%" alt="Mobile Check" />
<img src="img/ss-mobile-04-status.png" width="30%" alt="Mobile Status" />
</div>
<div align="center">
<small><b>Login</b> (Left) • <b>Voucher Check</b> (Center) • <b>Status Dashboard</b> (Right)</small>
</div>
---
<div align="center">
<p>Crafted with ❤️ by <a href="https://github.com/dyzulk"><b>@dyzulk</b></a></p>
<p>
<small>Powered by Mikrotik & Vanilla JS</small>
</p>
</div>

View File

@@ -136,6 +136,11 @@ a:hover {
flex-direction: column;
}
.content-wrapper {
flex: 1; /* Pushes footer down */
width: 100%;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
@@ -449,7 +454,7 @@ textarea:-webkit-autofill:focus {
/* Footer */
footer {
text-align: center;
margin-top: 6rem; /* Even more separation from buttons */
margin-top: auto; /* Sticky bottom logic */
padding-top: 2rem;
padding-bottom: 2rem;
color: var(--fg-tertiary);
@@ -584,7 +589,7 @@ footer {
transform: translateY(-50%);
width: 16px;
height: 16px;
opacity: 0.5;
opacity: 0.9;
pointer-events: none;
}
@@ -664,10 +669,30 @@ footer {
z-index: 10;
}
#qr-confirm-overlay.hidden {
#qr-confirm-overlay.hidden, .modal.hidden {
display: none;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.85);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
z-index: 2000;
}
.modal-content {
width: 100%;
max-width: 320px;
}
.confirm-card {
background: var(--bg-secondary);
border: 1px solid var(--border);

27
deploy.ps1 Normal file
View File

@@ -0,0 +1,27 @@
# Deploy Script for Twinpath Hotspot
# This script copies production files to a 'dist' folder for easy MikroTik upload.
$Source = Get-Location
$Dest = Join-Path (Split-Path $Source -Parent) "dist\hotspot"
# Files/Folders to exclude from production
$ExcludeFiles = @("README.md", "LICENSE", "deploy.ps1", ".gitignore")
$ExcludeDirs = @(".git")
Write-Host "🚀 Preparing production folder: $Dest" -ForegroundColor Cyan
# Create destination if it doesn't exist
if (!(Test-Path $Dest)) {
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
}
# Use Robocopy for efficient syncing (it's faster and handles exclusions well)
# /MIR: Mirror directory tree
# /XD: Exclude Directories
# /XF: Exclude Files
# /NFL: No File List (cleaner output)
# /NDL: No Directory List (cleaner output)
robocopy $Source $Dest /MIR /XD $ExcludeDirs /XF $ExcludeFiles /R:3 /W:5 /NFL /NDL /NP
$TargetName = Split-Path $Dest -Leaf
Write-Host "✅ Deployment ready! You can now drag & drop 'dist\$TargetName' to MikroTik." -ForegroundColor Green

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
img/ss-mobile-04-status.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -1,13 +1,13 @@
const brandConfig = {
brandName: "TwinpathNet",
portalUrl: "http://welcome.dyzulk.com/login",
allowedDomains: [
"welcome.dyzulk.com"
],
creditName: "dyzulk.com",
creditUrl: "https://dyzulk.com",
mikhmonUrl: "https://mikhmon.dyzulk.com",
mikhmonSession: "Twinpath-Net",
allowedDomains: [
"welcome.dyzulk.com"
],
assets: {
logo: "img/logo-twinpath.svg",
icon_ticket: "svg/ticket.svg",

View File

@@ -62,7 +62,8 @@ const translations = {
check_not_found: "Voucher tidak ditemukan atau belum aktif.",
check_expired: "Voucher sudah kadaluarsa.",
check_valid_until: "Aktif sampai",
check_quota_remaining: "Sisa Kuota"
check_quota_remaining: "Sisa Kuota",
status_label: "STATUS"
},
en: {
lang_name: "English",
@@ -127,7 +128,8 @@ const translations = {
check_not_found: "Voucher not found or not active.",
check_expired: "Voucher has expired.",
check_valid_until: "Valid until",
check_quota_remaining: "Quota Remaining"
check_quota_remaining: "Quota Remaining",
status_label: "STATUS"
}
};

View File

@@ -24,13 +24,33 @@ function safeResume() {
function handleDecodedText(decodedText) {
console.log(`Scan result: ${decodedText}`);
// 1. Fast Path for CHECK MODE: Allow plain text and skip confirmation
if (activeScannerMode === 'check' || activeScannerMode === 'info') {
let code = decodedText;
try {
if (decodedText.startsWith('http://') || decodedText.startsWith('https://')) {
const url = new URL(decodedText);
code = url.searchParams.get('username') || decodedText;
}
} catch (e) {
code = decodedText;
}
if (code) {
console.log("Check/Info mode: Direct completion for", code);
closeQR();
checkVoucher(code); // Trigger the API check modal
return;
}
}
let username = "";
let password = "";
let isUnauthorized = false;
let blockReason = ""; // Track why it was blocked
scannedUrl = "";
// Check if result is a URL
// Check if result is a URL (STRICT for Login Mode)
try {
if (decodedText.startsWith('http://') || decodedText.startsWith('https://')) {
const url = new URL(decodedText);
@@ -75,7 +95,7 @@ function handleDecodedText(decodedText) {
blockReason = getTranslation('qr_err_parse');
}
// Fill inputs (only if authorized)
// Fill inputs (only if authorized - Login Mode)
if (!isUnauthorized && username) {
const voucherInput = document.getElementById('voucher-input');
const passField = document.getElementById('voucher-pass');
@@ -83,15 +103,11 @@ function handleDecodedText(decodedText) {
if (passField) passField.value = password || username;
}
// Show confirmation overlay
// Show confirmation overlay (Login Mode only)
const overlay = document.getElementById('qr-confirm-overlay');
const confirmUser = document.getElementById('confirm-user');
const connectBtn = document.querySelector('button[onclick="proceedSubmit()"]');
const confirmMsg = document.querySelector('[data-i18n="confirm_msg"]');
// Determine mode from active tab
const activeTab = document.querySelector('.tab-btn.active');
const isInfoMode = activeTab && activeTab.onclick.toString().includes('info');
if (overlay && confirmUser) {
if (isUnauthorized) {
@@ -101,13 +117,8 @@ function handleDecodedText(decodedText) {
confirmUser.innerText = username;
if (connectBtn) {
connectBtn.style.display = 'block';
if (isInfoMode) {
connectBtn.innerText = getTranslation('check_btn');
if (confirmMsg) confirmMsg.innerText = getTranslation('info_label');
} else {
connectBtn.innerText = getTranslation('connect_btn');
if (confirmMsg) confirmMsg.innerText = getTranslation('confirm_msg');
}
connectBtn.innerText = getTranslation('connect_btn');
if (confirmMsg) confirmMsg.innerText = getTranslation('confirm_msg');
}
}
overlay.classList.remove('hidden');
@@ -237,7 +248,15 @@ function scanFromFile(event) {
function openQR() {
let activeScannerMode = 'login'; // 'login' or 'check'
async function openQR(mode = 'login') {
activeScannerMode = mode;
console.log(`Opening QR Scanner in ${mode} mode`);
// Hide confirmation overlay when opening scanner to prevent legacy results from showing
document.getElementById('qr-confirm-overlay').classList.add('hidden');
const modal = document.getElementById('qr-scanner-modal');
modal.style.display = 'flex';

View File

@@ -5,10 +5,11 @@ function setMode(mode) {
const tabs = document.querySelectorAll('.tab-btn');
const loginBtn = document.getElementById('login-btn');
const checkBtn = document.getElementById('check-btn');
const trialContainer = document.getElementById('trial-container');
const form = document.login;
// Reset visibility
[voucherMode, memberMode, infoMode, loginBtn, checkBtn].forEach(el => {
[voucherMode, memberMode, infoMode, loginBtn, checkBtn, trialContainer].forEach(el => {
if (el) el.classList.add('hidden');
});
tabs.forEach(t => t.classList.remove('active'));
@@ -16,6 +17,7 @@ function setMode(mode) {
if (mode === 'voucher') {
if (voucherMode) voucherMode.classList.remove('hidden');
if (loginBtn) loginBtn.classList.remove('hidden');
if (trialContainer) trialContainer.classList.remove('hidden');
tabs[0].classList.add('active');
if (form) {
const code = document.getElementById('voucher-input').value;
@@ -25,6 +27,7 @@ function setMode(mode) {
} else if (mode === 'member') {
if (memberMode) memberMode.classList.remove('hidden');
if (loginBtn) loginBtn.classList.remove('hidden');
if (trialContainer) trialContainer.classList.remove('hidden');
tabs[1].classList.add('active');
if (form) {
form.username.value = document.getElementById('member-user').value;
@@ -33,6 +36,7 @@ function setMode(mode) {
} else if (mode === 'info') {
if (infoMode) infoMode.classList.remove('hidden');
if (checkBtn) checkBtn.classList.remove('hidden');
// trialContainer remains hidden in info mode
tabs[2].classList.add('active');
}
@@ -45,6 +49,20 @@ function setMode(mode) {
loginBtn.innerText = translations[lang][key];
}
}
// Close any open modals when switching tabs
if (typeof closeQR === 'function') closeQR();
if (typeof closeVoucherInfo === 'function') closeVoucherInfo();
}
function getActiveMode() {
const tabs = document.querySelectorAll('.tab-btn');
if (tabs.length >= 3) {
if (tabs[0].classList.contains('active')) return 'voucher';
if (tabs[1].classList.contains('active')) return 'member';
if (tabs[2].classList.contains('active')) return 'info';
}
return 'voucher';
}
function doLogin() {
@@ -110,7 +128,10 @@ function openInExternalBrowser() {
}
function formatMikrotikTime(timeStr) {
if (!timeStr || timeStr === 'unlimited' || timeStr.includes('$(')) return timeStr;
if (!timeStr || timeStr === 'unlimited' || timeStr.includes('$(')) {
const lang = localStorage.getItem('twinpath_lang') || 'en';
return (translations[lang] && translations[lang]['unlimited']) || 'Unlimited';
}
const regex = /(\d+)([wdhms])/g;
let parts = [];
@@ -179,6 +200,38 @@ function updateProgressBars() {
const quotaBar = document.querySelector('.progress-quota');
if (quotaBar) quotaBar.style.width = '100%'; // Unlimited
}
// Check for "Reached Limit" state
const isExpiredTime = limitUptime > 0 && uptime >= limitUptime;
const isExpiredQuota = limitBytes > 0 && bytesOut >= limitBytes;
const timeRemainingContainer = document.querySelector('[data-i18n="time_left"]')?.parentElement?.querySelector('.value');
const quotaRemainingContainer = document.querySelector('[data-i18n="quota_left"]')?.parentElement?.querySelector('.value');
const lang = localStorage.getItem('twinpath_lang') || 'en';
const unlimitedLabel = (translations[lang] && translations[lang]['unlimited']) || 'Unlimited';
if (timeRemainingContainer) {
if (limitUptime === 0) {
timeRemainingContainer.innerText = unlimitedLabel;
} else if (isExpiredTime) {
timeRemainingContainer.innerText = "Reached Limit"; // Or add a translation key
timeRemainingContainer.style.color = "#ff4d4d";
}
}
if (quotaRemainingContainer) {
if (limitBytes === 0) {
quotaRemainingContainer.innerText = unlimitedLabel;
} else if (isExpiredQuota) {
quotaRemainingContainer.innerText = "Reached Limit";
quotaRemainingContainer.style.color = "#ff4d4d";
}
}
}
function closeVoucherInfo() {
const modal = document.getElementById('voucher-info-modal');
if (modal) modal.classList.add('hidden');
}
function checkVoucher(forceCode = null) {
@@ -186,57 +239,99 @@ function checkVoucher(forceCode = null) {
const code = forceCode || (input ? input.value.trim() : "");
if (!code) return;
const overlay = document.getElementById('qr-confirm-overlay');
const confirmUser = document.getElementById('confirm-user');
const confirmMsg = document.querySelector('[data-i18n="confirm_msg"]');
const connectBtn = document.querySelector('button[onclick="proceedSubmit()"]');
const infoModal = document.getElementById('voucher-info-modal');
const infoContent = document.getElementById('voucher-info-content');
// Show loading state in overlay
if (overlay && confirmUser) {
confirmUser.innerText = getTranslation('check_loading');
if (connectBtn) connectBtn.style.display = 'none';
overlay.classList.remove('hidden');
// Show loading state
if (infoContent && infoModal) {
infoContent.innerHTML = `<div style="text-align:center; padding: 20px;">${getTranslation('check_loading')}</div>`;
infoModal.classList.remove('hidden');
}
const mikhmonUrl = brandConfig.mikhmonUrl;
const session = brandConfig.mikhmonSession;
const url = `${mikhmonUrl}/status/index.php?session=${session}&nama=${code}&json=true`;
const url = `${mikhmonUrl}/api/check.php?session=${session}&nama=${code}`;
fetch(url)
.then(response => response.json())
.then(data => {
if (data.status === 'active') {
confirmUser.innerHTML = `
<div style="font-size: 0.85rem; text-align: left; margin-top: 5px;">
<div style="margin-bottom: 8px; border-bottom: 1px solid #333; padding-bottom: 4px; color: #50e3c2; font-weight: bold;">${data.profile}</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
const lang = localStorage.getItem('twinpath_lang') || 'en';
let statusText = data.expired_at;
let statusColor = '#ff4d4d'; // Default red for expiration dates
let statusBg = 'rgba(255,77,77,0.1)';
let statusBorder = 'rgba(255,77,77,0.2)';
// Format logic
if (statusText === 'Active') {
statusColor = '#50e3c2'; // Green
statusBg = 'rgba(80, 227, 194, 0.1)';
statusBorder = 'rgba(80, 227, 194, 0.2)';
} else {
// Try to parse date: "jan/15/2026 00:37:47"
const dateParts = statusText.match(/([a-z]+)\/(\d+)\/(\d+)\s+(\d+:\d+:\d+)/i);
if (dateParts) {
try {
const [_, mStr, d, y, time] = dateParts;
const months = { 'jan': 0, 'feb': 1, 'mar': 2, 'apr': 3, 'may': 4, 'jun': 5, 'jul': 6, 'aug': 7, 'sep': 8, 'oct': 9, 'nov': 10, 'dec': 11 };
const dateObj = new Date(y, months[mStr.toLowerCase()], d);
// Set time components manually if needed or just use date
const [hh, mm, ss] = time.split(':');
dateObj.setHours(hh, mm, ss);
const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' };
const formattedDate = new Intl.DateTimeFormat(lang === 'id' ? 'id-ID' : 'en-US', options).format(dateObj);
statusText = formattedDate; // Just the date
} catch (e) { console.error("Date parse error", e); }
}
}
infoContent.innerHTML = `
<div class="confirm-item" style="margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px;">
<span class="confirm-label" data-i18n="user_label">${getTranslation('user_label')}</span>
<span class="confirm-value">${data.user}</span>
</div>
<div style="font-size: 0.85rem; text-align: left;">
<div style="margin-bottom: 12px; color: #50e3c2; font-weight: bold; font-family: var(--font-mono);">${data.profile}</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div>
<div style="font-size: 0.65rem; color: #888;">UPTIME</div>
<div style="color: #fff;">${data.uptime}</div>
<div class="confirm-label">UPTIME</div>
<div style="color: #fff; font-weight: 600;">${data.uptime}</div>
</div>
<div>
<div style="font-size: 0.65rem; color: #888;">QUOTA</div>
<div style="color: #fff;">${data.data_left}</div>
<div class="confirm-label">QUOTA</div>
<div style="color: #fff; font-weight: 600;">${data.data_left}</div>
</div>
</div>
<div style="margin-top: 10px;">
<div style="font-size: 0.65rem; color: #888;">EXPIRED AT</div>
<div style="color: #ff4d4d; font-family: monospace;">${data.expired_at}</div>
<div style="margin-top: 15px; background: ${statusBg}; padding: 8px; border-radius: 4px; border: 1px solid ${statusBorder};">
<div class="confirm-label" style="color: ${statusColor};" data-i18n="status_label">STATUS</div>
<div style="color: ${statusColor}; font-family: monospace; font-weight: bold;">${statusText}</div>
</div>
</div>
`;
if (confirmMsg) confirmMsg.innerText = getTranslation('success_title');
} else if (data.status === 'expired') {
confirmUser.innerHTML = `<span style="color: #ff4d4d;">${getTranslation('check_expired')}</span>`;
if (confirmMsg) confirmMsg.innerText = getTranslation('failed_title');
infoContent.innerHTML = `
<div style="text-align:center; padding: 20px;">
<span style="color: #ff4d4d; font-weight: bold; font-size: 1.1rem;">${getTranslation('check_expired')}</span>
<div style="font-size: 0.8rem; color: #888; margin-top: 5px;">Reason: ${data.reason || 'Unknown'}</div>
</div>
`;
} else {
confirmUser.innerHTML = `<span style="color: #aaa;">${getTranslation('check_not_found')}</span>`;
if (confirmMsg) confirmMsg.innerText = getTranslation('failed_title');
infoContent.innerHTML = `
<div style="text-align:center; padding: 20px;">
<span style="color: #aaa; font-weight: bold;">${getTranslation('check_not_found')}</span>
</div>
`;
}
})
.catch(err => {
console.error("AJAX Error:", err);
confirmUser.innerHTML = `<span style="color: #ff4d4d;">CORS/Connection Error</span><br><small style="font-size: 10px; color: #666;">Check Mikhmon Address & CORS</small>`;
infoContent.innerHTML = `
<div style="text-align:center; padding: 20px; color: #ff4d4d;">
<strong>Connection Error</strong><br>
<small style="font-size: 10px; color: #888;">Cannot reach Mikhmon API. Check CORS settings.</small>
</div>
`;
});
}
@@ -300,6 +395,16 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
});
// Handle Enter key for Voucher Check
const infoInput = document.getElementById('info-input');
if (infoInput) {
infoInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
checkVoucher(); // No form to prevent anymore
}
});
}
});
function togglePassword(inputId, button) {

View File

@@ -28,76 +28,81 @@
</div>
</div>
<!-- Header -->
<header class="header">
<img src="img/logo-twinpath.svg" alt="" data-asset="logo" data-brand-name class="logo-img">
<div class="status-badge">
<span style="color: #50e3c2"></span> <span data-i18n="operational">Operational</span>
</div>
</header>
<!-- Main Card -->
<div class="card">
<!-- TwinpathNet Error Message (Only shows if error exists) -->
$(if error)
<div style="color: #ff4d4d; margin-bottom: 1rem; font-size: 0.875rem; text-align: center;">
$(error)
</div>
$(endif)
<!-- Tabs -->
<div class="tabs">
<div class="tab-btn active" onclick="setMode('voucher')">
<img src="svg/ticket.svg" alt="" data-asset="icon_ticket"> <span data-i18n="tab_voucher">Voucher</span>
<!-- Main Content Wrapper -->
<main class="content-wrapper">
<!-- Header -->
<header class="header">
<img src="img/logo-twinpath.svg" alt="" data-asset="logo" data-brand-name class="logo-img">
<div class="status-badge">
<span style="color: #50e3c2"></span> <span data-i18n="operational">Operational</span>
</div>
<div class="tab-btn" onclick="setMode('member')">
<img src="svg/user.svg" alt="" data-asset="icon_user"> <span data-i18n="tab_member">Member</span>
</div>
<div class="tab-btn" onclick="setMode('info')">
<img src="svg/clock.svg" alt="" data-asset="icon_clock"> <span data-i18n="tab_info">Info</span>
</div>
</div>
</header>
<!-- Login Form -->
<form name="login" action="$(link-login-only)" method="post" onsubmit="return doLogin()">
<input type="hidden" name="dst" value="$(link-orig)">
<input type="hidden" name="popup" value="true">
<!-- Main Card -->
<div class="card">
<!-- TwinpathNet Error Message (Only shows if error exists) -->
$(if error)
<div style="color: #ff4d4d; margin-bottom: 1rem; font-size: 0.875rem; text-align: center;">
$(error)
</div>
$(endif)
<!-- Voucher Mode -->
<div id="voucher-mode">
<div class="input-group">
<label class="input-label" data-i18n="voucher_label">Voucher Code</label>
<div class="input-wrapper">
<img src="svg/ticket.svg" class="input-icon-img" alt="" data-asset="icon_ticket">
<input type="text" name="username" id="voucher-input" class="input-field input-with-icon" data-i18n="voucher_placeholder" placeholder="Enter code received..." value="$(username)">
</div>
<!-- Hide password field for voucher (username=password) -->
<input type="hidden" name="password" id="voucher-pass">
<!-- Tabs -->
<div class="tabs">
<div class="tab-btn active" onclick="setMode('voucher')">
<img src="svg/ticket.svg" alt="" data-asset="icon_ticket"> <span data-i18n="tab_voucher">Voucher</span>
</div>
<div class="tab-btn" onclick="setMode('member')">
<img src="svg/user.svg" alt="" data-asset="icon_user"> <span data-i18n="tab_member">Member</span>
</div>
<div class="tab-btn" onclick="setMode('info')">
<img src="svg/clock.svg" alt="" data-asset="icon_clock"> <span data-i18n="tab_info">Info</span>
</div>
</div>
<!-- Member Mode -->
<div id="member-mode" class="hidden">
<div class="input-group">
<label class="input-label" data-i18n="user_label">Username</label>
<div class="input-wrapper">
<img src="svg/user.svg" class="input-icon-img" alt="" data-asset="icon_user">
<input type="text" id="member-user" class="input-field input-with-icon" placeholder="username">
</div>
</div>
<div class="input-group">
<label class="input-label" data-i18n="pass_label">Password</label>
<div class="input-wrapper">
<img src="svg/lock.svg" class="input-icon-img" alt="" data-asset="icon_lock">
<input type="password" id="member-pass" class="input-field input-with-icon input-with-toggle" placeholder="••••••••">
<button type="button" class="password-toggle" onclick="togglePassword('member-pass', this)">
<img src="svg/eye.svg" alt="" id="toggle-icon-member-pass">
</button>
</div>
</div>
</div>
<!-- Login Form (Voucher & Member Only) -->
<form name="login" action="$(link-login-only)" method="post" onsubmit="return doLogin()">
<input type="hidden" name="dst" value="$(link-orig)">
<input type="hidden" name="popup" value="true">
<!-- Hidden submit to capture Enter key on inputs -->
<input type="submit" style="display:none" />
<!-- Info/Check Mode -->
<!-- Voucher Mode -->
<div id="voucher-mode">
<div class="input-group">
<label class="input-label" data-i18n="voucher_label">Voucher Code</label>
<div class="input-wrapper">
<img src="svg/ticket.svg" class="input-icon-img" alt="" data-asset="icon_ticket">
<input type="text" name="username" id="voucher-input" class="input-field input-with-icon" data-i18n="voucher_placeholder" placeholder="Enter code received..." value="$(username)">
</div>
<!-- Hide password field for voucher (username=password) -->
<input type="hidden" name="password" id="voucher-pass">
</div>
</div>
<!-- Member Mode -->
<div id="member-mode" class="hidden">
<div class="input-group">
<label class="input-label" data-i18n="user_label">Username</label>
<div class="input-wrapper">
<img src="svg/user.svg" class="input-icon-img" alt="" data-asset="icon_user">
<input type="text" id="member-user" class="input-field input-with-icon" placeholder="username">
</div>
</div>
<div class="input-group">
<label class="input-label" data-i18n="pass_label">Password</label>
<div class="input-wrapper">
<img src="svg/lock.svg" class="input-icon-img" alt="" data-asset="icon_lock">
<input type="password" id="member-pass" class="input-field input-with-icon input-with-toggle" placeholder="••••••••">
<button type="button" class="password-toggle" onclick="togglePassword('member-pass', this)">
<img src="svg/eye.svg" alt="" id="toggle-icon-member-pass">
</button>
</div>
</div>
</div>
</form>
<!-- Info/Check Mode (Outside Form) -->
<div id="info-mode" class="hidden">
<div class="input-group">
<label class="input-label" data-i18n="info_label">Check Validity</label>
@@ -108,18 +113,20 @@
</div>
</div>
<!-- Action Buttons -->
<!-- Action Buttons (Shared Grid) -->
<div style="display: grid; gap: 0.75rem;">
<button type="submit" id="login-btn" class="btn btn-primary" data-i18n="login_voucher">Use Voucher</button>
<!-- Login Button: Triggers Form Submit via JS -->
<button type="button" id="login-btn" class="btn btn-primary" data-i18n="login_voucher" onclick="if(doLogin()) document.login.submit()">Use Voucher</button>
<button type="button" id="check-btn" class="btn btn-primary hidden" onclick="checkVoucher()" data-i18n="check_btn">Check Status</button>
<button type="button" class="btn btn-outline" id="scan-btn" onclick="openQR()">
<button type="button" class="btn btn-outline" id="scan-btn" onclick="openQR(getActiveMode())">
<img src="svg/scan-line.svg" width="16" height="16" alt="" data-asset="icon_scan" style="margin-right: 0.5rem; vertical-align: text-bottom;">
<span data-i18n="scan_btn">Scan QR Code</span>
</button>
$(if trial == 'yes')
<div style="text-align: center; margin-top: 0.5rem; font-size: 0.8rem; color: var(--fg-secondary);">
<div id="trial-container" style="text-align: center; margin-top: 0.5rem; font-size: 0.8rem; color: var(--fg-secondary);">
<span data-i18n="or_text">Or</span>
<button type="button" onclick="location.href='$(link-login-only)?dst=$(link-orig-esc)&amp;username=T-$(mac-esc)'" class="btn btn-outline" style="margin-top: 0.5rem" data-i18n="trial_btn">
Free Trial Access
@@ -127,30 +134,30 @@
</div>
$(endif)
</div>
</form>
</div>
</div>
<!-- Pricing Section -->
<div>
<h3 style="font-size: 1rem; margin-bottom: 1rem; color: var(--fg-secondary);" data-i18n="pricing_title">Available Packages</h3>
<div class="pricing-grid">
<!-- Paket 1 -->
<div class="pricing-card">
<div class="duration">3 <span data-i18n="hours">HOURS</span></div>
<div class="price">2K</div>
</div>
<!-- Paket 2 -->
<div class="pricing-card">
<div class="duration">1 <span data-i18n="day">DAY</span></div>
<div class="price">5K</div>
</div>
<!-- Paket 3 -->
<div class="pricing-card">
<div class="duration">1 <span data-i18n="week">WEEK</span></div>
<div class="price">10K</div>
<!-- Pricing Section -->
<div>
<h3 style="font-size: 1rem; margin-bottom: 1rem; color: var(--fg-secondary);" data-i18n="pricing_title">Available Packages</h3>
<div class="pricing-grid">
<!-- Paket 1 -->
<div class="pricing-card">
<div class="duration">3 <span data-i18n="hours">HOURS</span></div>
<div class="price">2K</div>
</div>
<!-- Paket 2 -->
<div class="pricing-card">
<div class="duration">1 <span data-i18n="day">DAY</span></div>
<div class="price">5K</div>
</div>
<!-- Paket 3 -->
<div class="pricing-card">
<div class="duration">1 <span data-i18n="week">WEEK</span></div>
<div class="price">10K</div>
</div>
</div>
</div>
</div>
</main>
<footer>
<p><span data-i18n="powered_by">Powered by</span> <span data-brand-name></span><a href="#" data-brand-link="credit" target="_blank" data-brand-credit></a></p>
@@ -207,6 +214,20 @@
</div>
</div>
<!-- Voucher Info Modal (Dedicated for results) -->
<div id="voucher-info-modal" class="modal hidden">
<div class="modal-content confirm-card">
<button class="close-modal" onclick="closeVoucherInfo()">×</button>
<h3 data-i18n="info_title">Voucher Details</h3>
<div id="voucher-info-content" class="confirm-details">
<!-- Dynamic content here -->
</div>
<div style="margin-top: 1.5rem;">
<button type="button" class="btn btn-primary" style="width: 100%" onclick="closeVoucherInfo()">OK</button>
</div>
</div>
</div>
<!-- Hidden element for file scanning -->
<div id="qr-file-reader" style="display:none"></div>

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ededed" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 273 B