Compare commits
10 Commits
v1.0.0-dar
...
theme/mode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d09cc773e8 | ||
|
|
c083143612 | ||
|
|
877f8b44f3 | ||
|
|
d4bbed9137 | ||
|
|
b6a1ea65a6 | ||
|
|
a8349e1f4f | ||
|
|
8f62c68a69 | ||
|
|
98a6f4304c | ||
|
|
06205ee56b | ||
|
|
77ec4f68c4 |
160
README.md
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
BIN
img/ss-desktop-01-login-voucher.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
img/ss-desktop-02-login-member.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
img/ss-desktop-03-login-ceck-validity.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
img/ss-desktop-04-status.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
img/ss-mobile-01-login-voucher.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
img/ss-mobile-02-login-member.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
img/ss-mobile-03-login-check-validity.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
img/ss-mobile-04-status.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
163
js/script.js
@@ -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) {
|
||||
|
||||
197
login.html
@@ -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)&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>
|
||||
|
||||
|
||||
@@ -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 |