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.
|
<p>
|
||||||
|
<a href="https://github.com/dyzulk/twinpath-hotspot-themes/releases">
|
||||||
## ✨ Features
|
<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" />
|
||||||
- **Dynamic Branching**: Every branch in this repository represents a different theme/variant.
|
</a>
|
||||||
- **Premium Aesthetics**: Dark modes, vibrant accents, and modern typography.
|
<a href="#">
|
||||||
- **Multi-language Support**: Built-in Indonesian (ID) and English (EN) localization.
|
<img src="https://img.shields.io/badge/Mikrotik-RouterOS%20v6-000000?style=for-the-badge&logo=mikrotik&logoColor=white&labelColor=000000" alt="Mikrotik" />
|
||||||
- **QR Code Connectivity**: Integrated QR scanner for quick login from vouchers.
|
</a>
|
||||||
|
<a href="#">
|
||||||
## 🚀 How to Use
|
<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>
|
||||||
### 1. Choose Your Theme
|
<a href="LICENSE">
|
||||||
Check out the available branches to find the theme that suits your brand:
|
<img src="https://img.shields.io/badge/License-MIT-000000?style=for-the-badge&labelColor=000000" alt="License" />
|
||||||
- `theme/modern-dark` (Current active theme: Vercel-style dark with teal accents)
|
</a>
|
||||||
|
</p>
|
||||||
### 2. Implementation
|
</div>
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
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;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
flex: 1; /* Pushes footer down */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -449,7 +454,7 @@ textarea:-webkit-autofill:focus {
|
|||||||
/* Footer */
|
/* Footer */
|
||||||
footer {
|
footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 6rem; /* Even more separation from buttons */
|
margin-top: auto; /* Sticky bottom logic */
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
color: var(--fg-tertiary);
|
color: var(--fg-tertiary);
|
||||||
@@ -584,7 +589,7 @@ footer {
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
opacity: 0.5;
|
opacity: 0.9;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,10 +669,30 @@ footer {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
#qr-confirm-overlay.hidden {
|
#qr-confirm-overlay.hidden, .modal.hidden {
|
||||||
display: none;
|
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 {
|
.confirm-card {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border);
|
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 = {
|
const brandConfig = {
|
||||||
brandName: "TwinpathNet",
|
brandName: "TwinpathNet",
|
||||||
portalUrl: "http://welcome.dyzulk.com/login",
|
portalUrl: "http://welcome.dyzulk.com/login",
|
||||||
allowedDomains: [
|
|
||||||
"welcome.dyzulk.com"
|
|
||||||
],
|
|
||||||
creditName: "dyzulk.com",
|
creditName: "dyzulk.com",
|
||||||
creditUrl: "https://dyzulk.com",
|
creditUrl: "https://dyzulk.com",
|
||||||
mikhmonUrl: "https://mikhmon.dyzulk.com",
|
mikhmonUrl: "https://mikhmon.dyzulk.com",
|
||||||
mikhmonSession: "Twinpath-Net",
|
mikhmonSession: "Twinpath-Net",
|
||||||
|
allowedDomains: [
|
||||||
|
"welcome.dyzulk.com"
|
||||||
|
],
|
||||||
assets: {
|
assets: {
|
||||||
logo: "img/logo-twinpath.svg",
|
logo: "img/logo-twinpath.svg",
|
||||||
icon_ticket: "svg/ticket.svg",
|
icon_ticket: "svg/ticket.svg",
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ const translations = {
|
|||||||
check_not_found: "Voucher tidak ditemukan atau belum aktif.",
|
check_not_found: "Voucher tidak ditemukan atau belum aktif.",
|
||||||
check_expired: "Voucher sudah kadaluarsa.",
|
check_expired: "Voucher sudah kadaluarsa.",
|
||||||
check_valid_until: "Aktif sampai",
|
check_valid_until: "Aktif sampai",
|
||||||
check_quota_remaining: "Sisa Kuota"
|
check_quota_remaining: "Sisa Kuota",
|
||||||
|
status_label: "STATUS"
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
lang_name: "English",
|
lang_name: "English",
|
||||||
@@ -127,7 +128,8 @@ const translations = {
|
|||||||
check_not_found: "Voucher not found or not active.",
|
check_not_found: "Voucher not found or not active.",
|
||||||
check_expired: "Voucher has expired.",
|
check_expired: "Voucher has expired.",
|
||||||
check_valid_until: "Valid until",
|
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) {
|
function handleDecodedText(decodedText) {
|
||||||
console.log(`Scan result: ${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 username = "";
|
||||||
let password = "";
|
let password = "";
|
||||||
let isUnauthorized = false;
|
let isUnauthorized = false;
|
||||||
let blockReason = ""; // Track why it was blocked
|
let blockReason = ""; // Track why it was blocked
|
||||||
scannedUrl = "";
|
scannedUrl = "";
|
||||||
|
|
||||||
// Check if result is a URL
|
// Check if result is a URL (STRICT for Login Mode)
|
||||||
try {
|
try {
|
||||||
if (decodedText.startsWith('http://') || decodedText.startsWith('https://')) {
|
if (decodedText.startsWith('http://') || decodedText.startsWith('https://')) {
|
||||||
const url = new URL(decodedText);
|
const url = new URL(decodedText);
|
||||||
@@ -75,7 +95,7 @@ function handleDecodedText(decodedText) {
|
|||||||
blockReason = getTranslation('qr_err_parse');
|
blockReason = getTranslation('qr_err_parse');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill inputs (only if authorized)
|
// Fill inputs (only if authorized - Login Mode)
|
||||||
if (!isUnauthorized && username) {
|
if (!isUnauthorized && username) {
|
||||||
const voucherInput = document.getElementById('voucher-input');
|
const voucherInput = document.getElementById('voucher-input');
|
||||||
const passField = document.getElementById('voucher-pass');
|
const passField = document.getElementById('voucher-pass');
|
||||||
@@ -83,16 +103,12 @@ function handleDecodedText(decodedText) {
|
|||||||
if (passField) passField.value = password || username;
|
if (passField) passField.value = password || username;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show confirmation overlay
|
// Show confirmation overlay (Login Mode only)
|
||||||
const overlay = document.getElementById('qr-confirm-overlay');
|
const overlay = document.getElementById('qr-confirm-overlay');
|
||||||
const confirmUser = document.getElementById('confirm-user');
|
const confirmUser = document.getElementById('confirm-user');
|
||||||
const connectBtn = document.querySelector('button[onclick="proceedSubmit()"]');
|
const connectBtn = document.querySelector('button[onclick="proceedSubmit()"]');
|
||||||
const confirmMsg = document.querySelector('[data-i18n="confirm_msg"]');
|
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 (overlay && confirmUser) {
|
||||||
if (isUnauthorized) {
|
if (isUnauthorized) {
|
||||||
confirmUser.innerHTML = `<span style="color: #ff4d4d;">Blocked: ${blockReason}</span>`;
|
confirmUser.innerHTML = `<span style="color: #ff4d4d;">Blocked: ${blockReason}</span>`;
|
||||||
@@ -101,15 +117,10 @@ function handleDecodedText(decodedText) {
|
|||||||
confirmUser.innerText = username;
|
confirmUser.innerText = username;
|
||||||
if (connectBtn) {
|
if (connectBtn) {
|
||||||
connectBtn.style.display = 'block';
|
connectBtn.style.display = 'block';
|
||||||
if (isInfoMode) {
|
|
||||||
connectBtn.innerText = getTranslation('check_btn');
|
|
||||||
if (confirmMsg) confirmMsg.innerText = getTranslation('info_label');
|
|
||||||
} else {
|
|
||||||
connectBtn.innerText = getTranslation('connect_btn');
|
connectBtn.innerText = getTranslation('connect_btn');
|
||||||
if (confirmMsg) confirmMsg.innerText = getTranslation('confirm_msg');
|
if (confirmMsg) confirmMsg.innerText = getTranslation('confirm_msg');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
overlay.classList.remove('hidden');
|
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');
|
const modal = document.getElementById('qr-scanner-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
|||||||
163
js/script.js
@@ -5,10 +5,11 @@ function setMode(mode) {
|
|||||||
const tabs = document.querySelectorAll('.tab-btn');
|
const tabs = document.querySelectorAll('.tab-btn');
|
||||||
const loginBtn = document.getElementById('login-btn');
|
const loginBtn = document.getElementById('login-btn');
|
||||||
const checkBtn = document.getElementById('check-btn');
|
const checkBtn = document.getElementById('check-btn');
|
||||||
|
const trialContainer = document.getElementById('trial-container');
|
||||||
const form = document.login;
|
const form = document.login;
|
||||||
|
|
||||||
// Reset visibility
|
// Reset visibility
|
||||||
[voucherMode, memberMode, infoMode, loginBtn, checkBtn].forEach(el => {
|
[voucherMode, memberMode, infoMode, loginBtn, checkBtn, trialContainer].forEach(el => {
|
||||||
if (el) el.classList.add('hidden');
|
if (el) el.classList.add('hidden');
|
||||||
});
|
});
|
||||||
tabs.forEach(t => t.classList.remove('active'));
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
@@ -16,6 +17,7 @@ function setMode(mode) {
|
|||||||
if (mode === 'voucher') {
|
if (mode === 'voucher') {
|
||||||
if (voucherMode) voucherMode.classList.remove('hidden');
|
if (voucherMode) voucherMode.classList.remove('hidden');
|
||||||
if (loginBtn) loginBtn.classList.remove('hidden');
|
if (loginBtn) loginBtn.classList.remove('hidden');
|
||||||
|
if (trialContainer) trialContainer.classList.remove('hidden');
|
||||||
tabs[0].classList.add('active');
|
tabs[0].classList.add('active');
|
||||||
if (form) {
|
if (form) {
|
||||||
const code = document.getElementById('voucher-input').value;
|
const code = document.getElementById('voucher-input').value;
|
||||||
@@ -25,6 +27,7 @@ function setMode(mode) {
|
|||||||
} else if (mode === 'member') {
|
} else if (mode === 'member') {
|
||||||
if (memberMode) memberMode.classList.remove('hidden');
|
if (memberMode) memberMode.classList.remove('hidden');
|
||||||
if (loginBtn) loginBtn.classList.remove('hidden');
|
if (loginBtn) loginBtn.classList.remove('hidden');
|
||||||
|
if (trialContainer) trialContainer.classList.remove('hidden');
|
||||||
tabs[1].classList.add('active');
|
tabs[1].classList.add('active');
|
||||||
if (form) {
|
if (form) {
|
||||||
form.username.value = document.getElementById('member-user').value;
|
form.username.value = document.getElementById('member-user').value;
|
||||||
@@ -33,6 +36,7 @@ function setMode(mode) {
|
|||||||
} else if (mode === 'info') {
|
} else if (mode === 'info') {
|
||||||
if (infoMode) infoMode.classList.remove('hidden');
|
if (infoMode) infoMode.classList.remove('hidden');
|
||||||
if (checkBtn) checkBtn.classList.remove('hidden');
|
if (checkBtn) checkBtn.classList.remove('hidden');
|
||||||
|
// trialContainer remains hidden in info mode
|
||||||
tabs[2].classList.add('active');
|
tabs[2].classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +49,20 @@ function setMode(mode) {
|
|||||||
loginBtn.innerText = translations[lang][key];
|
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() {
|
function doLogin() {
|
||||||
@@ -110,7 +128,10 @@ function openInExternalBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMikrotikTime(timeStr) {
|
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;
|
const regex = /(\d+)([wdhms])/g;
|
||||||
let parts = [];
|
let parts = [];
|
||||||
@@ -179,6 +200,38 @@ function updateProgressBars() {
|
|||||||
const quotaBar = document.querySelector('.progress-quota');
|
const quotaBar = document.querySelector('.progress-quota');
|
||||||
if (quotaBar) quotaBar.style.width = '100%'; // Unlimited
|
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) {
|
function checkVoucher(forceCode = null) {
|
||||||
@@ -186,57 +239,99 @@ function checkVoucher(forceCode = null) {
|
|||||||
const code = forceCode || (input ? input.value.trim() : "");
|
const code = forceCode || (input ? input.value.trim() : "");
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
|
|
||||||
const overlay = document.getElementById('qr-confirm-overlay');
|
const infoModal = document.getElementById('voucher-info-modal');
|
||||||
const confirmUser = document.getElementById('confirm-user');
|
const infoContent = document.getElementById('voucher-info-content');
|
||||||
const confirmMsg = document.querySelector('[data-i18n="confirm_msg"]');
|
|
||||||
const connectBtn = document.querySelector('button[onclick="proceedSubmit()"]');
|
|
||||||
|
|
||||||
// Show loading state in overlay
|
// Show loading state
|
||||||
if (overlay && confirmUser) {
|
if (infoContent && infoModal) {
|
||||||
confirmUser.innerText = getTranslation('check_loading');
|
infoContent.innerHTML = `<div style="text-align:center; padding: 20px;">${getTranslation('check_loading')}</div>`;
|
||||||
if (connectBtn) connectBtn.style.display = 'none';
|
infoModal.classList.remove('hidden');
|
||||||
overlay.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mikhmonUrl = brandConfig.mikhmonUrl;
|
const mikhmonUrl = brandConfig.mikhmonUrl;
|
||||||
const session = brandConfig.mikhmonSession;
|
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)
|
fetch(url)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'active') {
|
if (data.status === 'active') {
|
||||||
confirmUser.innerHTML = `
|
const lang = localStorage.getItem('twinpath_lang') || 'en';
|
||||||
<div style="font-size: 0.85rem; text-align: left; margin-top: 5px;">
|
let statusText = data.expired_at;
|
||||||
<div style="margin-bottom: 8px; border-bottom: 1px solid #333; padding-bottom: 4px; color: #50e3c2; font-weight: bold;">${data.profile}</div>
|
let statusColor = '#ff4d4d'; // Default red for expiration dates
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
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>
|
||||||
<div style="font-size: 0.65rem; color: #888;">UPTIME</div>
|
<div class="confirm-label">UPTIME</div>
|
||||||
<div style="color: #fff;">${data.uptime}</div>
|
<div style="color: #fff; font-weight: 600;">${data.uptime}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size: 0.65rem; color: #888;">QUOTA</div>
|
<div class="confirm-label">QUOTA</div>
|
||||||
<div style="color: #fff;">${data.data_left}</div>
|
<div style="color: #fff; font-weight: 600;">${data.data_left}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 10px;">
|
<div style="margin-top: 15px; background: ${statusBg}; padding: 8px; border-radius: 4px; border: 1px solid ${statusBorder};">
|
||||||
<div style="font-size: 0.65rem; color: #888;">EXPIRED AT</div>
|
<div class="confirm-label" style="color: ${statusColor};" data-i18n="status_label">STATUS</div>
|
||||||
<div style="color: #ff4d4d; font-family: monospace;">${data.expired_at}</div>
|
<div style="color: ${statusColor}; font-family: monospace; font-weight: bold;">${statusText}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
if (confirmMsg) confirmMsg.innerText = getTranslation('success_title');
|
|
||||||
} else if (data.status === 'expired') {
|
} else if (data.status === 'expired') {
|
||||||
confirmUser.innerHTML = `<span style="color: #ff4d4d;">${getTranslation('check_expired')}</span>`;
|
infoContent.innerHTML = `
|
||||||
if (confirmMsg) confirmMsg.innerText = getTranslation('failed_title');
|
<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 {
|
} else {
|
||||||
confirmUser.innerHTML = `<span style="color: #aaa;">${getTranslation('check_not_found')}</span>`;
|
infoContent.innerHTML = `
|
||||||
if (confirmMsg) confirmMsg.innerText = getTranslation('failed_title');
|
<div style="text-align:center; padding: 20px;">
|
||||||
|
<span style="color: #aaa; font-weight: bold;">${getTranslation('check_not_found')}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("AJAX Error:", 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) {
|
function togglePassword(inputId, button) {
|
||||||
|
|||||||
35
login.html
@@ -28,6 +28,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Wrapper -->
|
||||||
|
<main class="content-wrapper">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<img src="img/logo-twinpath.svg" alt="" data-asset="logo" data-brand-name class="logo-img">
|
<img src="img/logo-twinpath.svg" alt="" data-asset="logo" data-brand-name class="logo-img">
|
||||||
@@ -58,10 +60,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form (Voucher & Member Only) -->
|
||||||
<form name="login" action="$(link-login-only)" method="post" onsubmit="return doLogin()">
|
<form name="login" action="$(link-login-only)" method="post" onsubmit="return doLogin()">
|
||||||
<input type="hidden" name="dst" value="$(link-orig)">
|
<input type="hidden" name="dst" value="$(link-orig)">
|
||||||
<input type="hidden" name="popup" value="true">
|
<input type="hidden" name="popup" value="true">
|
||||||
|
<!-- Hidden submit to capture Enter key on inputs -->
|
||||||
|
<input type="submit" style="display:none" />
|
||||||
|
|
||||||
<!-- Voucher Mode -->
|
<!-- Voucher Mode -->
|
||||||
<div id="voucher-mode">
|
<div id="voucher-mode">
|
||||||
@@ -96,8 +100,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- Info/Check Mode -->
|
<!-- Info/Check Mode (Outside Form) -->
|
||||||
<div id="info-mode" class="hidden">
|
<div id="info-mode" class="hidden">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label class="input-label" data-i18n="info_label">Check Validity</label>
|
<label class="input-label" data-i18n="info_label">Check Validity</label>
|
||||||
@@ -108,18 +113,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons (Shared Grid) -->
|
||||||
<div style="display: grid; gap: 0.75rem;">
|
<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" 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;">
|
<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>
|
<span data-i18n="scan_btn">Scan QR Code</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
$(if trial == 'yes')
|
$(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>
|
<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">
|
<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
|
Free Trial Access
|
||||||
@@ -127,7 +134,6 @@
|
|||||||
</div>
|
</div>
|
||||||
$(endif)
|
$(endif)
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pricing Section -->
|
<!-- Pricing Section -->
|
||||||
@@ -151,6 +157,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<footer>
|
<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>
|
<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>
|
||||||
</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 -->
|
<!-- Hidden element for file scanning -->
|
||||||
<div id="qr-file-reader" style="display:none"></div>
|
<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 |