chore: bump version to v1.2.0, cleanup repo, and update docs refs

This commit is contained in:
dyzulk
2026-01-18 23:29:04 +07:00
parent 6c92985707
commit 18a525e438
36 changed files with 1362 additions and 2777 deletions

View File

@@ -3,7 +3,7 @@ namespace App\Config;
class SiteConfig {
const APP_NAME = 'MIVO';
const APP_VERSION = 'v1.1.1';
const APP_VERSION = 'v1.2.0';
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
const CREDIT_NAME = 'MivoDev';
const CREDIT_URL = 'https://github.com/mivodev';
@@ -13,7 +13,7 @@ class SiteConfig {
// Security Keys
// Fetched from .env or fallback to default
public static function getSecretKey() {
return getenv('APP_KEY') ?: 'mikhmonv3remake_secret_key_32bytes';
return getenv('APP_KEY') ?: 'mivo_official_secret_key_32bytes';
}
const IS_DEV = true; // Still useful for code logic not relying on env yet, or can be refactored too.

View File

@@ -44,7 +44,7 @@ class InstallController extends Controller {
Migrations::up();
// 2. Generate Key if default
if (SiteConfig::getSecretKey() === 'mikhmonv3remake_secret_key_32bytes') {
if (SiteConfig::getSecretKey() === 'mivo_official_secret_key_32bytes') {
$this->generateKey();
}
@@ -90,11 +90,11 @@ class InstallController extends Controller {
$envPath = ROOT . '/.env';
if (!file_exists($envPath)) {
// Check if SiteConfig has a manual override (legacy)
return SiteConfig::getSecretKey() !== 'mikhmonv3remake_secret_key_32bytes';
return SiteConfig::getSecretKey() !== 'mivo_official_secret_key_32bytes';
}
$key = getenv('APP_KEY');
$keyChanged = ($key && $key !== 'mikhmonv3remake_secret_key_32bytes');
$keyChanged = ($key && $key !== 'mivo_official_secret_key_32bytes');
try {
$db = Database::getInstance();

View File

@@ -191,10 +191,10 @@ class QuickPrintController extends Controller {
// Check if M or G
// Simple logic for now, assuming raw if number, or passing string if Mikrotik accepts it (usually requires bytes)
// Let's assume user inputs "100M" or "1G" which usually needs parsing.
// For now, let's assume input is NUMBER in MB as per standard Mikhmon practice, OR generic string.
// For now, let's assume input is NUMBER in MB as per standard Mivo practice, OR generic string.
// We'll pass as is for strings, or multiply if strictly numeric?
// Let's rely on standard Mikrotik parsing if string passed, or convert.
// Mikhmon v3 usually uses dropdown "MB/GB".
// Mivo usually uses dropdown "MB/GB".
// Implementing simple conversion:
$val = intval($package['data_limit']);
if (strpos(strtolower($package['data_limit']), 'g') !== false) {

View File

@@ -11,44 +11,89 @@ class ReportController extends Controller
{
public function index($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) {
$data = $this->getSellingReportData($session);
if (!$data) {
header('Location: /');
exit;
}
return $this->view('reports/selling', $data);
}
public function sellingExport($session, $type)
{
$data = $this->getSellingReportData($session);
if (!$data) {
header('Content-Type: application/json');
echo json_encode(['error' => 'No data found']);
exit;
}
$report = $data['report'];
$exportData = [];
foreach ($report as $row) {
$exportData[] = [
'Date/Batch' => $row['date'],
'Status' => $row['status'] ?? '-',
'Qty (Stock)' => $row['count'],
'Used' => $row['realized_count'],
'Realized Income' => $row['realized_total'],
'Total Stock' => $row['total']
];
}
header('Content-Type: application/json');
echo json_encode($exportData);
exit;
}
private function getSellingReportData($session)
{
$configModel = new Config();
$config = $configModel->getSession($session);
if (!$config) return null;
$API = new RouterOSAPI();
$users = [];
$profilePriceMap = [];
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
// Fetch All Users
// Optimized print: get .id, name, price, comment
$users = $API->comm("/ip/hotspot/user/print");
$profiles = $API->comm("/ip/hotspot/user/profile/print");
$API->disconnect();
// Build Price Map from Profile Scripts
foreach ($profiles as $p) {
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
if (!empty($meta['price'])) {
$profilePriceMap[$p['name']] = intval($meta['price']);
}
}
}
// Aggregate Data
$report = [];
$totalIncome = 0;
$totalVouchers = 0;
// Realized (Used) Metrics
$totalRealizedIncome = 0;
$totalUsedVouchers = 0;
foreach ($users as $user) {
// Skip if no price
if (empty($user['price']) || $user['price'] == '0') continue;
// Smart Price Detection
$price = $this->detectPrice($user, $profilePriceMap);
if ($price <= 0) continue;
// Inject price back to user array for downstream logic
$user['price'] = $price;
// Determine Date from Comment
// Mikhmon format usually: "mikhmon-MM/DD/YYYY" or just "MM/DD/YYYY" or plain comment
// We will try to parse a date from the comment, or use "Unknown Date"
$date = 'Unknown Date';
$comment = $user['comment'] ?? '';
// Regex for date patterns (d-m-Y or m/d/Y or Y-m-d)
// Simplify: Group by Comment content itself if it looks like a date/batch
// Or try to extract M-Y.
// For feature parity, Mikhmon often groups by the exact comment string as the "Batch/Date"
if (!empty($comment)) {
$date = $comment;
}
@@ -57,28 +102,59 @@ class ReportController extends Controller
$report[$date] = [
'date' => $date,
'count' => 0,
'total' => 0
'total' => 0,
'realized_total' => 0,
'realized_count' => 0
];
}
$price = intval($user['price']);
// Check if Used
// Criteria: uptime != 0s OR bytes-out > 0 OR bytes-in > 0
$isUsed = false;
if ((isset($user['uptime']) && $user['uptime'] != '0s') ||
(isset($user['bytes-out']) && $user['bytes-out'] > 0)) {
$isUsed = true;
}
$report[$date]['count']++;
$report[$date]['total'] += $price;
$totalIncome += $price;
$totalVouchers++;
if ($isUsed) {
$report[$date]['realized_count']++;
$report[$date]['realized_total'] += $price;
$totalRealizedIncome += $price;
$totalUsedVouchers++;
}
}
// Calculate Status for each batch
foreach ($report as &$row) {
if ($row['realized_count'] === 0) {
$row['status'] = 'New';
} elseif ($row['realized_count'] >= $row['count']) {
$row['status'] = 'Sold Out';
} else {
$row['status'] = 'Selling';
}
}
unset($row);
// Sort by key (Date/Comment) desc
krsort($report);
return $this->view('reports/selling', [
return [
'session' => $session,
'report' => $report,
'totalIncome' => $totalIncome,
'totalVouchers' => $totalVouchers,
'totalRealizedIncome' => $totalRealizedIncome,
'totalUsedVouchers' => $totalUsedVouchers,
'currency' => $config['currency'] ?? 'Rp'
]);
];
}
public function resume($session)
{
@@ -93,9 +169,18 @@ class ReportController extends Controller
$API = new RouterOSAPI();
$users = [];
$profilePriceMap = [];
if ($API->connect($config['ip_address'], $config['username'], $config['password'])) {
$users = $API->comm("/ip/hotspot/user/print");
$profiles = $API->comm("/ip/hotspot/user/profile/print");
$API->disconnect();
foreach ($profiles as $p) {
$meta = HotspotHelper::parseProfileMetadata($p['on-login'] ?? '');
if (!empty($meta['price'])) {
$profilePriceMap[$p['name']] = intval($meta['price']);
}
}
}
// Initialize Aggregates
@@ -103,28 +188,69 @@ class ReportController extends Controller
$monthly = [];
$yearly = [];
$totalIncome = 0;
// Realized Metrics for Resume?
// Usually Resume is just general financial overview.
// We'll stick to Stock for now unless requested, as Resume mimics Mikhmon's logic closer.
// Or we can just calculate standard revenue based on Stock if that's what user expects for "Resume",
// OR we can add Realized. Let's keep Resume simple first, focus on Selling Report.
foreach ($users as $user) {
if (empty($user['price']) || $user['price'] == '0') continue;
$price = $this->detectPrice($user, $profilePriceMap);
if ($price <= 0) continue;
// Try to parse Date from Comment (Mikhmon format: mikhmon-10/25/2023 or just 10/25/2023)
$user['price'] = $price;
// Try to parse Date from Comment
// Supported formats:
// - MM/DD/YYYY or MM.DD.YYYY (US)
// - DD-MM-YYYY (EU/ID)
// - YYYY-MM-DD (ISO)
// Regex explanations:
// 1. \b starts word boundary to avoid matching parts of batch IDs (e.g. 711-...)
// 2. We look for 3 groups of digits separated by / . or -
$comment = $user['comment'] ?? '';
$dateObj = null;
// Simple parser: try to find MM/DD/YYYY
if (preg_match('/(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})/', $comment, $matches)) {
// Assuming MM/DD/YYYY based on typical Mikhmon, but could be DD-MM-YYYY
// Let's standardise on checking valid date.
// Standard Mikhmon V3 is MM/DD/YYYY.
$m = $matches[1];
$d = $matches[2];
$y = $matches[3];
if (strlen($y) == 2) $y = '20' . $y;
$dateObj = \DateTime::createFromFormat('m/d/Y', "$m/$d/$y");
if (preg_match('/\b(\d{1,2})[\/.-](\d{1,2})[\/.-](\d{2,4})\b/', $comment, $matches)) {
// Heuristic: If 3rd part is year (4 digits or > 31), use it.
// If 1st part > 12, it's likely Day (DD-MM-YYYY).
// Mivo Generator format often: MM.DD.YY or DD.MM.YY
$p1 = intval($matches[1]);
$p2 = intval($matches[2]);
$p3 = intval($matches[3]);
$year = $p3;
$month = $p1;
$day = $p2;
// Adjust 2-digit year
if ($year < 100) $year += 2000;
// Guess format
// If p1 > 12, it must be Day. (DD-MM-YYYY)
if ($p1 > 12) {
$day = $p1;
$month = $p2;
}
// Validate date
if (checkdate($month, $day, $year)) {
$dateObj = (new \DateTime())->setDate($year, $month, $day);
}
}
// Check for ISO YYYY-MM-DD
elseif (preg_match('/\b(\d{4})[\/.-](\d{1,2})[\/.-](\d{1,2})\b/', $comment, $matches)) {
if (checkdate($matches[2], $matches[3], $matches[1])) {
$dateObj = (new \DateTime())->setDate($matches[1], $matches[2], $matches[3]);
}
}
// Fallback: If no date found in comment, maybe created at?
// Usually Mikhmon relies strictly on comment.
// Fallback: If no date found -> "Unknown Date" in resume?
// Resume requires Month/Year keys. If we can't parse date, we can't add to daily/monthly.
// We'll skip or add to "Unknown"?
// Current logic skips if !$dateObj
if (!$dateObj) continue;
$price = intval($user['price']);
@@ -162,4 +288,38 @@ class ReportController extends Controller
'currency' => $config['currency'] ?? 'Rp'
]);
}
/**
* Smart Price Detection Logic
* Hierarchy:
* 1. Comment Override (p:5000)
* 2. Profile Script (Standard Profile)
* 3. Profile Name Fallback (50K) -- REMOVED loose number matching to avoid garbage data
*/
private function detectPrice($user, $profileMap)
{
$comment = $user['comment'] ?? '';
// 1. Comment Override (p:5000 or price:5000)
// Updated: Added \b to prevent matching "up-123" as "p-123"
if (preg_match('/\b(?:p|price)[:-]\s*(\d+)/i', $comment, $matches)) {
return intval($matches[1]);
}
// 2. Profile Script
$profile = $user['profile'] ?? 'default';
if (isset($profileMap[$profile])) {
return $profileMap[$profile];
}
// 3. Fallback: Parse Profile Name (Strict "K" notation only)
// Matches "5K", "5k" -> 5000
if (preg_match('/(\d+)k\b/i', $profile, $m)) {
return intval($m[1]) * 1000;
}
// DEPRECATED: Loose number matching caused garbage data (e.g. "up-311" -> 311)
return 0;
}
}

View File

@@ -77,7 +77,7 @@ class SettingsController extends Controller {
$db = \App\Core\Database::getInstance();
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
// Assuming we are updating the default 'admin' user or the currently logged in user
// Original Mikhmon usually has one main user. Let's update 'admin' for now.
// Original Mivo usually has one main user. Let's update 'admin' for now.
$db->query("UPDATE users SET password = ? WHERE username = 'admin'", [$hash]);
\App\Helpers\FlashHelper::set('success', 'toasts.password_updated', 'toasts.password_updated_desc', [], true);
}
@@ -431,4 +431,194 @@ class SettingsController extends Controller {
}
header('Location: /settings/api-cors');
}
// --- Plugin Management ---
public function plugins() {
$pluginManager = new \App\Core\PluginManager();
// Since PluginManager loads everything in constructor/loadPlugins,
// we can just scan the directory to list them and check status (implied active for now)
$pluginsDir = ROOT . '/plugins';
$plugins = [];
if (is_dir($pluginsDir)) {
$folders = scandir($pluginsDir);
foreach ($folders as $folder) {
if ($folder === '.' || $folder === '..') continue;
$path = $pluginsDir . '/' . $folder;
if (is_dir($path) && file_exists($path . '/plugin.php')) {
// Try to read header from plugin.php
$content = file_get_contents($path . '/plugin.php', false, null, 0, 1024); // Read first 1KB
preg_match('/Plugin Name:\s*(.*)$/mi', $content, $nameMatch);
preg_match('/Version:\s*(.*)$/mi', $content, $verMatch);
preg_match('/Description:\s*(.*)$/mi', $content, $descMatch);
preg_match('/Author:\s*(.*)$/mi', $content, $authMatch);
$plugins[] = [
'id' => $folder,
'name' => trim($nameMatch[1] ?? $folder),
'version' => trim($verMatch[1] ?? '1.0.0'),
'description' => trim($descMatch[1] ?? '-'),
'author' => trim($authMatch[1] ?? '-'),
'path' => $path
];
}
}
}
return $this->view('settings/plugins', ['plugins' => $plugins]);
}
public function uploadPlugin() {
if (!isset($_FILES['plugin_file']) || $_FILES['plugin_file']['error'] !== UPLOAD_ERR_OK) {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.no_file_selected', [], true);
header('Location: /settings/plugins');
exit;
}
$file = $_FILES['plugin_file'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'zip') {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Only .zip files are allowed', [], true);
header('Location: /settings/plugins');
exit;
}
$zip = new \ZipArchive();
if ($zip->open($file['tmp_name']) === TRUE) {
$extractPath = ROOT . '/plugins/';
if (!is_dir($extractPath)) mkdir($extractPath, 0755, true);
// TODO: Better validation to prevent overwriting existing plugins without confirmation?
// For now, extraction overwrites.
// Validate content before extracting everything
// Check if zip has a root folder or just files
// Logic:
// 1. Extract to temp.
// 2. Find plugin.php
// 3. Move to plugins dir.
$tempExtract = sys_get_temp_dir() . '/mivo_plugin_' . uniqid();
if (!mkdir($tempExtract, 0755, true)) {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'Failed to create temp dir', [], true);
header('Location: /settings/plugins');
exit;
}
$zip->extractTo($tempExtract);
$zip->close();
// Search for plugin.php
$pluginFile = null;
$pluginRoot = $tempExtract;
// Recursive iterator to find plugin.php (max depth 2 to avoid deep scanning)
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tempExtract));
foreach ($rii as $file) {
if ($file->isDir()) continue;
if ($file->getFilename() === 'plugin.php') {
$pluginFile = $file->getPathname();
$pluginRoot = dirname($pluginFile);
break;
}
}
if ($pluginFile) {
// Determine destination name
// If the immediate parent of plugin.php is NOT the temp dir, use that folder name.
// Else use the zip name.
$folderName = basename($pluginRoot);
if ($pluginRoot === $tempExtract) {
$folderName = pathinfo($_FILES['plugin_file']['name'], PATHINFO_FILENAME);
}
$dest = $extractPath . $folderName;
// Move/Copy
// Using helper or rename. Rename might fail across volumes (temp to project).
// Use custom recursive copy then delete temp.
$this->recurseCopy($pluginRoot, $dest);
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_installed', 'toasts.plugin_installed_desc', ['name' => $folderName], true);
} else {
\App\Helpers\FlashHelper::set('error', 'toasts.install_failed', 'toasts.invalid_plugin_desc', [], true);
}
// Cleanup
$this->recurseDelete($tempExtract);
} else {
\App\Helpers\FlashHelper::set('error', 'toasts.upload_failed', 'toasts.zip_open_failed_desc', [], true);
}
header('Location: /settings/plugins');
}
public function deletePlugin() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /settings/plugins');
exit;
}
$id = $_POST['plugin_id'] ?? '';
if (empty($id)) {
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID', [], true);
header('Location: /settings/plugins');
exit;
}
// Security check: validate id is just a folder name, no path traversal
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $id)) {
\App\Helpers\FlashHelper::set('error', 'common.error', 'Invalid plugin ID format', [], true);
header('Location: /settings/plugins');
exit;
}
$pluginDir = ROOT . '/plugins/' . $id;
if (is_dir($pluginDir)) {
$this->recurseDelete($pluginDir);
\App\Helpers\FlashHelper::set('success', 'toasts.plugin_deleted', 'toasts.plugin_deleted_desc', [], true);
} else {
\App\Helpers\FlashHelper::set('error', 'common.error', 'Plugin directory not found', [], true);
}
header('Location: /settings/plugins');
exit;
}
// Helper for recursive copy (since rename/move_uploaded_file limit across partitions)
private function recurseCopy($src, $dst) {
$dir = opendir($src);
@mkdir($dst);
while(false !== ( $file = readdir($dir)) ) {
if (( $file != '.' ) && ( $file != '..' )) {
if ( is_dir($src . '/' . $file) ) {
$this->recurseCopy($src . '/' . $file,$dst . '/' . $file);
}
else {
copy($src . '/' . $file,$dst . '/' . $file);
}
}
}
closedir($dir);
}
private function recurseDelete($dir) {
if (!is_dir($dir)) return;
$scan = scandir($dir);
foreach ($scan as $file) {
if ($file == '.' || $file == '..') continue;
if (is_dir($dir . "/" . $file)) {
$this->recurseDelete($dir . "/" . $file);
} else {
unlink($dir . "/" . $file);
}
}
rmdir($dir);
}
}

View File

@@ -45,7 +45,7 @@ class Console {
private function printBanner() {
echo "\n";
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . "v1.1.0" . self::COLOR_RESET . "\n\n";
echo self::COLOR_BOLD . " MIVO Helper " . self::COLOR_RESET . self::COLOR_GRAY . \App\Config\SiteConfig::APP_VERSION . self::COLOR_RESET . "\n\n";
}
private function commandServe($args) {
@@ -171,7 +171,7 @@ class Console {
if (file_exists($envPath)) {
$envIds = parse_ini_file($envPath);
if (!empty($envIds['APP_KEY']) && $envIds['APP_KEY'] !== 'mikhmonv3remake_secret_key_32bytes') {
if (!empty($envIds['APP_KEY']) && $envIds['APP_KEY'] !== 'mivo_official_secret_key_32bytes') {
$keyExists = true;
}
}

View File

@@ -5,7 +5,7 @@ namespace App\Helpers;
class HotspotHelper
{
/**
* Parse profile on-login script metadata (Mikhmon format)
* Parse profile on-login script metadata (Standard format)
* Format: :put (",mode,price,validity,selling_price,lock_user,")
*/
public static function parseProfileMetadata($script) {

View File

@@ -46,7 +46,7 @@ class TemplateHelper {
'{{ip_address}}' => '192.168.88.254',
'{{mac_address}}' => 'AA:BB:CC:DD:EE:FF',
'{{comment}}' => 'Thank You',
'{{copyright}}' => 'Mikhmon',
'{{copyright}}' => 'Mivo',
];
$content = str_replace(array_keys($dummyData), array_values($dummyData), $content);

View File

@@ -326,6 +326,30 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
setInterval(fetchTraffic, reloadInterval);
fetchTraffic();
});
// Localization Support
const updateChartLabels = () => {
if (window.i18n && window.i18n.isLoaded) {
const rxLabel = window.i18n.t('dashboard.rx_download');
const txLabel = window.i18n.t('dashboard.tx_upload');
// Only update if changed
if (chart.data.datasets[0].label !== rxLabel || chart.data.datasets[1].label !== txLabel) {
chart.data.datasets[0].label = rxLabel;
chart.data.datasets[1].label = txLabel;
chart.update('none');
}
}
};
// Listen for language changes
if (window.Mivo) {
window.Mivo.on('languageChanged', updateChartLabels);
}
window.addEventListener('languageChanged', updateChartLabels);
// Try initial update after a short delay to ensure i18n is ready if race condition
setTimeout(updateChartLabels, 500);
});
</script>

View File

@@ -4,7 +4,7 @@
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold tracking-tight">Design System</h1>
<p class="text-accents-5">Component library and style guide for Mikhmon v3.</p>
<p class="text-accents-5">Component library and style guide for Mivo.</p>
</div>
<div class="flex gap-2">
<button onclick="document.documentElement.classList.remove('dark')" class="btn bg-gray-200 text-gray-800">Light</button>

View File

@@ -3,8 +3,8 @@
<div class="w-full max-w-4xl mx-auto py-8 md:py-16 px-4 sm:px-6 text-center">
<div class="mb-8 flex justify-center">
<div class="h-16 w-16 bg-transparent rounded-full flex items-center justify-center">
<img src="/assets/img/logo-m.svg" alt="Mikhmon Logo" class="h-16 w-auto block dark:hidden">
<img src="/assets/img/logo-m-dark.svg" alt="Mikhmon Logo" class="h-16 w-auto hidden dark:block">
<img src="/assets/img/logo-m.svg" alt="Mivo Logo" class="h-16 w-auto block dark:hidden">
<img src="/assets/img/logo-m-dark.svg" alt="Mivo Logo" class="h-16 w-auto hidden dark:block">
</div>
</div>

View File

@@ -14,6 +14,7 @@ $menu = [
['label' => 'templates_title', 'url' => '/settings/voucher-templates', 'namespace' => 'settings'],
['label' => 'logos_title', 'url' => '/settings/logos', 'namespace' => 'settings'],
['label' => 'api_cors_title', 'url' => '/settings/api-cors', 'namespace' => 'settings'],
['label' => 'plugins_title', 'url' => '/settings/plugins', 'namespace' => 'settings'],
];
?>
<nav id="settings-sidebar" class="w-full sticky top-[64px] z-40 bg-background/95 backdrop-blur border-b border-accents-2 transition-all duration-300">

View File

@@ -26,88 +26,92 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<!-- Daily Tab -->
<div id="content-daily" class="tab-content">
<div class="table-container">
<table class="table-glass">
<thead>
<tr>
<th data-i18n="reports.date">Date</th>
<th class="text-right" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($daily as $date => $total): ?>
<tr>
<td><?= $date ?></td>
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<table class="table-glass" id="table-daily">
<thead>
<tr>
<th data-i18n="reports.date">Date</th>
<th class="text-right" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($daily as $date => $total): ?>
<tr>
<td><?= $date ?></td>
<td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Monthly Tab -->
<div id="content-monthly" class="tab-content hidden">
<div class="table-container">
<table class="table-glass">
<thead>
<tr>
<th data-i18n="reports.month">Month</th>
<th class="text-right" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($monthly as $date => $total): ?>
<tr>
<td><?= $date ?></td>
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<table class="table-glass" id="table-monthly">
<thead>
<tr>
<th data-i18n="reports.month">Month</th>
<th class="text-right" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($monthly as $date => $total): ?>
<tr>
<td><?= $date ?></td>
<td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Yearly Tab -->
<div id="content-yearly" class="tab-content hidden">
<div class="table-container">
<table class="table-glass">
<thead>
<tr>
<th data-i18n="reports.year">Year</th>
<th class="text-right" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($yearly as $date => $total): ?>
<tr>
<td><?= $date ?></td>
<td class="text-right"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<table class="table-glass" id="table-yearly">
<thead>
<tr>
<th data-i18n="reports.year">Year</th>
<th class="text-right" data-i18n="reports.total">Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($yearly as $date => $total): ?>
<tr>
<td><?= $date ?></td>
<td class="text-right font-mono"><?= $currency ?> <?= number_format($total, 0, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<script src="/assets/js/components/datatable.js"></script>
<script>
function switchTab(tabName) {
// Hide all contents
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
// Show selected
document.getElementById('content-' + tabName).classList.remove('hidden');
// Reset tab styles
document.querySelectorAll('nav button').forEach(el => {
el.classList.remove('border-primary', 'text-primary');
el.classList.add('border-transparent', 'text-accents-5');
document.addEventListener('DOMContentLoaded', () => {
// Init Datatables
if (typeof SimpleDataTable !== 'undefined') {
new SimpleDataTable('#table-daily', { itemsPerPage: 10, searchable: true });
new SimpleDataTable('#table-monthly', { itemsPerPage: 10, searchable: true });
new SimpleDataTable('#table-yearly', { itemsPerPage: 10, searchable: true });
}
});
// Active tab style
const btn = document.getElementById('tab-' + tabName);
btn.classList.remove('border-transparent', 'text-accents-5');
btn.classList.add('border-primary', 'text-primary');
}
function switchTab(tabName) {
// Hide all contents
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
// Show selected
document.getElementById('content-' + tabName).classList.remove('hidden');
// Reset tab styles
document.querySelectorAll('nav button').forEach(el => {
el.classList.remove('border-primary', 'text-primary');
el.classList.add('border-transparent', 'text-accents-5');
});
// Active tab style
const btn = document.getElementById('tab-' + tabName);
btn.classList.remove('border-transparent', 'text-accents-5');
btn.classList.add('border-primary', 'text-primary');
}
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -9,197 +9,182 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
<p class="text-accents-5"><span data-i18n="reports.selling_subtitle">Sales summary and details for:</span> <span class="text-foreground font-medium"><?= htmlspecialchars($session) ?></span></p>
</div>
<div class="flex gap-2">
<button onclick="location.reload()" class="btn btn-secondary">
<div class="dropdown dropdown-end relative" id="export-dropdown">
<button class="btn btn-secondary dropdown-toggle" onclick="document.getElementById('export-menu').classList.toggle('hidden')">
<i data-lucide="download" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.export">Export</span>
<i data-lucide="chevron-down" class="w-4 h-4 ml-1"></i>
</button>
<div id="export-menu" class="dropdown-menu hidden absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white dark:bg-black border border-accents-2 z-50 p-1">
<button onclick="exportReport('csv')" class="block w-full text-left px-4 py-2 text-sm text-foreground hover:bg-accents-1 rounded flex items-center">
<i data-lucide="file-text" class="w-4 h-4 mr-2 text-green-600"></i> Export CSV
</button>
<button onclick="exportReport('xlsx')" class="block w-full text-left px-4 py-2 text-sm text-foreground hover:bg-accents-1 rounded flex items-center">
<i data-lucide="sheet" class="w-4 h-4 mr-2 text-green-600"></i> Export Excel
</button>
</div>
</div>
<button onclick="location.reload()" class="btn btn-secondary">
<i data-lucide="refresh-cw" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.refresh">Refresh</span>
</button>
<button onclick="window.print()" class="btn btn-primary">
<i data-lucide="printer" class="w-4 h-4 mr-2"></i> <span data-i18n="reports.print_report">Print Report</span>
</button>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="card bg-accents-1 border-accents-2">
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.total_income">Total Income</div>
<div class="text-3xl font-bold text-green-500 mt-2">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<!-- Stock / Potential -->
<div class="card">
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.generated_stock">Generated Stock</div>
<div class="text-3xl font-bold text-accents-6 mt-2">
<?= \App\Helpers\FormatHelper::formatCurrency($totalIncome, $currency) ?>
</div>
<div class="text-xs text-accents-5 mt-1">
<?= number_format($totalVouchers) ?> vouchers
</div>
</div>
<div class="card bg-accents-1 border-accents-2">
<div class="text-sm text-accents-5 uppercase font-bold tracking-wide" data-i18n="reports.total_vouchers">Total Vouchers Sold</div>
<div class="text-3xl font-bold text-blue-500 mt-2">
<?= number_format($totalVouchers, 0, ',', '.') ?>
<!-- Realized / Actual -->
<div class="card !bg-green-500/10 !border-green-500/20">
<div class="text-sm text-green-600 dark:text-green-400 uppercase font-bold tracking-wide" data-i18n="reports.realized_income">Realized Income</div>
<div class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">
<?= \App\Helpers\FormatHelper::formatCurrency($totalRealizedIncome ?? 0, $currency) ?>
</div>
<div class="text-xs text-green-600/70 dark:text-green-400/70 mt-1">
<?= number_format($totalUsedVouchers ?? 0) ?> used
</div>
</div>
</div>
<div class="space-y-4">
<!-- Filter Bar -->
<div class="flex flex-col md:flex-row gap-4 justify-between items-center no-print">
<!-- Search -->
<div class="relative w-full md:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i data-lucide="search" class="h-4 w-4 text-accents-5"></i>
</div>
<input type="text" id="global-search" class="form-input pl-10 w-full" placeholder="Search date..." data-i18n-placeholder="common.table.search_placeholder">
</div>
</div>
<!-- Detailed Table -->
<div class="table-container">
<table class="table-glass" id="report-table">
<thead>
<table class="table-glass" id="report-table">
<thead>
<tr>
<th data-sort="date" data-i18n="reports.date_batch">Date / Batch (Comment)</th>
<th data-i18n="reports.status">Status</th>
<th class="text-right" data-i18n="reports.qty">Qty (Stock)</th>
<th class="text-right text-green-500" data-i18n="reports.used">Used</th>
<th data-sort="total" class="text-right" data-i18n="reports.total_stock">Total Stock</th>
</tr>
</thead>
<tbody id="table-body">
<?php if (empty($report)): ?>
<tr>
<th data-sort="date" class="sortable cursor-pointer hover:text-foreground select-none" data-i18n="reports.date_batch">Date / Batch (Comment)</th>
<th class="text-right" data-i18n="reports.qty">Qty</th>
<th data-sort="total" class="sortable text-right cursor-pointer hover:text-foreground select-none" data-i18n="reports.total">Total</th>
<td colspan="5" class="p-8 text-center text-accents-5" data-i18n="reports.no_data">No sales data found.</td>
</tr>
</thead>
<tbody id="table-body">
<?php if (empty($report)): ?>
<tr>
<td colspan="3" class="p-8 text-center text-accents-5" data-i18n="reports.no_data">No sales data found.</td>
<?php else: ?>
<?php foreach ($report as $row): ?>
<tr class="table-row-item">
<td class="font-medium">
<?= htmlspecialchars($row['date']) ?>
</td>
<td>
<?php if($row['status'] === 'New'): ?>
<span class="px-2 py-1 text-xs font-bold rounded-md bg-accents-2 text-accents-6">NEW</span>
<?php elseif($row['status'] === 'Selling'): ?>
<span class="px-2 py-1 text-xs font-bold rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/20">SELLING</span>
<?php elseif($row['status'] === 'Sold Out'): ?>
<span class="px-2 py-1 text-xs font-bold rounded-md bg-green-500/10 text-green-500 border border-green-500/20">SOLD OUT</span>
<?php endif; ?>
</td>
<td class="text-right font-mono text-accents-6">
<?= number_format($row['count']) ?>
</td>
<td class="text-right font-mono text-green-500 font-medium">
<?= number_format($row['realized_count']) ?>
<span class="text-xs opacity-70 block">
<?= \App\Helpers\FormatHelper::formatCurrency($row['realized_total'], $currency) ?>
</span>
</td>
<td class="text-right font-mono font-bold text-foreground">
<?= \App\Helpers\FormatHelper::formatCurrency($row['total'], $currency) ?>
</td>
</tr>
<?php else: ?>
<?php foreach ($report as $row): ?>
<tr class="table-row-item"
data-date="<?= strtolower($row['date']) ?>"
data-total="<?= $row['total'] ?>">
<td class="font-medium"><?= htmlspecialchars($row['date']) ?></td>
<td class="text-right font-mono"><?= number_format($row['count']) ?></td>
<td class="text-right font-mono font-bold text-foreground">
<?= \App\Helpers\FormatHelper::formatCurrency($row['total'], $currency) ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="px-6 py-4 border-t border-white/10 flex items-center justify-between no-print" id="pagination-controls">
<div class="text-sm text-accents-5">
Showing <span id="start-idx" class="font-medium text-foreground">0</span> to <span id="end-idx" class="font-medium text-foreground">0</span> of <span id="total-count" class="font-medium text-foreground">0</span> rows
</div>
<div class="flex gap-2">
<button id="prev-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.previous">Previous</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-btn" class="btn btn-sm btn-secondary" disabled data-i18n="common.next">Next</button>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<script src="/assets/js/components/datatable.js"></script>
<!-- Local SheetJS Library -->
<script src="/assets/vendor/xlsx/xlsx.full.min.js"></script>
<script>
class TableManager {
constructor(rows, itemsPerPage = 15) {
this.allRows = Array.from(rows);
this.filteredRows = this.allRows;
this.itemsPerPage = itemsPerPage;
this.currentPage = 1;
this.elements = {
body: document.getElementById('table-body'),
startIdx: document.getElementById('start-idx'),
endIdx: document.getElementById('end-idx'),
totalCount: document.getElementById('total-count'),
prevBtn: document.getElementById('prev-btn'),
nextBtn: document.getElementById('next-btn'),
pageNumbers: document.getElementById('page-numbers')
};
this.filters = { search: '' };
this.init();
}
init() {
// Translate placeholder
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
document.getElementById('global-search').addEventListener('input', (e) => {
this.filters.search = e.target.value.toLowerCase();
this.currentPage = 1;
this.update();
});
this.elements.prevBtn.addEventListener('click', () => { if(this.currentPage > 1) { this.currentPage--; this.render(); } });
this.elements.nextBtn.addEventListener('click', () => {
const max = Math.ceil(this.filteredRows.length / this.itemsPerPage);
if(this.currentPage < max) { this.currentPage++; this.render(); }
});
this.update();
// Listen for language change
window.addEventListener('languageChanged', () => {
const searchInput = document.getElementById('global-search');
if (searchInput && window.i18n) {
searchInput.placeholder = window.i18n.t('common.table.search_placeholder');
}
this.render();
document.addEventListener('DOMContentLoaded', () => {
if (typeof SimpleDataTable !== 'undefined') {
new SimpleDataTable('#report-table', {
itemsPerPage: 15,
searchable: true,
pagination: true,
// Add Filter for Status Column (Index 1)
filters: [
{ index: 1, label: 'Status: All' }
]
});
}
});
update() {
this.filteredRows = this.allRows.filter(row => {
const date = row.dataset.date || '';
if (this.filters.search && !date.includes(this.filters.search)) return false;
return true;
});
this.render();
}
async function exportReport(type) {
const url = '/<?= $session ?>/reports/selling/export/' + type;
const btn = document.querySelector('.dropdown-toggle');
const originalText = btn.innerHTML;
// Show Loading State
btn.innerHTML = `<i data-lucide="loader-2" class="w-4 h-4 mr-2 animate-spin"></i> Processing...`;
lucide.createIcons();
render() {
const total = this.filteredRows.length;
const maxPage = Math.ceil(total / this.itemsPerPage) || 1;
if (this.currentPage > maxPage) this.currentPage = maxPage;
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = Math.min(start + this.itemsPerPage, total);
this.elements.startIdx.textContent = total === 0 ? 0 : start + 1;
this.elements.endIdx.textContent = end;
this.elements.totalCount.textContent = total;
// Update Text (Use Translation)
if (window.i18n && document.getElementById('pagination-controls')) {
const text = window.i18n.t('common.table.showing', {
start: total === 0 ? 0 : start + 1,
end: end,
total: total
});
// Find and update the text node if possible
const container = document.getElementById('pagination-controls').querySelector('.text-accents-5');
if(container) {
container.innerHTML = text.replace('{start}', `<span class="font-medium text-foreground">${total === 0 ? 0 : start + 1}</span>`)
.replace('{end}', `<span class="font-medium text-foreground">${end}</span>`)
.replace('{total}', `<span class="font-medium text-foreground">${total}</span>`);
}
}
this.elements.body.innerHTML = '';
this.filteredRows.slice(start, end).forEach(row => this.elements.body.appendChild(row));
this.elements.prevBtn.disabled = this.currentPage === 1;
this.elements.nextBtn.disabled = this.currentPage === maxPage || total === 0;
try {
const response = await fetch(url);
const data = await response.json();
if (this.elements.pageNumbers) {
const pageText = window.i18n ? window.i18n.t('common.page_of', {current: this.currentPage, total: maxPage}) : `Page ${this.currentPage} of ${maxPage}`;
this.elements.pageNumbers.innerHTML = `<span class="px-3 py-1 text-sm font-medium bg-accents-2 rounded text-accents-6">${pageText}</span>`;
if (data.error) {
alert('Export Failed: ' + data.error);
return;
}
if (typeof lucide !== 'undefined') lucide.createIcons();
const filename = `selling-report-<?= date('Y-m-d') ?>-${type}.` + (type === 'csv' ? 'csv' : 'xlsx');
if (type === 'csv') {
// Convert JSON to CSV
const header = Object.keys(data[0]);
const csv = [
header.join(','), // header row first
...data.map(row => header.map(fieldName => JSON.stringify(row[fieldName])).join(','))
].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.setAttribute('hidden', '');
a.setAttribute('href', url);
a.setAttribute('download', filename);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
else if (type === 'xlsx') {
// Use SheetJS for Real Excel
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Selling Report");
XLSX.writeFile(wb, filename);
}
} catch (error) {
console.error('Export Error:', error);
alert('Failed to export data. Check console for details.');
} finally {
// Restore Button
btn.innerHTML = originalText;
lucide.createIcons();
document.getElementById('export-menu').classList.add('hidden');
}
}
document.addEventListener('DOMContentLoaded', () => {
new TableManager(document.querySelectorAll('.table-row-item'), 15);
});
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -0,0 +1,129 @@
<?php
// Plugins View
$title = "Plugins";
$no_main_container = true;
require_once ROOT . '/app/Views/layouts/header_main.php';
?>
<!-- Sub-Navbar Navigation -->
<?php include ROOT . '/app/Views/layouts/sidebar_settings.php'; ?>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full flex flex-col">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight" data-i18n="settings.plugins">Plugins</h1>
<p class="text-accents-5 mt-2" data-i18n="settings.plugins_desc">Manage and extend functionality with plugins.</p>
</div>
<button onclick="openUploadModal()" class="btn btn-primary">
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
<span data-i18n="settings.upload_plugin">Upload Plugin</span>
</button>
</div>
<!-- Content Area -->
<div class="mt-8 flex-1 min-w-0" id="settings-content-area">
<div class="card overflow-hidden p-0">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="text-xs text-accents-5 uppercase bg-accents-1/50 border-b border-accents-2 font-semibold tracking-wider">
<tr>
<th class="px-6 py-4 w-[250px]" data-i18n="common.name">Name</th>
<th class="px-6 py-4" data-i18n="common.description">Description</th>
<th class="px-6 py-4 w-[100px]" data-i18n="common.version">Version</th>
<th class="px-6 py-4 w-[150px]" data-i18n="common.author">Author</th>
<th class="px-6 py-4 w-[100px] text-right" data-i18n="common.status">Status</th>
<th class="px-6 py-4 w-[100px] text-right" data-i18n="common.actions">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-accents-2">
<?php if(empty($plugins)): ?>
<tr>
<td colspan="6" class="px-6 py-12 text-center text-accents-5">
<div class="flex flex-col items-center gap-3">
<div class="p-3 rounded-full bg-accents-1">
<i data-lucide="package-search" class="w-6 h-6 text-accents-4"></i>
</div>
<span class="font-medium" data-i18n="settings.no_plugins">No plugins installed</span>
<span class="text-xs" data-i18n="settings.no_plugins_desc">Upload a .zip file to get started.</span>
</div>
</td>
</tr>
<?php else: ?>
<?php foreach($plugins as $plugin): ?>
<tr class="group hover:bg-accents-1/30 transition-colors">
<td class="px-6 py-4 font-medium text-foreground">
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded bg-primary/10 flex items-center justify-center text-primary">
<i data-lucide="plug" class="w-4 h-4"></i>
</div>
<div class="flex flex-col">
<span><?= htmlspecialchars($plugin['name']) ?></span>
<span class="text-[10px] text-accents-4 font-normal font-mono"><?= htmlspecialchars($plugin['id']) ?></span>
</div>
</div>
</td>
<td class="px-6 py-4 text-accents-6">
<?= htmlspecialchars($plugin['description']) ?>
</td>
<td class="px-6 py-4 text-accents-6 font-mono text-xs">
<?= htmlspecialchars($plugin['version']) ?>
</td>
<td class="px-6 py-4 text-accents-6">
<?= htmlspecialchars($plugin['author']) ?>
</td>
<td class="px-6 py-4 text-right">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400">
Active
</span>
</td>
<td class="px-6 py-4 text-right">
<form action="/settings/plugins/delete" method="POST" class="inline" onsubmit="event.preventDefault();
const title = window.i18n ? window.i18n.t('settings.delete_plugin') : 'Delete Plugin?';
const msg = window.i18n ? window.i18n.t('settings.delete_plugin_confirm', {name: '<?= htmlspecialchars($plugin['name']) ?>'}) : 'Delete this plugin?';
Mivo.confirm(title, msg, window.i18n ? window.i18n.t('common.delete') : 'Delete', window.i18n ? window.i18n.t('common.cancel') : 'Cancel').then(res => {
if(res) this.submit();
});">
<input type="hidden" name="plugin_id" value="<?= htmlspecialchars($plugin['id']) ?>">
<button type="submit" class="btn-icon-danger" title="Delete">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function openUploadModal() {
const title = window.i18n ? window.i18n.t('settings.upload_plugin') : 'Upload Plugin';
const html = `
<form id="upload-plugin-form" action="/settings/plugins/upload" method="POST" enctype="multipart/form-data" class="space-y-4">
<div class="text-sm text-accents-5">
<p class="mb-4" data-i18n="settings.upload_plugin_desc">Select a plugin .zip file to install.</p>
<input type="file" name="plugin_file" accept=".zip" required class="form-control-file w-full">
</div>
</form>
`;
Mivo.modal.form(title, html, window.i18n ? window.i18n.t('common.install') : 'Install', () => {
const form = document.getElementById('upload-plugin-form');
if (form.reportValidity()) {
form.submit();
return true;
}
return false;
});
}
</script>
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>

View File

@@ -276,12 +276,12 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
'{{timelimit}}': ' 3 Hours',
'{{datalimit}}': '500 MB',
'{{profile}}': 'General',
'{{comment}}': 'mikhmon',
'{{hotspotname}}': 'Mikhmon Hotspot',
'{{comment}}': 'mivo',
'{{hotspotname}}': 'Mivo Hotspot',
'{{num}}': '1',
'{{logo}}': '<img src="/assets/img/logo.png" style="height:30px;border:0;">', // Default placeholder
'{{dns_name}}': 'hotspot.mikhmon',
'{{login_url}}': 'http://hotspot.mikhmon/login',
'{{dns_name}}': 'hotspot.mivo',
'{{login_url}}': 'http://hotspot.mivo/login',
};
function updatePreview() {