mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-04 12:02:33 +02:00
feat(active-codes): Smart Activation Codes system
Pre-generated stock voucher codes that pair with an auto-created line and count down only on the client's first activation. Full admin + reseller management — list, generate wizard, batch manager, mass edit — plus an activation portal and a public activation API. - schema: activation_codes table (migration 019 + database.sql) - domain: ActiveCodeService (generate / activate / mass actions / export / batch summary; view queries live here, not in the templates) - admin + reseller controllers and views; PortalController + activation API - nginx routes for /active_code.php and the active_code API endpoint - wired into auth, users, reseller dispatcher, table/player APIs and navbar Passes phpstan, phpcs, make gates, and the unit suite (524 tests).
This commit is contained in:
@@ -63,6 +63,34 @@ class AuthRepository {
|
||||
return $rCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active code string for Web Player (type 6), if configured and enabled.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getWebPlayerCode(): ?string {
|
||||
foreach (self::getAllCodes(6) as $code) {
|
||||
if (!empty($code['enabled'])) {
|
||||
return (string)$code['code'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active code string for Active Code Portal (type 7), if configured and enabled.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getActiveCodePortalCode(): ?string {
|
||||
foreach (self::getAllCodes(7) as $code) {
|
||||
if (!empty($code['enabled'])) {
|
||||
return (string)$code['code'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate per-code nginx config files from the database and reload nginx.
|
||||
*
|
||||
@@ -95,9 +123,13 @@ class AuthRepository {
|
||||
// NOTE: 'includes/api/admin' and 'includes/api/reseller' are legacy nginx route
|
||||
// identifiers baked into generated access-code configs — NOT filesystem paths.
|
||||
// Do not rename without regenerating all deployed nginx configs.
|
||||
$rType = array('admin', 'reseller', 'ministra', 'includes/api/admin', 'includes/api/reseller', 'ministra/new', 'player')[$rCode['type']];
|
||||
$rAlias = array('Public/Views/admin', 'reseller', 'Ministra', 'includes/api/admin', 'includes/api/reseller', 'Ministra/new', 'Public/assets/player')[$rCode['type']];
|
||||
$rBurst = array(500, 50, 50, 1000, 1000, 50, 500)[$rCode['type']];
|
||||
$rTypeMap = [0 => 'admin', 1 => 'reseller', 2 => 'ministra', 3 => 'includes/api/admin', 4 => 'includes/api/reseller', 5 => 'ministra/new', 6 => 'player', 7 => 'portal'];
|
||||
$rAliasMap = [0 => 'Public/Views/admin', 1 => 'reseller', 2 => 'Ministra', 3 => 'includes/api/admin', 4 => 'includes/api/reseller', 5 => 'Ministra/new', 6 => 'Public/assets/player', 7 => 'Public/Views/portal'];
|
||||
$rBurstMap = [0 => 500, 1 => 50, 2 => 50, 3 => 1000, 4 => 1000, 5 => 50, 6 => 500, 7 => 500];
|
||||
|
||||
$rType = $rTypeMap[(int)$rCode['type']] ?? 'admin';
|
||||
$rAlias = $rAliasMap[(int)$rCode['type']] ?? 'Public/Views/admin';
|
||||
$rBurst = $rBurstMap[(int)$rCode['type']] ?? 500;
|
||||
$rCurrentTemplate = in_array($rType, array('ministra', 'ministra/new')) ? $rMinistraTemplate : $rTemplate;
|
||||
|
||||
if (in_array($rType, array('ministra', 'ministra/new')) || strlen($rCode['code']) >= 4) {
|
||||
|
||||
@@ -65,7 +65,11 @@ class AuthService {
|
||||
$rArray['whitelist'] = '[]';
|
||||
}
|
||||
|
||||
if ($rData['type'] != 2 && strlen($rData['code']) < 8) {
|
||||
if (in_array((int)$rData['type'], [6, 7], true)) {
|
||||
if (strlen($rData['code']) < 3) {
|
||||
return array('status' => STATUS_CODE_LENGTH, 'data' => $rData);
|
||||
}
|
||||
} elseif ($rData['type'] != 2 && strlen($rData['code']) < 8) {
|
||||
return array('status' => STATUS_CODE_LENGTH, 'data' => $rData);
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,23 @@ class CoreNavbarProvider implements NavbarProviderInterface {
|
||||
->parent('users.lines')->url('line_mass')
|
||||
->label('mass_edit_lines')->permissions(['mass_edit_lines'])->order(30));
|
||||
|
||||
// Active Codes
|
||||
NavbarRegistry::add((new NavbarItem('users.active_codes'))
|
||||
->parent('users')->url('#')
|
||||
->label('active_codes')->permissions(['add_user', 'users'])->order(15));
|
||||
NavbarRegistry::add((new NavbarItem('users.active_codes.add'))
|
||||
->parent('users.active_codes')->url('active_code')
|
||||
->label('generate_codes')->permissions(['add_user'])->order(10));
|
||||
NavbarRegistry::add((new NavbarItem('users.active_codes.manage'))
|
||||
->parent('users.active_codes')->url('active_codes')
|
||||
->label('manage_active_codes')->permissions(['users'])->order(20));
|
||||
NavbarRegistry::add((new NavbarItem('users.active_codes.batch'))
|
||||
->parent('users.active_codes')->url('active_codes_batch')
|
||||
->label('batch_manager')->permissions(['users'])->order(25));
|
||||
NavbarRegistry::add((new NavbarItem('users.active_codes.mass'))
|
||||
->parent('users.active_codes')->url('active_codes_mass')
|
||||
->label('mass_edit_active_codes')->permissions(['mass_edit_lines'])->order(30));
|
||||
|
||||
// MAG
|
||||
NavbarRegistry::add((new NavbarItem('users.mag'))
|
||||
->parent('users')->url('#')
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Domain\Line;
|
||||
|
||||
use XcVm\Core\Config\DomainResolver;
|
||||
use XcVm\Domain\User\UserRepository;
|
||||
|
||||
/**
|
||||
* ActiveCodeService — Native Smart Activation Codes System
|
||||
*
|
||||
* Implements delayed activation (stock mode), automated subscriber account pairing,
|
||||
* collision-free cryptographic code generation, transactional credit management,
|
||||
* mass edits, and scratch-card export.
|
||||
*
|
||||
* @package XC_VM_Domain_Line
|
||||
* @author XC_VM Team
|
||||
* @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html
|
||||
*/
|
||||
class ActiveCodeService {
|
||||
use \XcVm\Infrastructure\Database\DatabaseAware;
|
||||
|
||||
/**
|
||||
* Generate unique collision-free code string.
|
||||
*/
|
||||
public static function generateCodeString(int $length = 10, string $type = 'alphanumeric'): string {
|
||||
$db = self::db();
|
||||
$chars = ($type === 'numeric')
|
||||
? '0123456789'
|
||||
: '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // Exclude visually ambiguous characters (0, O, 1, I)
|
||||
|
||||
$charsLen = strlen($chars);
|
||||
do {
|
||||
$code = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $chars[random_int(0, $charsLen - 1)];
|
||||
}
|
||||
$db->query('SELECT `id` FROM `activation_codes` WHERE `activation_code` = ? LIMIT 1;', $code);
|
||||
} while ($db->num_rows() > 0);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique batch name.
|
||||
*/
|
||||
public static function generateBatchName(): string {
|
||||
return 'BATCH-' . date('Ymd') . '-' . strtoupper(substr(bin2hex(random_bytes(3)), 0, 5));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate single or bulk active codes with transaction safety.
|
||||
*
|
||||
* @param array $data Input form parameters
|
||||
* @param array $user Authenticated user
|
||||
* @param bool $isAdmin Is administrator
|
||||
* @return array Result array with status, message, count, and codes
|
||||
*/
|
||||
public static function generateCodes(array $data, array $user, bool $isAdmin): array {
|
||||
$db = self::db();
|
||||
|
||||
$qty = max(1, min(500, intval($data['num_codes'] ?? 1)));
|
||||
$length = max(6, min(24, intval($data['code_length'] ?? 10)));
|
||||
$format = ($data['code_format'] ?? 'alphanumeric') === 'numeric' ? 'numeric' : 'alphanumeric';
|
||||
|
||||
$packageId = intval($data['package_id'] ?? 0);
|
||||
$package = PackageService::getById($packageId);
|
||||
if (!$package) {
|
||||
return ['status' => 'ERROR', 'message' => 'Invalid package selected.'];
|
||||
}
|
||||
|
||||
// Calculate credit cost per code
|
||||
$isTrial = !empty($package['is_trial']) || !empty($data['is_trial']);
|
||||
if ($isTrial) {
|
||||
$costPerCode = floatval($package['trial_credits'] ?? 0);
|
||||
} else {
|
||||
// Check for reseller custom package override
|
||||
$override = json_decode($user['override_packages'] ?? '', true) ?: [];
|
||||
if (isset($override[$packageId]['official_credits']) && strlen((string)$override[$packageId]['official_credits']) > 0) {
|
||||
$costPerCode = floatval($override[$packageId]['official_credits']);
|
||||
} else {
|
||||
$costPerCode = floatval($package['official_credits'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$totalCost = $qty * $costPerCode;
|
||||
|
||||
// Balance check for non-admin
|
||||
if (!$isAdmin) {
|
||||
$currentCredits = floatval($user['credits'] ?? 0);
|
||||
if ($totalCost > $currentCredits) {
|
||||
return [
|
||||
'status' => 'INSUFFICIENT_CREDITS',
|
||||
'message' => "Insufficient balance. Required: {$totalCost} credits, Available: {$currentCredits} credits."
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Target owner for codes
|
||||
$targetOwnerId = $user['id'];
|
||||
if ($isAdmin && !empty($data['created_by'])) {
|
||||
$targetOwnerId = intval($data['created_by']);
|
||||
}
|
||||
|
||||
$batchName = trim($data['batch_name'] ?? '');
|
||||
if (empty($batchName)) {
|
||||
$batchName = self::generateBatchName();
|
||||
}
|
||||
|
||||
// Bouquets determination
|
||||
if (!empty($data['bouquets_selected']) && is_array($data['bouquets_selected'])) {
|
||||
$selectedBouquets = array_map('intval', $data['bouquets_selected']);
|
||||
} else {
|
||||
$selectedBouquets = json_decode((string)($package['bouquets'] ?? '[]'), true) ?: [];
|
||||
}
|
||||
$bouquetsJson = '[' . implode(',', array_map('intval', $selectedBouquets)) . ']';
|
||||
|
||||
$dnsBase = trim($data['dns_base'] ?? '') ?: null;
|
||||
$forcedCountry = trim($data['forced_country'] ?? '') ?: ($package['forced_country'] ?? null);
|
||||
$maxConnections = intval($data['max_connections'] ?? ($package['max_connections'] ?: 1));
|
||||
$isAdult = !empty($data['is_adult']) ? 1 : 0;
|
||||
$outputFormats = $package['output_formats'] ?? '[]';
|
||||
|
||||
$generatedCodes = [];
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
// 1. Deduct reseller credits if non-admin
|
||||
if (!$isAdmin && $totalCost > 0) {
|
||||
$newCredits = floatval($user['credits']) - $totalCost;
|
||||
$db->query('UPDATE `users` SET `credits` = ? WHERE `id` = ?;', $newCredits, $user['id']);
|
||||
|
||||
// Audit logging
|
||||
$db->query(
|
||||
"INSERT INTO `users_credits_logs` (`target_id`, `admin_id`, `amount`, `date`, `reason`) VALUES (?, ?, ?, ?, ?);",
|
||||
$user['id'],
|
||||
$user['id'],
|
||||
-$totalCost,
|
||||
time(),
|
||||
"Generated {$qty} active codes for package: {$package['package_name']} (Batch: {$batchName})"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"INSERT INTO `users_logs` (`owner`, `type`, `action`, `package_id`, `cost`, `credits_after`, `date`, `deleted_info`) VALUES (?, 'active_code', 'generate', ?, ?, ?, ?, ?);",
|
||||
$user['id'],
|
||||
$packageId,
|
||||
$totalCost,
|
||||
$newCredits,
|
||||
time(),
|
||||
json_encode(['qty' => $qty, 'batch_name' => $batchName, 'package' => $package['package_name']])
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Generate subscriber lines & activation codes
|
||||
for ($i = 0; $i < $qty; $i++) {
|
||||
$code = self::generateCodeString($length, $format);
|
||||
|
||||
// Auto-create companion line with frozen countdown (exp_date = NULL)
|
||||
$lineUsername = 'ac_' . strtolower(substr(bin2hex(random_bytes(5)), 0, 9));
|
||||
$linePassword = substr(bin2hex(random_bytes(6)), 0, 10);
|
||||
|
||||
// Ensure username collision-free
|
||||
while (UserRepository::getLineByUsername($lineUsername)) {
|
||||
$lineUsername = 'ac_' . strtolower(substr(bin2hex(random_bytes(5)), 0, 9));
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"INSERT INTO `lines` (
|
||||
`member_id`, `username`, `password`, `exp_date`, `admin_enabled`, `enabled`,
|
||||
`bouquet`, `allowed_outputs`, `max_connections`, `is_restreamer`, `is_trial`,
|
||||
`is_mag`, `is_e2`, `forced_country`, `package_id`, `is_activecode`, `created_at`,
|
||||
`reseller_notes`
|
||||
) VALUES (?, ?, ?, NULL, 1, 1, ?, ?, ?, 0, ?, 0, 0, ?, ?, 1, ?, ?);",
|
||||
$targetOwnerId,
|
||||
$lineUsername,
|
||||
$linePassword,
|
||||
$bouquetsJson,
|
||||
$outputFormats,
|
||||
$maxConnections,
|
||||
$isTrial ? 1 : 0,
|
||||
$forcedCountry,
|
||||
$packageId,
|
||||
time(),
|
||||
"Active Code: {$code} (Batch: {$batchName})"
|
||||
);
|
||||
|
||||
$lineId = (int)$db->last_insert_id();
|
||||
|
||||
// Insert into activation_codes table
|
||||
$db->query(
|
||||
"INSERT INTO `activation_codes` (
|
||||
`activation_code`, `batch_name`, `subscriber_id`, `status`, `created_by`,
|
||||
`package_id`, `bouquets`, `is_adult`, `is_trial`, `purchase_cost`,
|
||||
`dns_base`, `forced_country`, `max_connections`, `created_at`
|
||||
) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
$code,
|
||||
$batchName,
|
||||
$lineId,
|
||||
$targetOwnerId,
|
||||
$packageId,
|
||||
$bouquetsJson,
|
||||
$isAdult,
|
||||
$isTrial ? 1 : 0,
|
||||
$costPerCode,
|
||||
$dnsBase,
|
||||
$forcedCountry,
|
||||
$maxConnections,
|
||||
time()
|
||||
);
|
||||
|
||||
$generatedCodes[] = [
|
||||
'code' => $code,
|
||||
'line_id' => $lineId,
|
||||
'username' => $lineUsername,
|
||||
'password' => $linePassword,
|
||||
'batch_name' => $batchName,
|
||||
];
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
|
||||
return [
|
||||
'status' => 'SUCCESS',
|
||||
'message' => "Successfully generated {$qty} active codes.",
|
||||
'batch_name' => $batchName,
|
||||
'qty' => $qty,
|
||||
'total_cost' => $totalCost,
|
||||
'codes' => $generatedCodes,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$db->rollback();
|
||||
return [
|
||||
'status' => 'ERROR',
|
||||
'message' => 'Failed to generate codes: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a code or verify an existing active code.
|
||||
* Starts the subscription timer countdown on first access (Stock Mode -> Active).
|
||||
*
|
||||
* @param string $code Activation code
|
||||
* @param array $deviceInfo Client device details (mac, device_id, ip, user_agent)
|
||||
* @return array Result with status, line info, M3U playlists, XC credentials
|
||||
*/
|
||||
public static function activateCode(string $code, array $deviceInfo = []): array {
|
||||
$db = self::db();
|
||||
$cleanCode = strtoupper(trim($code));
|
||||
|
||||
$codeRow = self::getByCode($cleanCode);
|
||||
if (!$codeRow) {
|
||||
return ['status' => 'INVALID_CODE', 'message' => 'Invalid or unknown activation code.'];
|
||||
}
|
||||
|
||||
// Revoked or disabled
|
||||
if ($codeRow['status'] == 0) {
|
||||
return ['status' => 'DISABLED', 'message' => 'This activation code has been suspended or revoked.'];
|
||||
}
|
||||
|
||||
$line = UserRepository::getLineById($codeRow['subscriber_id']);
|
||||
if (!$line) {
|
||||
return ['status' => 'LINE_NOT_FOUND', 'message' => 'Underlying subscription line not found.'];
|
||||
}
|
||||
|
||||
$package = PackageService::getById($codeRow['package_id']);
|
||||
$now = time();
|
||||
|
||||
// ─── First-Time Activation (Countdown starts now) ───
|
||||
if ($codeRow['status'] == 1 || empty($codeRow['activated_at'])) {
|
||||
$duration = intval($codeRow['is_trial'] ? ($package['trial_duration'] ?? 1) : ($package['official_duration'] ?? 1));
|
||||
$unit = (string)($codeRow['is_trial'] ? ($package['trial_duration_in'] ?? 'days') : ($package['official_duration_in'] ?? 'months'));
|
||||
|
||||
if (!in_array($unit, ['hours', 'days', 'months', 'years'], true)) {
|
||||
$unit = 'months';
|
||||
}
|
||||
|
||||
$expDate = strtotime("+{$duration} {$unit}", $now);
|
||||
$mac = !empty($deviceInfo['mac']) ? trim($deviceInfo['mac']) : null;
|
||||
$deviceId = !empty($deviceInfo['device_id']) ? trim($deviceInfo['device_id']) : null;
|
||||
$clientIp = $deviceInfo['ip'] ?? ($_SERVER['REMOTE_ADDR'] ?? null);
|
||||
|
||||
// Update activation_codes
|
||||
$db->query(
|
||||
"UPDATE `activation_codes` SET
|
||||
`status` = 2,
|
||||
`activated_at` = ?,
|
||||
`mac` = COALESCE(?, `mac`),
|
||||
`device_id` = COALESCE(?, `device_id`)
|
||||
WHERE `id` = ?;",
|
||||
$now,
|
||||
$mac,
|
||||
$deviceId,
|
||||
$codeRow['id']
|
||||
);
|
||||
|
||||
// Update companion line
|
||||
$db->query(
|
||||
"UPDATE `lines` SET
|
||||
`exp_date` = ?,
|
||||
`last_ip` = ?,
|
||||
`last_activity` = ?
|
||||
WHERE `id` = ?;",
|
||||
$expDate,
|
||||
$clientIp,
|
||||
$now,
|
||||
$line['id']
|
||||
);
|
||||
|
||||
$line['exp_date'] = $expDate;
|
||||
$codeRow['status'] = 2;
|
||||
$codeRow['activated_at'] = $now;
|
||||
} else {
|
||||
// Already activated: check if expired
|
||||
if (!empty($line['exp_date']) && $line['exp_date'] < $now) {
|
||||
return [
|
||||
'status' => 'EXPIRED',
|
||||
'message' => 'Subscription has expired.',
|
||||
'exp_date' => $line['exp_date'],
|
||||
'code' => $cleanCode,
|
||||
];
|
||||
}
|
||||
|
||||
// Check device lock if enforced
|
||||
if (!empty($codeRow['mac']) && !empty($deviceInfo['mac']) && strcasecmp($codeRow['mac'], $deviceInfo['mac']) !== 0) {
|
||||
return ['status' => 'DEVICE_MISMATCH', 'message' => 'Code is locked to another hardware device.'];
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve Portal and M3U URLs
|
||||
$portalHost = DomainResolver::resolve(SERVER_ID);
|
||||
if (!empty($codeRow['dns_base'])) {
|
||||
$portalHost = rtrim($codeRow['dns_base'], '/');
|
||||
}
|
||||
$portalParsed = parse_url($portalHost);
|
||||
$serverDomain = $portalParsed['host'] ?? $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$serverPort = $portalParsed['port'] ?? (isset($_SERVER['SERVER_PORT']) ? (int)$_SERVER['SERVER_PORT'] : 80);
|
||||
|
||||
$m3uHls = "{$portalHost}/get.php?username={$line['username']}&password={$line['password']}&type=m3u_plus&output=hls";
|
||||
$m3uTs = "{$portalHost}/get.php?username={$line['username']}&password={$line['password']}&type=m3u_plus&output=ts";
|
||||
|
||||
$webPlayerUrl = null;
|
||||
$db->query("SELECT `code` FROM `access_codes` WHERE `type` = 6 AND `enabled` = 1 LIMIT 1;");
|
||||
if ($db->num_rows() > 0) {
|
||||
$wpRow = $db->get_row();
|
||||
$webPlayerUrl = "{$portalHost}/{$wpRow['code']}/";
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'SUCCESS',
|
||||
'code' => $cleanCode,
|
||||
'is_new_activation' => ($codeRow['status'] == 2 && ($codeRow['activated_at'] >= ($now - 5))),
|
||||
'package_name' => $package['package_name'] ?? 'Premium IPTV',
|
||||
'exp_date' => (int)$line['exp_date'],
|
||||
'exp_date_formatted' => date('Y-m-d H:i:s', (int)$line['exp_date']),
|
||||
'max_connections' => (int)($line['max_connections'] ?? 1),
|
||||
'line' => $line,
|
||||
'web_player_url' => $webPlayerUrl,
|
||||
'credentials' => [
|
||||
'server' => $serverDomain,
|
||||
'port' => $serverPort,
|
||||
'host' => $portalHost,
|
||||
'username' => $line['username'],
|
||||
'password' => $line['password'],
|
||||
],
|
||||
'playlists' => [
|
||||
'm3u_hls' => $m3uHls,
|
||||
'm3u_ts' => $m3uTs,
|
||||
],
|
||||
'device' => [
|
||||
'mac' => $codeRow['mac'],
|
||||
'device_id' => $codeRow['device_id'],
|
||||
],
|
||||
'code_details' => $codeRow,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up activation code record by code string.
|
||||
*/
|
||||
public static function getByCode(string $code): ?array {
|
||||
$db = self::db();
|
||||
$db->query('SELECT * FROM `activation_codes` WHERE `activation_code` = ? LIMIT 1;', strtoupper(trim($code)));
|
||||
return $db->num_rows() > 0 ? $db->get_row() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up activation code record by ID.
|
||||
*/
|
||||
public static function getById(int $id): ?array {
|
||||
$db = self::db();
|
||||
$db->query('SELECT * FROM `activation_codes` WHERE `id` = ? LIMIT 1;', $id);
|
||||
return $db->num_rows() > 0 ? $db->get_row() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct resellers who have created activation codes (reseller filter).
|
||||
*/
|
||||
public static function getResellersWithCodes(): array {
|
||||
$db = self::db();
|
||||
return $db->fetchAll(
|
||||
'SELECT DISTINCT `users`.`id`, `users`.`username`
|
||||
FROM `activation_codes`
|
||||
INNER JOIN `users` ON `users`.`id` = `activation_codes`.`created_by`
|
||||
ORDER BY `users`.`username` ASC;'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent distinct batch names (batch filter). Pass a list of creator ids to
|
||||
* scope it (reseller view); empty = all batches (admin view).
|
||||
*/
|
||||
public static function getRecentBatchNames(array $createdBy = [], int $limit = 100): array {
|
||||
$db = self::db();
|
||||
$where = '`batch_name` IS NOT NULL';
|
||||
if (!empty($createdBy)) {
|
||||
$where = '`created_by` IN (' . implode(',', array_map('intval', $createdBy)) . ') AND ' . $where;
|
||||
}
|
||||
return $db->fetchAll(
|
||||
'SELECT DISTINCT `batch_name` FROM `activation_codes`
|
||||
WHERE ' . $where . '
|
||||
ORDER BY `created_at` DESC LIMIT ' . (int) $limit . ';'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All resellers with their credit balance, for the creator-assignment
|
||||
* dropdown on the admin generate-codes wizard.
|
||||
*/
|
||||
public static function getResellersForAssignment(): array {
|
||||
$db = self::db();
|
||||
return $db->fetchAll('SELECT `id`, `username`, `credits` FROM `users` ORDER BY `username` ASC;');
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-action / Mass edit engine.
|
||||
*/
|
||||
public static function massAction(string $action, array $codeIds, array $user, bool $isAdmin, array $extra = []): array {
|
||||
$db = self::db();
|
||||
if (empty($codeIds)) {
|
||||
return ['status' => 'ERROR', 'message' => 'No codes selected.'];
|
||||
}
|
||||
|
||||
$cleanIds = array_map('intval', $codeIds);
|
||||
$idList = implode(',', $cleanIds);
|
||||
|
||||
// Security check: restrict non-admins to their report tree
|
||||
$whereScope = '';
|
||||
if (!$isAdmin) {
|
||||
$allowedReports = (array)($user['reports'] ?? [$user['id']]);
|
||||
$reportsList = implode(',', array_map('intval', $allowedReports));
|
||||
$whereScope = " AND `created_by` IN ({$reportsList})";
|
||||
}
|
||||
|
||||
// Fetch targets
|
||||
$codes = $db->fetchAll("SELECT * FROM `activation_codes` WHERE `id` IN ({$idList}) {$whereScope};");
|
||||
if (empty($codes)) {
|
||||
return ['status' => 'ERROR', 'message' => 'No accessible codes found for mass action.'];
|
||||
}
|
||||
|
||||
$targetIds = array_column($codes, 'id');
|
||||
$targetIdList = implode(',', $targetIds);
|
||||
$subscriberIds = array_filter(array_column($codes, 'subscriber_id'));
|
||||
$subIdList = !empty($subscriberIds) ? implode(',', $subscriberIds) : '0';
|
||||
|
||||
switch ($action) {
|
||||
case 'mass_enable':
|
||||
// Set status: 1 if never activated, 2 if activated
|
||||
$db->query("UPDATE `activation_codes` SET `status` = IF(`activated_at` IS NULL, 1, 2) WHERE `id` IN ({$targetIdList});");
|
||||
$db->query("UPDATE `lines` SET `enabled` = 1, `admin_enabled` = 1 WHERE `id` IN ({$subIdList});");
|
||||
return ['status' => 'SUCCESS', 'message' => count($targetIds) . ' codes successfully enabled.'];
|
||||
|
||||
case 'mass_disable':
|
||||
$db->query("UPDATE `activation_codes` SET `status` = 0 WHERE `id` IN ({$targetIdList});");
|
||||
$db->query("UPDATE `lines` SET `enabled` = 0 WHERE `id` IN ({$subIdList});");
|
||||
return ['status' => 'SUCCESS', 'message' => count($targetIds) . ' codes suspended.'];
|
||||
|
||||
case 'mass_extend':
|
||||
$days = max(1, intval($extra['days'] ?? 30));
|
||||
$seconds = $days * 86400;
|
||||
$db->query(
|
||||
"UPDATE `lines` SET `exp_date` = IF(`exp_date` IS NOT NULL AND `exp_date` > UNIX_TIMESTAMP(), `exp_date` + ?, UNIX_TIMESTAMP() + ?) WHERE `id` IN ({$subIdList}) AND `exp_date` IS NOT NULL;",
|
||||
$seconds,
|
||||
$seconds
|
||||
);
|
||||
return ['status' => 'SUCCESS', 'message' => "Extended expiration of selected active codes by {$days} days."];
|
||||
|
||||
case 'mass_reset_device':
|
||||
$db->query("UPDATE `activation_codes` SET `mac` = NULL, `device_id` = NULL WHERE `id` IN ({$targetIdList});");
|
||||
return ['status' => 'SUCCESS', 'message' => 'Hardware/Device lock reset on selected codes.'];
|
||||
|
||||
case 'mass_change_package':
|
||||
$newPackageId = intval($extra['package_id'] ?? 0);
|
||||
$newPackage = PackageService::getById($newPackageId);
|
||||
if (!$newPackage) {
|
||||
return ['status' => 'ERROR', 'message' => 'Invalid package.'];
|
||||
}
|
||||
$newBouquets = $newPackage['bouquets'];
|
||||
$db->query("UPDATE `activation_codes` SET `package_id` = ?, `bouquets` = ? WHERE `id` IN ({$targetIdList});", $newPackageId, $newBouquets);
|
||||
$db->query("UPDATE `lines` SET `package_id` = ?, `bouquet` = ? WHERE `id` IN ({$subIdList});", $newPackageId, $newBouquets);
|
||||
return ['status' => 'SUCCESS', 'message' => 'Updated package on selected codes.'];
|
||||
|
||||
case 'mass_delete':
|
||||
$refund = !empty($extra['refund_credits']) || !empty($extra['refund']);
|
||||
$totalRefunded = 0;
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
if ($refund && !$isAdmin) {
|
||||
// Calculate refund only on stock/unactivated codes (status = 1)
|
||||
foreach ($codes as $c) {
|
||||
if ($c['status'] == 1 && $c['purchase_cost'] > 0 && $c['created_by'] == $user['id']) {
|
||||
$totalRefunded += floatval($c['purchase_cost']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalRefunded > 0) {
|
||||
$db->query("UPDATE `users` SET `credits` = `credits` + ? WHERE `id` = ?;", $totalRefunded, $user['id']);
|
||||
$db->query(
|
||||
"INSERT INTO `users_credits_logs` (`target_id`, `admin_id`, `amount`, `date`, `reason`) VALUES (?, ?, ?, ?, ?);",
|
||||
$user['id'],
|
||||
$user['id'],
|
||||
$totalRefunded,
|
||||
time(),
|
||||
"Refund for deleted unused active codes (" . count($targetIds) . " codes)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$db->query("DELETE FROM `activation_codes` WHERE `id` IN ({$targetIdList});");
|
||||
$db->query("DELETE FROM `lines` WHERE `id` IN ({$subIdList});");
|
||||
$db->commit();
|
||||
|
||||
$msg = count($targetIds) . ' codes deleted successfully.';
|
||||
if ($totalRefunded > 0) {
|
||||
$msg .= " Refunded {$totalRefunded} credits for unused stock.";
|
||||
}
|
||||
return ['status' => 'SUCCESS', 'message' => $msg];
|
||||
} catch (\Throwable $e) {
|
||||
$db->rollback();
|
||||
return ['status' => 'ERROR', 'message' => 'Failed to delete codes: ' . $e->getMessage()];
|
||||
}
|
||||
|
||||
default:
|
||||
return ['status' => 'ERROR', 'message' => 'Unknown mass action.'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grouped summary metrics by batch.
|
||||
*/
|
||||
public static function getBatchSummary(array $user, bool $isAdmin, ?string $batchName = null): array {
|
||||
$db = self::db();
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!$isAdmin) {
|
||||
$allowedReports = (array)($user['reports'] ?? [$user['id']]);
|
||||
$where[] = "`created_by` IN (" . implode(',', array_map('intval', $allowedReports)) . ")";
|
||||
}
|
||||
|
||||
if ($batchName) {
|
||||
$where[] = "`batch_name` = ?";
|
||||
$params[] = $batchName;
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? ('WHERE ' . implode(' AND ', $where)) : '';
|
||||
|
||||
$sql = "SELECT
|
||||
`batch_name`,
|
||||
`created_by`,
|
||||
`package_id`,
|
||||
`is_trial`,
|
||||
MIN(`created_at`) as `created_at`,
|
||||
COUNT(*) as `total_codes`,
|
||||
SUM(CASE WHEN `status` = 1 THEN 1 ELSE 0 END) as `stock_count`,
|
||||
SUM(CASE WHEN `status` = 2 THEN 1 ELSE 0 END) as `active_count`,
|
||||
SUM(CASE WHEN `status` = 0 THEN 1 ELSE 0 END) as `disabled_count`
|
||||
FROM `activation_codes`
|
||||
{$whereClause}
|
||||
GROUP BY `batch_name`, `created_by`, `package_id`, `is_trial`
|
||||
ORDER BY `created_at` DESC;";
|
||||
|
||||
$rows = $db->fetchAll($sql, ...$params);
|
||||
$packages = [];
|
||||
$resellers = [];
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$pkgId = $row['package_id'];
|
||||
if (!isset($packages[$pkgId])) {
|
||||
$p = PackageService::getById($pkgId);
|
||||
$packages[$pkgId] = $p['package_name'] ?? 'Custom Package';
|
||||
}
|
||||
$row['package_name'] = $packages[$pkgId];
|
||||
|
||||
$resellerId = $row['created_by'];
|
||||
if (!isset($resellers[$resellerId])) {
|
||||
$u = UserRepository::getUserById($resellerId);
|
||||
$resellers[$resellerId] = $u['username'] ?? "User #{$resellerId}";
|
||||
}
|
||||
$row['creator_name'] = $resellers[$resellerId];
|
||||
$row['ready_codes'] = $row['stock_count'];
|
||||
$row['active_codes'] = $row['active_count'];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export scratch-card formatted text for printing.
|
||||
*/
|
||||
public static function exportBatchTxt(string $batchName, array $user, bool $isAdmin): string {
|
||||
$db = self::db();
|
||||
$where = ["`batch_name` = ?"];
|
||||
$params = [$batchName];
|
||||
|
||||
if (!$isAdmin) {
|
||||
$allowedReports = (array)($user['reports'] ?? [$user['id']]);
|
||||
$where[] = "`created_by` IN (" . implode(',', array_map('intval', $allowedReports)) . ")";
|
||||
}
|
||||
|
||||
$whereClause = 'WHERE ' . implode(' AND ', $where);
|
||||
$codes = $db->fetchAll("SELECT * FROM `activation_codes` {$whereClause} ORDER BY `id` ASC;", ...$params);
|
||||
|
||||
if (empty($codes)) {
|
||||
return "No codes found for batch {$batchName}.\n";
|
||||
}
|
||||
|
||||
$first = $codes[0];
|
||||
$pkg = PackageService::getById($first['package_id']);
|
||||
$pkgName = $pkg['package_name'] ?? 'IPTV Subscription';
|
||||
$portalUrl = DomainResolver::resolve(SERVER_ID);
|
||||
if (!empty($first['dns_base'])) {
|
||||
$portalUrl = rtrim($first['dns_base'], '/');
|
||||
}
|
||||
|
||||
$out = "========================================================================\n";
|
||||
$out .= " ACTIVATION CODES VOUCHER BATCH \n";
|
||||
$out .= "========================================================================\n";
|
||||
$out .= "Batch Name : {$batchName}\n";
|
||||
$out .= "Package : {$pkgName}\n";
|
||||
$out .= "Generated : " . date('Y-m-d H:i:s', (int)$first['created_at']) . "\n";
|
||||
$out .= "Total Vouchers: " . count($codes) . "\n";
|
||||
$out .= "Activation Portal: {$portalUrl}/portal\n";
|
||||
$out .= "========================================================================\n\n";
|
||||
|
||||
$i = 1;
|
||||
foreach ($codes as $c) {
|
||||
$statusText = ($c['status'] == 1) ? 'READY / UNUSED' : (($c['status'] == 2) ? 'ACTIVE' : 'REVOKED');
|
||||
$out .= "+----------------------------------------------------------------------+\n";
|
||||
$out .= sprintf("| CARD #%03d | CODE: %-25s | %-16s |\n", $i++, $c['activation_code'], $statusText);
|
||||
$out .= sprintf("| Portal: %-42s Max Conn: %-2d |\n", "{$portalUrl}/portal", (int)$c['max_connections']);
|
||||
$out .= "+----------------------------------------------------------------------+\n\n";
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -393,6 +393,22 @@ class UserRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a line by username.
|
||||
*
|
||||
* @param string $rUsername Line username.
|
||||
* @return array|null The line row, or null if not found.
|
||||
*/
|
||||
public static function getLineByUsername($rUsername) {
|
||||
$db = self::db();
|
||||
$db->query('SELECT * FROM `lines` WHERE `username` = ?;', $rUsername);
|
||||
|
||||
if ($db->num_rows() == 1) {
|
||||
return $db->get_row();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a registered (panel) user by id.
|
||||
*
|
||||
@@ -409,6 +425,16 @@ class UserRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for getRegisteredUserById.
|
||||
*
|
||||
* @param int $rID User id.
|
||||
* @return array|null
|
||||
*/
|
||||
public static function getUserById($rID) {
|
||||
return self::getRegisteredUserById($rID);
|
||||
}
|
||||
|
||||
/**
|
||||
* List registered users under an owner.
|
||||
*
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace XcVm\Infrastructure;
|
||||
|
||||
use XcVm\Core\Auth\Authorization;
|
||||
use XcVm\Core\Config\DomainResolver;
|
||||
use XcVm\Core\Config\SettingsManager;
|
||||
use XcVm\Core\Http\RequestManager;
|
||||
use XcVm\Core\Util\ImageUtils;
|
||||
use XcVm\Domain\Device\EnigmaService;
|
||||
use XcVm\Domain\Device\MagService;
|
||||
use XcVm\Domain\Epg\EpgService;
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
use XcVm\Domain\Line\LineService;
|
||||
use XcVm\Domain\Line\PackageService;
|
||||
use XcVm\Domain\Stream\CategoryService;
|
||||
@@ -46,25 +48,78 @@ class ResellerApiDispatcher {
|
||||
public static function dispatch(string $action, ?array $rUserInfo, array $rPermissions): void {
|
||||
global $db;
|
||||
switch ($action) {
|
||||
case 'dashboard': self::handleDashboard($rUserInfo, $rPermissions, $db); break;
|
||||
case 'connections': self::handleConnections($rUserInfo, $rPermissions, $db); break;
|
||||
case 'line': self::handleLine($rUserInfo, $rPermissions, $db); break;
|
||||
case 'line_activity': self::handleLineActivity($rUserInfo, $rPermissions, $db); break;
|
||||
case 'adjust_credits': self::handleAdjustCredits($rUserInfo, $rPermissions, $db); break;
|
||||
case 'reg_user': self::handleRegUser($rUserInfo, $rPermissions, $db); break;
|
||||
case 'ticket': self::handleTicket($rUserInfo, $rPermissions, $db); break;
|
||||
case 'mag': self::handleMag($rUserInfo, $rPermissions, $db); break;
|
||||
case 'enigma': self::handleEnigma($rUserInfo, $rPermissions, $db); break;
|
||||
case 'get_package': self::handleGetPackage($rUserInfo, $rPermissions, $db); break;
|
||||
case 'get_package_trial': self::handleGetPackageTrial($rUserInfo, $rPermissions, $db); break;
|
||||
case 'header_stats': self::handleHeaderStats($rUserInfo, $rPermissions, $db); break;
|
||||
case 'stats': self::handleStats($rUserInfo, $rPermissions, $db); break;
|
||||
case 'userlist': self::handleUserList($rUserInfo, $rPermissions, $db); break;
|
||||
case 'send_event': self::handleSendEvent($rUserInfo, $rPermissions, $db); break;
|
||||
case 'streamlist': self::handleStreamList($rUserInfo, $rPermissions, $db); break;
|
||||
case 'ip_whois': self::handleIpWhois($rUserInfo, $rPermissions, $db); break;
|
||||
case 'get_epg': self::handleGetEpg($rUserInfo, $rPermissions, $db); break;
|
||||
case 'get_programme': self::handleGetProgramme($rUserInfo, $rPermissions, $db); break;
|
||||
case 'dashboard':
|
||||
self::handleDashboard($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'connections':
|
||||
self::handleConnections($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'line':
|
||||
self::handleLine($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'line_activity':
|
||||
self::handleLineActivity($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'adjust_credits':
|
||||
self::handleAdjustCredits($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'reg_user':
|
||||
self::handleRegUser($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'ticket':
|
||||
self::handleTicket($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'mag':
|
||||
self::handleMag($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'enigma':
|
||||
self::handleEnigma($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'get_package':
|
||||
self::handleGetPackage($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'get_package_trial':
|
||||
self::handleGetPackageTrial($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'header_stats':
|
||||
self::handleHeaderStats($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'stats':
|
||||
self::handleStats($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'userlist':
|
||||
self::handleUserList($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'send_event':
|
||||
self::handleSendEvent($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'streamlist':
|
||||
self::handleStreamList($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'ip_whois':
|
||||
self::handleIpWhois($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'get_epg':
|
||||
self::handleGetEpg($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'get_programme':
|
||||
self::handleGetProgramme($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'active_code_details':
|
||||
self::handleActiveCodeDetails($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'active_codes_mass':
|
||||
self::handleActiveCodesMass($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'active_codes_batch_action':
|
||||
self::handleActiveCodesBatchAction($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'active_codes_export_txt':
|
||||
self::handleActiveCodesExportTxt($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
case 'generate_active_codes':
|
||||
self::handleGenerateActiveCodes($rUserInfo, $rPermissions, $db);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,4 +1069,173 @@ class ResellerApiDispatcher {
|
||||
}
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Active Code Details AJAX (modal view)
|
||||
*/
|
||||
private static function handleActiveCodeDetails(?array $rUserInfo, array $rPermissions, $db): void {
|
||||
$codeId = intval(RequestManager::get('id') ?? 0);
|
||||
if (!$codeId) {
|
||||
echo json_encode(['result' => false, 'message' => 'Missing code ID.']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$allowedReports = (array)($rUserInfo['reports'] ?? [$rUserInfo['id']]);
|
||||
$code = $db->fetchOne(
|
||||
"SELECT `activation_codes`.*, `lines`.`username` as `sub_username`, `lines`.`password` as `sub_password`,
|
||||
`lines`.`exp_date` as `sub_exp_date`, `lines`.`max_connections` as `line_max_conn`
|
||||
FROM `activation_codes`
|
||||
LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id`
|
||||
WHERE `activation_codes`.`id` = ? AND `activation_codes`.`created_by` IN (" . implode(',', array_map('intval', $allowedReports)) . ") LIMIT 1;",
|
||||
$codeId
|
||||
);
|
||||
|
||||
if (!$code) {
|
||||
echo json_encode(['result' => false, 'message' => 'Code not found or access denied.']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$package = PackageService::getById((int)$code['package_id']);
|
||||
$portalUrl = rtrim($code['dns_base'] ?: DomainResolver::resolve(SERVER_ID), '/');
|
||||
$portalParsed = parse_url($portalUrl);
|
||||
|
||||
$m3uHls = "{$portalUrl}/get.php?username={$code['sub_username']}&password={$code['sub_password']}&type=m3u_plus&output=hls";
|
||||
$m3uTs = "{$portalUrl}/get.php?username={$code['sub_username']}&password={$code['sub_password']}&type=m3u_plus&output=ts";
|
||||
|
||||
$portalCode = null;
|
||||
$db->query("SELECT `code` FROM `access_codes` WHERE `type` = 7 AND `enabled` = 1 LIMIT 1;");
|
||||
if ($db->num_rows() > 0) {
|
||||
$portalCode = $db->get_row()['code'];
|
||||
}
|
||||
|
||||
$playerCode = null;
|
||||
$db->query("SELECT `code` FROM `access_codes` WHERE `type` = 6 AND `enabled` = 1 LIMIT 1;");
|
||||
if ($db->num_rows() > 0) {
|
||||
$playerCode = $db->get_row()['code'];
|
||||
}
|
||||
|
||||
$subscriberPortalUrl = $portalCode ? "{$portalUrl}/{$portalCode}/" : "{$portalUrl}/portal";
|
||||
$directActivateUrl = "{$subscriberPortalUrl}?code=" . urlencode((string)$code['activation_code']);
|
||||
$webPlayerUrl = $playerCode ? "{$portalUrl}/{$playerCode}/" : null;
|
||||
|
||||
echo json_encode([
|
||||
'result' => true,
|
||||
'data' => [
|
||||
'id' => (int)$code['id'],
|
||||
'code' => $code['activation_code'],
|
||||
'batch_name' => $code['batch_name'],
|
||||
'status' => (int)$code['status'],
|
||||
'status_text' => ($code['status'] == 1) ? 'Ready (Stock)' : (($code['status'] == 2) ? 'Active' : 'Disabled'),
|
||||
'package_name' => $package['package_name'] ?? 'Custom Package',
|
||||
'is_trial' => (bool)$code['is_trial'],
|
||||
'max_connections' => (int)($code['line_max_conn'] ?: $code['max_connections']),
|
||||
'exp_date' => $code['sub_exp_date'] ? date('Y-m-d H:i:s', (int)$code['sub_exp_date']) : 'Frozen (Stock)',
|
||||
'activated_at' => $code['activated_at'] ? date('Y-m-d H:i:s', (int)$code['activated_at']) : 'Never',
|
||||
'created_at' => $code['created_at'] ? date('Y-m-d H:i:s', (int)$code['created_at']) : '-',
|
||||
'mac' => $code['mac'] ?: 'None',
|
||||
'device_id' => $code['device_id'] ?: 'None',
|
||||
'username' => $code['sub_username'],
|
||||
'password' => $code['sub_password'],
|
||||
'server' => $portalParsed['host'] ?? 'localhost',
|
||||
'port' => $portalParsed['port'] ?? (isset($_SERVER['SERVER_PORT']) ? (int)$_SERVER['SERVER_PORT'] : 80),
|
||||
'portal_url' => $portalUrl,
|
||||
'activation_portal_url' => $subscriberPortalUrl,
|
||||
'direct_activate_url' => $directActivateUrl,
|
||||
'web_player_url' => $webPlayerUrl,
|
||||
'm3u_hls' => $m3uHls,
|
||||
'm3u_ts' => $m3uTs,
|
||||
]
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Active Codes Mass Actions AJAX
|
||||
*/
|
||||
private static function handleActiveCodesMass(?array $rUserInfo, array $rPermissions, $db): void {
|
||||
$subAction = trim(RequestManager::get('sub_action') ?? '');
|
||||
$ids = json_decode(RequestManager::get('ids') ?? '[]', true) ?: [];
|
||||
$extra = [
|
||||
'days' => intval(RequestManager::get('days') ?? 30),
|
||||
'package_id' => intval(RequestManager::get('package_id') ?? 0),
|
||||
'refund_credits' => !empty(RequestManager::get('refund_credits')),
|
||||
];
|
||||
|
||||
$res = ActiveCodeService::massAction($subAction, $ids, $rUserInfo, false, $extra);
|
||||
echo json_encode([
|
||||
'result' => ($res['status'] === 'SUCCESS'),
|
||||
'message' => $res['message'] ?? 'Action processed.'
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Batch Action AJAX (Enable, Disable, Delete)
|
||||
*/
|
||||
private static function handleActiveCodesBatchAction(?array $rUserInfo, array $rPermissions, $db): void {
|
||||
$batchName = trim(RequestManager::get('batch_name') ?? '');
|
||||
$subAction = trim(RequestManager::get('sub_action') ?? '');
|
||||
$refund = !empty(RequestManager::get('refund_credits'));
|
||||
|
||||
if (empty($batchName)) {
|
||||
echo json_encode(['result' => false, 'message' => 'Missing batch name.']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$allowedReports = (array)($rUserInfo['reports'] ?? [$rUserInfo['id']]);
|
||||
$codes = $db->fetchAll(
|
||||
"SELECT `id` FROM `activation_codes` WHERE `batch_name` = ? AND `created_by` IN (" . implode(',', array_map('intval', $allowedReports)) . ");",
|
||||
$batchName
|
||||
);
|
||||
|
||||
if (empty($codes)) {
|
||||
echo json_encode(['result' => false, 'message' => 'No codes found for this batch.']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$ids = array_column($codes, 'id');
|
||||
$res = ActiveCodeService::massAction($subAction, $ids, $rUserInfo, false, ['refund_credits' => $refund]);
|
||||
|
||||
echo json_encode([
|
||||
'result' => ($res['status'] === 'SUCCESS'),
|
||||
'message' => $res['message'] ?? 'Batch action processed.'
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Export Scratch Cards TXT
|
||||
*/
|
||||
private static function handleActiveCodesExportTxt(?array $rUserInfo, array $rPermissions, $db): void {
|
||||
$batchName = trim(RequestManager::get('batch_name') ?? '');
|
||||
if (empty($batchName)) {
|
||||
exit('Invalid batch name');
|
||||
}
|
||||
|
||||
$content = ActiveCodeService::exportBatchTxt($batchName, $rUserInfo, false);
|
||||
$filename = preg_replace('/[^A-Za-z0-9_\-]/', '_', $batchName) . '_vouchers.txt';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Content-Length: ' . strlen($content));
|
||||
echo $content;
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX Code Generation
|
||||
*/
|
||||
private static function handleGenerateActiveCodes(?array $rUserInfo, array $rPermissions, $db): void {
|
||||
$data = RequestManager::getAll();
|
||||
$res = ActiveCodeService::generateCodes($data, $rUserInfo, false);
|
||||
|
||||
echo json_encode([
|
||||
'result' => ($res['status'] === 'SUCCESS'),
|
||||
'message' => $res['message'] ?? '',
|
||||
'batch_name' => $res['batch_name'] ?? null,
|
||||
'qty' => $res['qty'] ?? 0,
|
||||
'codes' => $res['codes'] ?? []
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ class ResellerTableRenderer {
|
||||
case 'reg_users':
|
||||
self::handleRegUsers($rReturn, $rIsAPI, $rUserInfo, $rPermissions, $rSettings, $db, $rStart, $rLimit);
|
||||
break;
|
||||
case 'active_codes':
|
||||
self::handleActiveCodes($rReturn, $rIsAPI, $rUserInfo, $rPermissions, $rSettings, $db, $rStart, $rLimit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +128,7 @@ class ResellerTableRenderer {
|
||||
}
|
||||
$rWhere = $rWhereV = array();
|
||||
$rWhere[] = '`lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0';
|
||||
$rWhere[] = '(`lines`.`is_activecode` = 0 OR `lines`.`is_activecode` IS NULL)';
|
||||
$rWhere[] = '`lines`.`member_id` IN (' . implode(',', $rUserInfo['reports']) . ')';
|
||||
if (0 >= strlen(RequestManager::get('search')['value'])) {
|
||||
} else {
|
||||
@@ -1797,8 +1801,141 @@ class ResellerTableRenderer {
|
||||
}
|
||||
}
|
||||
return $rReturn;
|
||||
} else {
|
||||
return $rRow;
|
||||
}
|
||||
return $rRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the reseller "active_codes" table.
|
||||
*/
|
||||
private static function handleActiveCodes(array $rReturn, bool $rIsAPI, array $rUserInfo, array $rPermissions, array $rSettings, $db, int $rStart, int $rLimit): void {
|
||||
$rOrderDirection = (strtolower(RequestManager::get('order')[0]['dir'] ?? '') === 'desc' ? 'desc' : 'asc');
|
||||
$rOrder = [
|
||||
false, // checkbox
|
||||
'`activation_codes`.`activation_code`',
|
||||
'`activation_codes`.`batch_name`',
|
||||
'`activation_codes`.`package_id`',
|
||||
'`activation_codes`.`status`',
|
||||
'`lines`.`exp_date`',
|
||||
'`lines`.`username`',
|
||||
'`activation_codes`.`mac`',
|
||||
'`activation_codes`.`created_at`',
|
||||
false // actions
|
||||
];
|
||||
|
||||
$rOrderRow = (RequestManager::has('order') && strlen(RequestManager::get('order')[0]['column'] ?? '') > 0)
|
||||
? intval(RequestManager::get('order')[0]['column'])
|
||||
: 8;
|
||||
|
||||
$rOrderBy = (isset($rOrder[$rOrderRow]) && $rOrder[$rOrderRow] !== false)
|
||||
? "ORDER BY {$rOrder[$rOrderRow]} {$rOrderDirection}"
|
||||
: "ORDER BY `activation_codes`.`created_at` DESC";
|
||||
|
||||
$rWhere = [];
|
||||
$rWhereV = [];
|
||||
|
||||
// Scoped to reseller and sub-resellers
|
||||
$rWhere[] = '`activation_codes`.`created_by` IN (' . implode(',', array_map('intval', $rUserInfo['reports'])) . ')';
|
||||
|
||||
// Search
|
||||
$searchVal = trim(RequestManager::get('search')['value'] ?? '');
|
||||
if (strlen($searchVal) > 0) {
|
||||
$searchParam = "%{$searchVal}%";
|
||||
$rWhere[] = '(`activation_codes`.`activation_code` LIKE ? OR `activation_codes`.`batch_name` LIKE ? OR `lines`.`username` LIKE ? OR `activation_codes`.`mac` LIKE ?)';
|
||||
$rWhereV[] = $searchParam;
|
||||
$rWhereV[] = $searchParam;
|
||||
$rWhereV[] = $searchParam;
|
||||
$rWhereV[] = $searchParam;
|
||||
}
|
||||
|
||||
// Status filter: 1=Ready/Stock, 2=Active, 3=Expired, 4=Disabled
|
||||
$filter = RequestManager::get('filter');
|
||||
if (strlen((string)$filter) > 0 && $filter != 0) {
|
||||
if ($filter == 1) {
|
||||
$rWhere[] = '`activation_codes`.`status` = 1';
|
||||
} elseif ($filter == 2) {
|
||||
$rWhere[] = '`activation_codes`.`status` = 2 AND (`lines`.`exp_date` IS NULL OR `lines`.`exp_date` > UNIX_TIMESTAMP())';
|
||||
} elseif ($filter == 3) {
|
||||
$rWhere[] = '`activation_codes`.`status` = 2 AND `lines`.`exp_date` IS NOT NULL AND `lines`.`exp_date` <= UNIX_TIMESTAMP()';
|
||||
} elseif ($filter == 4) {
|
||||
$rWhere[] = '`activation_codes`.`status` = 0';
|
||||
}
|
||||
}
|
||||
|
||||
// Batch filter
|
||||
$batchFilter = trim((string)RequestManager::get('batch'));
|
||||
if (strlen($batchFilter) > 0) {
|
||||
$rWhere[] = '`activation_codes`.`batch_name` = ?';
|
||||
$rWhereV[] = $batchFilter;
|
||||
}
|
||||
|
||||
// Package filter
|
||||
$packageFilter = intval(RequestManager::get('package'));
|
||||
if ($packageFilter > 0) {
|
||||
$rWhere[] = '`activation_codes`.`package_id` = ?';
|
||||
$rWhereV[] = $packageFilter;
|
||||
}
|
||||
|
||||
$whereClause = 'WHERE ' . implode(' AND ', $rWhere);
|
||||
|
||||
$countSql = "SELECT COUNT(*) as `total` FROM `activation_codes` LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id` {$whereClause};";
|
||||
$db->query($countSql, ...$rWhereV);
|
||||
$rReturn['recordsTotal'] = $rReturn['recordsFiltered'] = (int)($db->get_row()['total'] ?? 0);
|
||||
|
||||
$sql = "SELECT
|
||||
`activation_codes`.*,
|
||||
`lines`.`username` as `sub_username`,
|
||||
`lines`.`exp_date` as `sub_exp_date`,
|
||||
`lines`.`enabled` as `line_enabled`,
|
||||
`users`.`username` as `creator_username`
|
||||
FROM `activation_codes`
|
||||
LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id`
|
||||
LEFT JOIN `users` ON `users`.`id` = `activation_codes`.`created_by`
|
||||
{$whereClause}
|
||||
{$rOrderBy}
|
||||
LIMIT {$rStart}, {$rLimit};";
|
||||
|
||||
$db->query($sql, ...$rWhereV);
|
||||
$rows = $db->get_rows() ?: [];
|
||||
|
||||
$data = [];
|
||||
$packagesCache = [];
|
||||
$now = time();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$pkgId = (int) $row['package_id'];
|
||||
if (!isset($packagesCache[$pkgId])) {
|
||||
$pkg = PackageService::getById($pkgId);
|
||||
$packagesCache[$pkgId] = $pkg['package_name'] ?? 'Package #' . $pkgId;
|
||||
}
|
||||
|
||||
$status = (int) $row['status'];
|
||||
$expUnix = $row['sub_exp_date'] ? (int) $row['sub_exp_date'] : 0;
|
||||
$expExpired = ($status === 2 && $expUnix && $expUnix < $now);
|
||||
$createdUnix = $row['created_at'] ? (int) $row['created_at'] : 0;
|
||||
|
||||
// Clean, keyed row payload; the Bootstrap 5 view renders every badge /
|
||||
// status / action button client-side. Mirrors the admin active_codes
|
||||
// handler. The subscriber password is intentionally NOT exposed here.
|
||||
$data[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'code' => (string) $row['activation_code'],
|
||||
'batch' => (string) ($row['batch_name'] ?: 'None'),
|
||||
'package_name' => $packagesCache[$pkgId],
|
||||
'is_trial' => !empty($row['is_trial']),
|
||||
'status' => $status,
|
||||
'exp_unix' => $expUnix,
|
||||
'exp_str' => $expUnix ? date('Y-m-d H:i', $expUnix) : '',
|
||||
'exp_expired' => $expExpired,
|
||||
'remaining_days' => ($expUnix && !$expExpired && $status !== 0 && $status !== 1) ? (int) ceil(($expUnix - $now) / 86400) : 0,
|
||||
'sub_username' => $row['sub_username'] !== null ? (string) $row['sub_username'] : null,
|
||||
'mac' => !empty($row['mac']) ? (string) $row['mac'] : null,
|
||||
'created_str' => $createdUnix ? date('Y-m-d H:i', $createdUnix) : '-',
|
||||
];
|
||||
}
|
||||
|
||||
$rReturn['data'] = $data;
|
||||
echo json_encode($rReturn);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Admin;
|
||||
|
||||
use XcVm\Domain\Bouquet\BouquetService;
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
use XcVm\Domain\Line\PackageService;
|
||||
|
||||
/**
|
||||
* ActiveCodeController — Admin Generate Active Codes Wizard
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Admin
|
||||
*/
|
||||
class ActiveCodeController extends BaseAdminController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->requirePermission();
|
||||
$this->setTitle('Generate Active Codes');
|
||||
|
||||
$this->render('active_code', [
|
||||
'rPackages' => PackageService::getAll(1, 'line') ?: [],
|
||||
'rBouquets' => BouquetService::getAll() ?: [],
|
||||
'rResellers' => ActiveCodeService::getResellersForAssignment(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Admin;
|
||||
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
|
||||
/**
|
||||
* ActiveCodesBatchController — Admin Batch Manager
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Admin
|
||||
*/
|
||||
class ActiveCodesBatchController extends BaseAdminController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->requirePermission();
|
||||
$this->setTitle('Active Codes Batch Manager');
|
||||
|
||||
$batches = ActiveCodeService::getBatchSummary([], true);
|
||||
|
||||
$this->render('active_codes_batch', [
|
||||
'batches' => $batches,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Admin;
|
||||
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
use XcVm\Domain\Line\PackageService;
|
||||
|
||||
/**
|
||||
* ActiveCodesController — Admin Active Codes Management
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Admin
|
||||
*/
|
||||
class ActiveCodesController extends BaseAdminController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->requirePermission();
|
||||
$this->setTitle('Active Codes');
|
||||
$this->render('active_codes', [
|
||||
'rPackages' => PackageService::getAll(1, 'line') ?: [],
|
||||
'resellers' => ActiveCodeService::getResellersWithCodes(),
|
||||
'batches' => ActiveCodeService::getRecentBatchNames(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Admin;
|
||||
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
use XcVm\Domain\Line\PackageService;
|
||||
|
||||
/**
|
||||
* ActiveCodesMassController — Admin Mass Edit Active Codes
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Admin
|
||||
*/
|
||||
class ActiveCodesMassController extends BaseAdminController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->requirePermission();
|
||||
$this->setTitle('Mass Edit Active Codes');
|
||||
|
||||
$this->render('active_codes_mass', [
|
||||
'rPackages' => PackageService::getAll(1, 'line') ?: [],
|
||||
'batches' => ActiveCodeService::getRecentBatchNames(),
|
||||
'resellers' => ActiveCodeService::getResellersWithCodes(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Admin\Ajax;
|
||||
|
||||
use XcVm\Core\Auth\AuthRepository;
|
||||
use XcVm\Core\Config\DomainResolver;
|
||||
use XcVm\Core\Http\RequestManager;
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
use XcVm\Domain\Line\PackageService;
|
||||
|
||||
/**
|
||||
* ActiveCodeAjaxController — Admin-ajax controller for Activation Codes.
|
||||
*
|
||||
* Endpoints:
|
||||
* - action=active_code_details
|
||||
* - action=generate_active_codes
|
||||
* - action=active_codes_batch_action
|
||||
* - action=active_codes_export_txt
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Admin
|
||||
*/
|
||||
class ActiveCodeAjaxController extends BaseAjaxController
|
||||
{
|
||||
/**
|
||||
* action=active_code_details — Get full voucher & companion line details for modal.
|
||||
*/
|
||||
public function details(): never
|
||||
{
|
||||
$this->requireXhr();
|
||||
|
||||
global $db;
|
||||
$codeId = intval(RequestManager::get('id') ?? 0);
|
||||
if (!$codeId) {
|
||||
$this->fail(['message' => 'Missing code ID.']);
|
||||
}
|
||||
|
||||
$code = $db->fetchOne(
|
||||
"SELECT `activation_codes`.*, `lines`.`username` as `sub_username`, `lines`.`password` as `sub_password`,
|
||||
`lines`.`exp_date` as `sub_exp_date`, `lines`.`max_connections` as `line_max_conn`
|
||||
FROM `activation_codes`
|
||||
LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id`
|
||||
WHERE `activation_codes`.`id` = ? LIMIT 1;",
|
||||
$codeId
|
||||
);
|
||||
|
||||
if (!$code) {
|
||||
$this->fail(['message' => 'Activation code not found.']);
|
||||
}
|
||||
|
||||
$package = PackageService::getById((int)$code['package_id']);
|
||||
$portalUrl = rtrim($code['dns_base'] ?: DomainResolver::resolve(SERVER_ID), '/');
|
||||
$portalParsed = parse_url($portalUrl);
|
||||
|
||||
$m3uHls = "{$portalUrl}/get.php?username={$code['sub_username']}&password={$code['sub_password']}&type=m3u_plus&output=hls";
|
||||
$m3uTs = "{$portalUrl}/get.php?username={$code['sub_username']}&password={$code['sub_password']}&type=m3u_plus&output=ts";
|
||||
|
||||
$portalCode = AuthRepository::getActiveCodePortalCode();
|
||||
$playerCode = AuthRepository::getWebPlayerCode();
|
||||
|
||||
$subscriberPortalUrl = $portalCode ? "{$portalUrl}/{$portalCode}/" : "{$portalUrl}/portal";
|
||||
$directActivateUrl = "{$subscriberPortalUrl}?code=" . urlencode((string)$code['activation_code']);
|
||||
$webPlayerUrl = $playerCode ? "{$portalUrl}/{$playerCode}/" : null;
|
||||
|
||||
$this->ok([
|
||||
'data' => [
|
||||
'id' => (int)$code['id'],
|
||||
'code' => $code['activation_code'],
|
||||
'batch_name' => $code['batch_name'],
|
||||
'status' => (int)$code['status'],
|
||||
'status_text' => ($code['status'] == 1) ? 'Ready (Stock)' : (($code['status'] == 2) ? 'Active' : 'Disabled'),
|
||||
'package_name' => $package['package_name'] ?? 'Custom Package',
|
||||
'is_trial' => (bool)$code['is_trial'],
|
||||
'max_connections' => (int)($code['line_max_conn'] ?: $code['max_connections']),
|
||||
'exp_date' => $code['sub_exp_date'] ? date('Y-m-d H:i:s', (int)$code['sub_exp_date']) : 'Frozen (Stock)',
|
||||
'activated_at' => $code['activated_at'] ? date('Y-m-d H:i:s', (int)$code['activated_at']) : 'Never',
|
||||
'created_at' => $code['created_at'] ? date('Y-m-d H:i:s', (int)$code['created_at']) : '-',
|
||||
'mac' => $code['mac'] ?: 'None',
|
||||
'device_id' => $code['device_id'] ?: 'None',
|
||||
'username' => $code['sub_username'],
|
||||
'password' => $code['sub_password'],
|
||||
'server' => $portalParsed['host'] ?? 'localhost',
|
||||
'port' => $portalParsed['port'] ?? (isset($_SERVER['SERVER_PORT']) ? (int)$_SERVER['SERVER_PORT'] : 80),
|
||||
'portal_url' => $portalUrl,
|
||||
'activation_portal_url' => $subscriberPortalUrl,
|
||||
'direct_activate_url' => $directActivateUrl,
|
||||
'web_player_url' => $webPlayerUrl,
|
||||
'm3u_hls' => $m3uHls,
|
||||
'm3u_ts' => $m3uTs,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* action=generate_active_codes — Generate active codes batch (Admin).
|
||||
*/
|
||||
public function generate(): never
|
||||
{
|
||||
$this->requireXhr();
|
||||
|
||||
$data = RequestManager::getAll();
|
||||
$user = $GLOBALS['rUserInfo'] ?? [];
|
||||
$res = ActiveCodeService::generateCodes($data, $user, true);
|
||||
|
||||
if ($res['status'] === 'SUCCESS') {
|
||||
$this->ok([
|
||||
'message' => $res['message'] ?? '',
|
||||
'batch_name' => $res['batch_name'] ?? null,
|
||||
'qty' => $res['qty'] ?? 0,
|
||||
'codes' => $res['codes'] ?? []
|
||||
]);
|
||||
}
|
||||
|
||||
$this->fail([
|
||||
'message' => $res['message'] ?? 'Failed to generate codes.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* action=active_codes_batch_action — Batch enable/disable/delete (Admin).
|
||||
*/
|
||||
public function batchAction(): never
|
||||
{
|
||||
$this->requireXhr();
|
||||
|
||||
global $db;
|
||||
$batchName = trim(RequestManager::get('batch_name') ?? '');
|
||||
$subAction = trim(RequestManager::get('sub_action') ?? '');
|
||||
$refund = !empty(RequestManager::get('refund_credits'));
|
||||
|
||||
if (empty($batchName)) {
|
||||
$this->fail(['message' => 'Missing batch name.']);
|
||||
}
|
||||
|
||||
$codes = $db->fetchAll(
|
||||
"SELECT `id` FROM `activation_codes` WHERE `batch_name` = ?;",
|
||||
$batchName
|
||||
);
|
||||
|
||||
if (empty($codes)) {
|
||||
$this->fail(['message' => 'No codes found for this batch.']);
|
||||
}
|
||||
|
||||
$ids = array_column($codes, 'id');
|
||||
$user = $GLOBALS['rUserInfo'] ?? [];
|
||||
$res = ActiveCodeService::massAction($subAction, $ids, $user, true, ['refund_credits' => $refund]);
|
||||
|
||||
if ($res['status'] === 'SUCCESS') {
|
||||
$this->ok(['message' => $res['message'] ?? 'Batch action processed.']);
|
||||
}
|
||||
|
||||
$this->fail(['message' => $res['message'] ?? 'Batch action failed.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* action=active_codes_export_txt — Export physical scratch card vouchers as .txt.
|
||||
*/
|
||||
public function exportTxt(): never
|
||||
{
|
||||
$batchName = trim(RequestManager::get('batch_name') ?? '');
|
||||
if (empty($batchName)) {
|
||||
exit('Invalid batch name');
|
||||
}
|
||||
|
||||
$user = $GLOBALS['rUserInfo'] ?? [];
|
||||
$content = ActiveCodeService::exportBatchTxt($batchName, $user, true);
|
||||
$filename = preg_replace('/[^A-Za-z0-9_\-]/', '_', $batchName) . '_vouchers.txt';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Content-Length: ' . strlen($content));
|
||||
echo $content;
|
||||
exit();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use XcVm\Core\Http\ApiClient;
|
||||
use XcVm\Core\Http\RequestManager;
|
||||
use XcVm\Domain\Device\EnigmaService;
|
||||
use XcVm\Domain\Device\MagService;
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
use XcVm\Domain\Line\LineRepository;
|
||||
use XcVm\Domain\Line\LineService;
|
||||
use XcVm\Domain\Server\ServerRepository;
|
||||
@@ -47,28 +48,24 @@ class MultiAjaxController extends BaseAjaxController {
|
||||
switch ($rType) {
|
||||
case 'line':
|
||||
$this->handleLine($rRequestIDs, $rSub);
|
||||
// no break — handler terminates the request
|
||||
case 'active_code':
|
||||
$this->handleActiveCode($rRequestIDs, $rSub);
|
||||
case 'mag':
|
||||
case 'enigma':
|
||||
$this->handleDevices($rType, $rRequestIDs, $rSub);
|
||||
// no break
|
||||
case 'user':
|
||||
$this->handleUser($rRequestIDs, $rSub);
|
||||
// no break
|
||||
case 'server':
|
||||
case 'proxy':
|
||||
$this->handleServers($rType, $rRequestIDs, $rSub);
|
||||
// no break
|
||||
case 'series':
|
||||
$this->handleSeries($rRequestIDs, $rSub);
|
||||
// no break
|
||||
case 'stream':
|
||||
case 'movie':
|
||||
case 'episode':
|
||||
case 'cchannel':
|
||||
case 'radio':
|
||||
$this->handleStreams($rType, $rRequestIDs, $rSub);
|
||||
// no break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,4 +354,15 @@ class MultiAjaxController extends BaseAjaxController {
|
||||
private function inList(array $rIDs): string {
|
||||
return implode(',', array_map('intval', $rIDs));
|
||||
}
|
||||
|
||||
private function handleActiveCode(array $rRequestIDs, string $rSub): never {
|
||||
$extra = [
|
||||
'days' => intval(RequestManager::get('days') ?? 30),
|
||||
'package_id' => intval(RequestManager::get('package_id') ?? 0),
|
||||
'refund_credits' => !empty(RequestManager::get('refund_credits')),
|
||||
];
|
||||
$res = ActiveCodeService::massAction($rSub, $rRequestIDs, $GLOBALS['rUserInfo'] ?? [], true, $extra);
|
||||
echo json_encode(['result' => ($res['status'] === 'SUCCESS'), 'message' => $res['message'] ?? '']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,9 @@ class TableController extends BaseAdminController {
|
||||
case "lines":
|
||||
$this->handleLines($rReturn, $rStart, $rLimit, $rIsAPI);
|
||||
return;
|
||||
case "active_codes":
|
||||
$this->handleActiveCodes($rReturn, $rStart, $rLimit, $rIsAPI);
|
||||
return;
|
||||
case "mags":
|
||||
$this->handleMags($rReturn, $rStart, $rLimit, $rIsAPI);
|
||||
return;
|
||||
@@ -243,6 +246,150 @@ class TableController extends BaseAdminController {
|
||||
}
|
||||
}
|
||||
|
||||
private function handleActiveCodes($rReturn, $rStart, $rLimit, $rIsAPI) {
|
||||
global $db;
|
||||
if (!Authorization::check("adv", "users") && !Authorization::check("adv", "manage_lines")) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$rOrderDirection = strtolower(RequestManager::get("order")[0]["dir"] ?? '') === "desc" ? "desc" : "asc";
|
||||
$rOrder = [
|
||||
false, // control
|
||||
false, // checkbox
|
||||
'`activation_codes`.`activation_code`',
|
||||
'`activation_codes`.`batch_name`',
|
||||
'`activation_codes`.`package_id`',
|
||||
'`users`.`username`',
|
||||
'`activation_codes`.`status`',
|
||||
'`lines`.`exp_date`',
|
||||
'`lines`.`username`',
|
||||
'`activation_codes`.`mac`',
|
||||
'`activation_codes`.`created_at`',
|
||||
false // actions
|
||||
];
|
||||
|
||||
$rOrderRow = (RequestManager::has("order") && strlen(RequestManager::get("order")[0]["column"] ?? '') > 0)
|
||||
? (int)(RequestManager::get("order")[0]["column"])
|
||||
: 10;
|
||||
|
||||
$rOrderBy = (isset($rOrder[$rOrderRow]) && $rOrder[$rOrderRow] !== false)
|
||||
? "ORDER BY {$rOrder[$rOrderRow]} {$rOrderDirection}"
|
||||
: "ORDER BY `activation_codes`.`created_at` DESC";
|
||||
|
||||
$rWhere = [];
|
||||
$rWhereV = [];
|
||||
|
||||
// Reseller filter
|
||||
$resellerFilter = (int)(RequestManager::get("reseller") ?? 0);
|
||||
if ($resellerFilter > 0) {
|
||||
$rWhere[] = "`activation_codes`.`created_by` = ?";
|
||||
$rWhereV[] = $resellerFilter;
|
||||
}
|
||||
|
||||
// Status filter: 1=Ready/Stock, 2=Active, 3=Expired, 4=Disabled
|
||||
$filter = RequestManager::get("filter");
|
||||
if (strlen((string)$filter) > 0 && $filter != 0) {
|
||||
if ($filter == 1) {
|
||||
$rWhere[] = "`activation_codes`.`status` = 1";
|
||||
} elseif ($filter == 2) {
|
||||
$rWhere[] = "`activation_codes`.`status` = 2 AND (`lines`.`exp_date` IS NULL OR `lines`.`exp_date` > UNIX_TIMESTAMP())";
|
||||
} elseif ($filter == 3) {
|
||||
$rWhere[] = "`activation_codes`.`status` = 2 AND `lines`.`exp_date` IS NOT NULL AND `lines`.`exp_date` <= UNIX_TIMESTAMP()";
|
||||
} elseif ($filter == 4) {
|
||||
$rWhere[] = "`activation_codes`.`status` = 0";
|
||||
}
|
||||
}
|
||||
|
||||
// Batch filter
|
||||
$batchFilter = trim((string)RequestManager::get("batch"));
|
||||
if (strlen($batchFilter) > 0) {
|
||||
$rWhere[] = "`activation_codes`.`batch_name` = ?";
|
||||
$rWhereV[] = $batchFilter;
|
||||
}
|
||||
|
||||
// Package filter
|
||||
$packageFilter = (int)(RequestManager::get("package") ?? 0);
|
||||
if ($packageFilter > 0) {
|
||||
$rWhere[] = "`activation_codes`.`package_id` = ?";
|
||||
$rWhereV[] = $packageFilter;
|
||||
}
|
||||
|
||||
// Search
|
||||
$searchVal = trim(RequestManager::get("search")["value"] ?? '');
|
||||
if (strlen($searchVal) > 0) {
|
||||
$searchParam = "%{$searchVal}%";
|
||||
$rWhere[] = "(`activation_codes`.`activation_code` LIKE ? OR `activation_codes`.`batch_name` LIKE ? OR `lines`.`username` LIKE ? OR `users`.`username` LIKE ? OR `activation_codes`.`mac` LIKE ?)";
|
||||
$rWhereV[] = $searchParam;
|
||||
$rWhereV[] = $searchParam;
|
||||
$rWhereV[] = $searchParam;
|
||||
$rWhereV[] = $searchParam;
|
||||
$rWhereV[] = $searchParam;
|
||||
}
|
||||
|
||||
$whereClause = !empty($rWhere) ? ("WHERE " . implode(" AND ", $rWhere)) : "";
|
||||
|
||||
$countSql = "SELECT COUNT(*) as `total` FROM `activation_codes` LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id` LEFT JOIN `users` ON `users`.`id` = `activation_codes`.`created_by` {$whereClause};";
|
||||
$db->query($countSql, ...$rWhereV);
|
||||
$rReturn["recordsTotal"] = $rReturn["recordsFiltered"] = (int)($db->get_row()["total"] ?? 0);
|
||||
|
||||
$sql = "SELECT
|
||||
`activation_codes`.*,
|
||||
`lines`.`username` as `sub_username`,
|
||||
`lines`.`exp_date` as `sub_exp_date`,
|
||||
`lines`.`enabled` as `line_enabled`,
|
||||
`users`.`username` as `creator_username`
|
||||
FROM `activation_codes`
|
||||
LEFT JOIN `lines` ON `lines`.`id` = `activation_codes`.`subscriber_id`
|
||||
LEFT JOIN `users` ON `users`.`id` = `activation_codes`.`created_by`
|
||||
{$whereClause}
|
||||
{$rOrderBy}
|
||||
LIMIT {$rStart}, {$rLimit};";
|
||||
|
||||
$db->query($sql, ...$rWhereV);
|
||||
$rows = $db->get_rows() ?: [];
|
||||
|
||||
$data = [];
|
||||
$packagesCache = [];
|
||||
$now = time();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$pkgId = (int) $row["package_id"];
|
||||
if (!isset($packagesCache[$pkgId])) {
|
||||
$pkg = PackageService::getById($pkgId);
|
||||
$packagesCache[$pkgId] = $pkg["package_name"] ?? "Package #" . $pkgId;
|
||||
}
|
||||
|
||||
$status = (int) $row["status"];
|
||||
$expUnix = $row["sub_exp_date"] ? (int) $row["sub_exp_date"] : 0;
|
||||
$expExpired = ($status === 2 && $expUnix && $expUnix < $now);
|
||||
$createdUnix = $row["created_at"] ? (int) $row["created_at"] : 0;
|
||||
|
||||
// Clean, keyed row payload; the Bootstrap 5 view renders every badge /
|
||||
// status / action button client-side. Mirrors the reseller active_codes
|
||||
// handler. The subscriber password is intentionally NOT exposed here.
|
||||
$data[] = [
|
||||
"id" => (int) $row["id"],
|
||||
"code" => (string) $row["activation_code"],
|
||||
"batch" => (string) ($row["batch_name"] ?: "None"),
|
||||
"package_name" => $packagesCache[$pkgId],
|
||||
"is_trial" => !empty($row["is_trial"]),
|
||||
"creator" => (string) ($row["creator_username"] ?: "Admin"),
|
||||
"status" => $status,
|
||||
"exp_unix" => $expUnix,
|
||||
"exp_str" => $expUnix ? date("Y-m-d H:i", $expUnix) : "",
|
||||
"exp_expired" => $expExpired,
|
||||
"remaining_days" => ($expUnix && !$expExpired && $status !== 0 && $status !== 1) ? (int) ceil(($expUnix - $now) / 86400) : 0,
|
||||
"sub_username" => $row["sub_username"] !== null ? (string) $row["sub_username"] : null,
|
||||
"mac" => !empty($row["mac"]) ? (string) $row["mac"] : null,
|
||||
"created_str" => $createdUnix ? date("Y-m-d H:i", $createdUnix) : "-",
|
||||
];
|
||||
}
|
||||
|
||||
$rReturn["data"] = $data;
|
||||
echo json_encode($rReturn);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function handleLines($rReturn, $rStart, $rLimit, $rIsAPI) {
|
||||
global $db, $rSettings;
|
||||
if (!Authorization::check("adv", "users") && !Authorization::check("adv", "mass_edit_users")) {
|
||||
@@ -258,6 +405,7 @@ class TableController extends BaseAdminController {
|
||||
}
|
||||
$rWhere = $rWhereV = [];
|
||||
$rWhere[] = "(`is_mag` + `is_e2`) = 0";
|
||||
$rWhere[] = "(`lines`.`is_activecode` = 0 OR `lines`.`is_activecode` IS NULL)";
|
||||
if (0 < strlen(RequestManager::get("search")["value"] ?? '')) {
|
||||
foreach (range(1, 6) as $rInt) {
|
||||
$rWhereV[] = "%" . RequestManager::get("search")["value"] . "%";
|
||||
@@ -769,10 +917,7 @@ class TableController extends BaseAdminController {
|
||||
}
|
||||
$rCategories = CategoryService::getAllByType("live");
|
||||
// Leading false, false = Responsive control + bulk-select checkbox columns (Bootstrap 5).
|
||||
// One entry per column of the streams table (admin/streams.php), in order:
|
||||
// control, select, id, icon, title, server, connections, status, player,
|
||||
// EPG, stream info, usage, actions. false = not sortable in SQL.
|
||||
$rOrder = [false, false, "`streams`.`id`", "`streams`.`stream_icon`", "`streams`.`stream_display_name`", "`streams_servers`.`current_source`", "`clients`", "`streams_servers`.`stream_started`", false, false, false, false, "`streams_servers`.`bitrate`"];
|
||||
$rOrder = [false, false, "`streams`.`id`", "`streams`.`stream_icon`", "`streams`.`stream_display_name`", "`streams_servers`.`current_source`", "`clients`", "`streams_servers`.`stream_started`", false, false, false, "`streams_servers`.`bitrate`"];
|
||||
if (RequestManager::has("order") && 0 < strlen(RequestManager::get("order")[0]["column"] ?? '')) {
|
||||
$rOrderRow = (int) (RequestManager::get("order")[0]["column"] ?? 0);
|
||||
} else {
|
||||
@@ -1145,21 +1290,6 @@ class TableController extends BaseAdminController {
|
||||
$rPlayerVideo = strtoupper((string) ($rVideo["codec_name"] ?? ""));
|
||||
}
|
||||
|
||||
// What the producer costs this node, and which producer it is
|
||||
// (ffmpeg, or the fanout daemon's native remuxer). Sampled
|
||||
// from /proc by cron:streams ON the server that runs the
|
||||
// stream — only it can read its own processes — and carried
|
||||
// here in the progress report it writes anyway.
|
||||
$rUsage = null;
|
||||
$rUsageInfo = json_decode($rRow["progress_info"] ?? '', true);
|
||||
if (is_array($rUsageInfo) && (isset($rUsageInfo["mem"]) || isset($rUsageInfo["producer"]))) {
|
||||
$rUsage = [
|
||||
"cpu" => isset($rUsageInfo["cpu"]) ? (float) $rUsageInfo["cpu"] : null,
|
||||
"mem" => isset($rUsageInfo["mem"]) ? (int) $rUsageInfo["mem"] : null,
|
||||
"producer" => $rUsageInfo["producer"] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// EPG availability + player codec compatibility.
|
||||
$rEPG = file_exists(EPG_PATH . "stream_" . $rRow["id"]) ? "available" : ($rRow["channel_id"] ? "pending" : "none");
|
||||
$rPlayerOk = false;
|
||||
@@ -1194,7 +1324,6 @@ class TableController extends BaseAdminController {
|
||||
"notes" => !empty($rRow["notes"]) ? $rRow["notes"] : null,
|
||||
"player_ok" => $rPlayerOk,
|
||||
"info" => $rInfo,
|
||||
"usage" => $rUsage,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Api;
|
||||
|
||||
use XcVm\Core\Auth\BruteforceGuard;
|
||||
use XcVm\Core\Config\DomainResolver;
|
||||
use XcVm\Core\Http\RequestManager;
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
|
||||
/**
|
||||
* ActiveCodeApiController — Dedicated REST API for Smart Activation Codes
|
||||
*
|
||||
* Provides authentication, first-time delayed countdown activation,
|
||||
* and Xtream Codes compatible response payloads for client applications
|
||||
* (Android TV, Smart TVs, Enigma, STB, Web, and Mobile players).
|
||||
*
|
||||
* Endpoint: /api/active_code or /api/v1/active-code/auth
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Api
|
||||
*/
|
||||
class ActiveCodeApiController extends BaseApiController {
|
||||
|
||||
public function index() {
|
||||
set_time_limit(0);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
|
||||
global $rSettings, $rServers;
|
||||
|
||||
$rawBody = @file_get_contents('php://input');
|
||||
$jsonInput = !empty($rawBody) ? json_decode($rawBody, true) : null;
|
||||
|
||||
$code = trim(
|
||||
RequestManager::get('code')
|
||||
?? RequestManager::get('activation_code')
|
||||
?? RequestManager::get('pin')
|
||||
?? RequestManager::get('c')
|
||||
?? ($jsonInput['code'] ?? $jsonInput['activation_code'] ?? $jsonInput['pin'] ?? '')
|
||||
);
|
||||
|
||||
$mac = trim(
|
||||
RequestManager::get('mac')
|
||||
?? RequestManager::get('mac_address')
|
||||
?? ($jsonInput['mac'] ?? $jsonInput['mac_address'] ?? '')
|
||||
);
|
||||
|
||||
$deviceId = trim(
|
||||
RequestManager::get('device_id')
|
||||
?? RequestManager::get('device')
|
||||
?? ($jsonInput['device_id'] ?? $jsonInput['device'] ?? '')
|
||||
);
|
||||
|
||||
$action = trim(
|
||||
RequestManager::get('action')
|
||||
?? ($jsonInput['action'] ?? 'auth')
|
||||
);
|
||||
|
||||
if (empty($code)) {
|
||||
$this->deny = true;
|
||||
http_response_code(400);
|
||||
echo json_encode([
|
||||
'status' => 'ERROR',
|
||||
'user_info' => ['auth' => 0],
|
||||
'message' => 'Missing activation code. Please provide "code" or "activation_code".'
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
exit();
|
||||
}
|
||||
|
||||
$clientIp = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$deviceInfo = [
|
||||
'mac' => $mac,
|
||||
'device_id' => $deviceId,
|
||||
'ip' => $clientIp,
|
||||
];
|
||||
|
||||
// Process activation / stock-countdown trigger
|
||||
$res = ActiveCodeService::activateCode($code, $deviceInfo);
|
||||
|
||||
if ($res['status'] !== 'SUCCESS') {
|
||||
$this->deny = true;
|
||||
BruteforceGuard::checkBruteforce(null, null, $code);
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'status' => 'ERROR',
|
||||
'error_code' => $res['status'],
|
||||
'user_info' => ['auth' => 0],
|
||||
'message' => $res['message'] ?? 'Activation failed.'
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->deny = false;
|
||||
|
||||
$username = $res['line']['username'] ?? $res['credentials']['username'] ?? '';
|
||||
$password = $res['line']['password'] ?? $res['credentials']['password'] ?? '';
|
||||
$maxConnections = strval($res['line']['max_connections'] ?? $res['max_connections'] ?? '1');
|
||||
$codeDetails = $res['code_details'] ?? [];
|
||||
|
||||
// Build standard server_info
|
||||
$serverId = defined('SERVER_ID') ? SERVER_ID : 1;
|
||||
$domainName = DomainResolver::resolve($serverId);
|
||||
$domain = parse_url($domainName, PHP_URL_HOST) ?: ($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||
$protocol = $rServers[$serverId]['server_protocol'] ?? 'http';
|
||||
$port = strval($rServers[$serverId]['http_broadcast_port'] ?? 80);
|
||||
$httpsPort = strval($rServers[$serverId]['https_broadcast_port'] ?? 443);
|
||||
$rtmpPort = strval($rServers[$serverId]['rtmp_port'] ?? 25462);
|
||||
|
||||
$serverUrl = $protocol . '://' . $domain . ($port != '80' && $port != '443' ? ':' . $port : '');
|
||||
if (!empty($codeDetails['dns_base'])) {
|
||||
$serverUrl = rtrim($codeDetails['dns_base'], '/');
|
||||
}
|
||||
|
||||
$m3uTs = $serverUrl . '/get.php?username=' . urlencode($username) . '&password=' . urlencode($password) . '&type=m3u_plus&output=ts';
|
||||
$m3uHls = $serverUrl . '/get.php?username=' . urlencode($username) . '&password=' . urlencode($password) . '&type=m3u_plus&output=m3u8';
|
||||
|
||||
$output = [
|
||||
'status' => 'SUCCESS',
|
||||
'user_info' => [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'message' => $rSettings['message_of_day'] ?? 'Welcome to IPTV',
|
||||
'auth' => 1,
|
||||
'status' => 'Active',
|
||||
'exp_date' => !empty($res['exp_date']) ? strval($res['exp_date']) : null,
|
||||
'exp_formatted' => $res['exp_formatted'] ?? ($res['exp_date_formatted'] ?? ''),
|
||||
'is_trial' => strval($codeDetails['is_trial'] ?? '0'),
|
||||
'active_cons' => '0',
|
||||
'created_at' => strval($codeDetails['created_at'] ?? time()),
|
||||
'max_connections' => $maxConnections,
|
||||
'allowed_output_formats' => ['m3u8', 'ts', 'rtmp']
|
||||
],
|
||||
'server_info' => [
|
||||
'version' => defined('XC_VM_VERSION') ? XC_VM_VERSION : '1.0.0',
|
||||
'url' => $domain,
|
||||
'port' => $port,
|
||||
'https_port' => $httpsPort,
|
||||
'server_protocol' => $protocol,
|
||||
'rtmp_port' => $rtmpPort,
|
||||
'timestamp_now' => time(),
|
||||
'time_now' => date('Y-m-d H:i:s'),
|
||||
'timezone' => ($rSettings['force_epg_timezone'] ?? false) ? 'UTC' : ($rSettings['default_timezone'] ?? 'UTC'),
|
||||
'process' => true
|
||||
],
|
||||
'active_code' => [
|
||||
'code' => $res['code'],
|
||||
'batch_name' => $codeDetails['batch_name'] ?? '',
|
||||
'package_name' => $res['package_name'] ?? 'Premium IPTV',
|
||||
'status' => 'Active',
|
||||
'is_new_activation' => $res['is_new_activation'] ?? false,
|
||||
],
|
||||
'playlists' => [
|
||||
'm3u_ts' => $m3uTs,
|
||||
'm3u_hls' => $m3uHls,
|
||||
],
|
||||
'player_api_url' => $serverUrl . '/player_api.php?username=' . urlencode($username) . '&password=' . urlencode($password)
|
||||
];
|
||||
|
||||
echo json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use XcVm\Core\Auth\BruteforceGuard;
|
||||
use XcVm\Core\Config\DomainResolver;
|
||||
use XcVm\Core\Util\Encryption;
|
||||
use XcVm\Core\Util\ImageUtils;
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
use XcVm\Domain\Stream\CategoryService;
|
||||
use XcVm\Domain\Stream\StreamSorter;
|
||||
use XcVm\Domain\User\UserRepository;
|
||||
@@ -106,15 +107,39 @@ class PlayerApiController {
|
||||
}
|
||||
$rUserInfo = null;
|
||||
|
||||
if (isset($rRequest['username']) && isset($rRequest['password'])) {
|
||||
if (isset($rRequest['username'])) {
|
||||
$rUsername = $rRequest['username'];
|
||||
$rPassword = $rRequest['password'];
|
||||
$rPassword = $rRequest['password'] ?? '';
|
||||
|
||||
if (empty($rUsername) || empty($rPassword)) {
|
||||
generateError('NO_CREDENTIALS');
|
||||
if (!empty($rUsername) && !empty($rPassword)) {
|
||||
$rUserInfo = UserRepository::getStreamingUserInfo($rSettings, $rCached, $rBouquets, null, $rUsername, $rPassword, $rGetChannels);
|
||||
}
|
||||
|
||||
$rUserInfo = UserRepository::getStreamingUserInfo($rSettings, $rCached, $rBouquets, null, $rUsername, $rPassword, $rGetChannels);
|
||||
// Active Code transparent auto-activation fallback
|
||||
if (!$rUserInfo && !empty($rUsername) && class_exists(ActiveCodeService::class)) {
|
||||
$candidateCode = trim($rUsername);
|
||||
$codeRow = ActiveCodeService::getByCode($candidateCode);
|
||||
if ($codeRow) {
|
||||
$deviceInfo = [
|
||||
'mac' => $rRequest['mac'] ?? '',
|
||||
'device_id' => $rRequest['device_id'] ?? '',
|
||||
'ip' => $rIP
|
||||
];
|
||||
$actRes = ActiveCodeService::activateCode($candidateCode, $deviceInfo);
|
||||
if ($actRes['status'] === 'SUCCESS' && !empty($actRes['line'])) {
|
||||
$rUsername = $actRes['line']['username'];
|
||||
$rPassword = $actRes['line']['password'];
|
||||
$rUserInfo = UserRepository::getStreamingUserInfo($rSettings, false, $rBouquets, null, $rUsername, $rPassword, $rGetChannels);
|
||||
if ($rUserInfo && !empty($actRes['line']['exp_date'])) {
|
||||
$rUserInfo['exp_date'] = $actRes['line']['exp_date'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$rUserInfo && (empty($rUsername) || empty($rPassword))) {
|
||||
generateError('NO_CREDENTIALS');
|
||||
}
|
||||
} else {
|
||||
if (isset($rRequest['token'])) {
|
||||
$rToken = $rRequest['token'];
|
||||
@@ -124,6 +149,27 @@ class PlayerApiController {
|
||||
}
|
||||
|
||||
$rUserInfo = UserRepository::getStreamingUserInfo($rSettings, $rCached, $rBouquets, null, $rToken, null, $rGetChannels);
|
||||
|
||||
if (!$rUserInfo && class_exists(ActiveCodeService::class)) {
|
||||
$candidateCode = trim($rToken);
|
||||
$codeRow = ActiveCodeService::getByCode($candidateCode);
|
||||
if ($codeRow) {
|
||||
$deviceInfo = [
|
||||
'mac' => $rRequest['mac'] ?? '',
|
||||
'device_id' => $rRequest['device_id'] ?? '',
|
||||
'ip' => $rIP
|
||||
];
|
||||
$actRes = ActiveCodeService::activateCode($candidateCode, $deviceInfo);
|
||||
if ($actRes['status'] === 'SUCCESS' && !empty($actRes['line'])) {
|
||||
$rUsername = $actRes['line']['username'];
|
||||
$rPassword = $actRes['line']['password'];
|
||||
$rUserInfo = UserRepository::getStreamingUserInfo($rSettings, false, $rBouquets, null, $rUsername, $rPassword, $rGetChannels);
|
||||
if ($rUserInfo && !empty($actRes['line']['exp_date'])) {
|
||||
$rUserInfo['exp_date'] = $actRes['line']['exp_date'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Player;
|
||||
|
||||
use XcVm\Core\Http\RequestManager;
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
|
||||
/**
|
||||
* PortalController — Public Subscriber Activation Portal
|
||||
*
|
||||
* Provides a responsive activation interface for clients to redeem
|
||||
* their Active Codes, view credentials, and download playlists.
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Player
|
||||
*/
|
||||
class PortalController {
|
||||
public function index() {
|
||||
$action = RequestManager::get('action') ?? '';
|
||||
$code = trim(RequestManager::get('code') ?? '');
|
||||
|
||||
$result = null;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $action === 'activate') {
|
||||
if (!empty($code)) {
|
||||
$deviceInfo = [
|
||||
'mac' => trim(RequestManager::get('mac') ?? ''),
|
||||
'device_id' => trim(RequestManager::get('device_id') ?? ''),
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
];
|
||||
$result = ActiveCodeService::activateCode($code, $deviceInfo);
|
||||
|
||||
// If AJAX request, return JSON
|
||||
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($result);
|
||||
exit();
|
||||
}
|
||||
} else {
|
||||
$result = ['status' => 'ERROR', 'message' => 'Please enter an activation code.'];
|
||||
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($result);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require MAIN_HOME . 'Public/Views/portal/index.php';
|
||||
exit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Reseller;
|
||||
|
||||
use XcVm\Domain\Bouquet\BouquetService;
|
||||
use XcVm\Domain\Line\PackageService;
|
||||
|
||||
/**
|
||||
* ResellerActiveCodeController — Generate Active Codes
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Reseller
|
||||
*/
|
||||
class ResellerActiveCodeController extends BaseResellerController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->requirePermission();
|
||||
$this->setTitle('Generate Active Codes');
|
||||
|
||||
$rUserInfo = $GLOBALS['rUserInfo'] ?? [];
|
||||
$rPackages = PackageService::getAll($rUserInfo['member_group_id'] ?? 0, 'line') ?: [];
|
||||
$rBouquets = BouquetService::getAll() ?: [];
|
||||
|
||||
$this->render('active_code', [
|
||||
'rPackages' => $rPackages,
|
||||
'rBouquets' => $rBouquets,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Reseller;
|
||||
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
|
||||
/**
|
||||
* ResellerActiveCodesBatchController — Batch Manager for Active Codes
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Reseller
|
||||
*/
|
||||
class ResellerActiveCodesBatchController extends BaseResellerController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->requirePermission();
|
||||
$this->setTitle('Batch Manager');
|
||||
|
||||
$rUserInfo = $GLOBALS['rUserInfo'] ?? [];
|
||||
$batches = ActiveCodeService::getBatchSummary($rUserInfo, false);
|
||||
|
||||
$this->render('active_codes_batch', [
|
||||
'batches' => $batches,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Public\Controllers\Reseller;
|
||||
|
||||
use XcVm\Domain\Line\ActiveCodeService;
|
||||
use XcVm\Domain\Line\PackageService;
|
||||
|
||||
/**
|
||||
* ResellerActiveCodesController — Active Codes Inventory
|
||||
*
|
||||
* @package XC_VM_Public_Controllers_Reseller
|
||||
*/
|
||||
class ResellerActiveCodesController extends BaseResellerController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->requirePermission();
|
||||
$this->setTitle('Active Codes');
|
||||
|
||||
$rUserInfo = $GLOBALS['rUserInfo'] ?? [];
|
||||
$allowedReports = (array) ($rUserInfo['reports'] ?? [$rUserInfo['id'] ?? 0]);
|
||||
|
||||
$this->render('active_codes', [
|
||||
'rPackages' => PackageService::getAll($rUserInfo['member_group_id'] ?? 0, 'line') ?: [],
|
||||
'batches' => ActiveCodeService::getRecentBatchNames($allowedReports),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Admin Generate Active Codes (Bootstrap 5)
|
||||
*
|
||||
* Provision active codes for any reseller without credit restrictions.
|
||||
*/
|
||||
|
||||
$rPackages = $rPackages ?? [];
|
||||
$rBouquets = $rBouquets ?? [];
|
||||
$rResellers = $rResellers ?? [];
|
||||
|
||||
?>
|
||||
|
||||
<style>
|
||||
.bq-wrapper {
|
||||
background-color: var(--bs-body-bg, #25293c);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.bq-scroll-area {
|
||||
max-height: 290px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.bq-tile {
|
||||
background-color: var(--bs-card-bg, #2f3349);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.bq-tile:hover {
|
||||
border-color: #7367f0;
|
||||
background-color: rgba(115, 103, 240, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.bq-tile.is-checked {
|
||||
border-color: #7367f0 !important;
|
||||
background-color: rgba(115, 103, 240, 0.12) !important;
|
||||
}
|
||||
.bq-filter-tab.active {
|
||||
background-color: #7367f0 !important;
|
||||
color: #fff !important;
|
||||
border-color: #7367f0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header border-bottom d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="card-title mb-1"><i class="ti tabler-sparkles text-primary me-2"></i>Generate Active Codes (Admin)</h5>
|
||||
<p class="text-muted small mb-0">Admins can provision active vouchers for any reseller or system inventory with 0 credit deduction.</p>
|
||||
</div>
|
||||
<a href="active_codes" class="btn btn-sm btn-label-secondary">
|
||||
<i class="ti tabler-arrow-left me-1"></i>Back to Codes
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<form id="admin-active-code-form">
|
||||
<!-- Reseller Assignment -->
|
||||
<div class="mb-4 p-3 bg-light-subtle rounded-3 border">
|
||||
<label class="form-label fw-semibold" for="created_by">Assign Ownership to Reseller</label>
|
||||
<select id="created_by" name="created_by" class="form-select select2">
|
||||
<option value="1">System Administrator (Admin Inventory)</option>
|
||||
<?php foreach ($rResellers as $res):
|
||||
if ((int)$res['id'] === 1) continue;
|
||||
?>
|
||||
<option value="<?= (int)$res['id']; ?>">
|
||||
<?= htmlspecialchars((string)$res['username'], ENT_QUOTES); ?> (<?= number_format((float)$res['credits'], 2); ?> Credits)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text small">Admin generations are exempt from credit costs. Selected reseller will be designated as the creator.</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Information -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-8">
|
||||
<label class="form-label fw-semibold" for="batch_name">Batch Name / Voucher Label</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="ti tabler-tag"></i></span>
|
||||
<input type="text" id="batch_name" name="batch_name" class="form-control font-monospace" placeholder="BATCH-202609-XXXX" value="BATCH-<?= date('Ymd'); ?>-<?= strtoupper(substr(bin2hex(random_bytes(2)), 0, 4)); ?>">
|
||||
<button type="button" class="btn btn-outline-secondary" id="btn-rand-batch">
|
||||
<i class="ti tabler-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label fw-semibold" for="code_format">Format</label>
|
||||
<select id="code_format" name="code_format" class="form-select">
|
||||
<option value="alphanumeric" selected>Alphanumeric (8X7K9P2M)</option>
|
||||
<option value="numeric">Numeric PIN (87219430)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quantity & Length -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-7">
|
||||
<label class="form-label fw-semibold" for="num_codes">Quantity of Codes</label>
|
||||
<div class="input-group">
|
||||
<input type="number" id="num_codes" name="num_codes" class="form-control fs-5 fw-bold text-center" value="5" min="1" max="500">
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="5">5</button>
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="10">10</button>
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="25">25</button>
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="50">50</button>
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="100">100</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label fw-semibold" for="code_length">Code Length</label>
|
||||
<select id="code_length" name="code_length" class="form-select">
|
||||
<option value="8">8 Characters</option>
|
||||
<option value="10" selected>10 Characters (Standard)</option>
|
||||
<option value="12">12 Characters</option>
|
||||
<option value="16">16 Characters</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Package -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold" for="package_id">Subscription Package <span class="text-danger">*</span></label>
|
||||
<select id="package_id" name="package_id" class="form-select form-select-lg" required>
|
||||
<option value="" disabled selected>-- Select a Package --</option>
|
||||
<?php foreach ($rPackages as $pkg): ?>
|
||||
<option value="<?= (int)$pkg['id']; ?>" data-trial="<?= !empty($pkg['is_trial']) ? 1 : 0; ?>">
|
||||
<?= htmlspecialchars((string)$pkg['package_name'], ENT_QUOTES); ?>
|
||||
<?= !empty($pkg['is_trial']) ? ' - [TRIAL]' : ''; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Bouquets Customization -->
|
||||
<div class="mb-4">
|
||||
<div class="bq-wrapper p-3">
|
||||
<!-- Header Toolbar -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="avatar avatar-xs bg-label-primary rounded-2 p-1 d-flex align-items-center justify-content-center" style="width: 34px; height: 34px;">
|
||||
<i class="ti tabler-category-2 fs-5 text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label fw-bold mb-0 fs-6">Bouquets Included</label>
|
||||
<div class="small text-muted">Select streaming content categories for this voucher</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-label-primary px-3 py-2 fs-6" id="bq-selected-count">
|
||||
<?= count($rBouquets); ?> / <?= count($rBouquets); ?> Selected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar & Action Buttons -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3 pb-2 border-bottom border-secondary border-opacity-10">
|
||||
<!-- Search -->
|
||||
<div class="input-group input-group-sm" style="max-width: 260px;">
|
||||
<span class="input-group-text bg-transparent"><i class="ti tabler-search"></i></span>
|
||||
<input type="text" id="filter-bq-input" class="form-control" placeholder="Search bouquets...">
|
||||
<button type="button" class="btn btn-outline-secondary d-none" id="btn-clear-bq-search">
|
||||
<i class="ti tabler-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<button type="button" class="btn btn-sm btn-label-primary" id="btn-select-all-bq" title="Select all bouquets">
|
||||
<i class="ti tabler-checks me-1"></i>Select All
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-label-secondary" id="btn-deselect-all-bq" title="Deselect all">
|
||||
<i class="ti tabler-x me-1"></i>Clear All
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-label-info" id="btn-invert-bq" title="Invert selection">
|
||||
<i class="ti tabler-arrows-shuffle me-1"></i>Invert
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-label-danger" id="btn-filter-adult" title="Exclude adult 18+ content">
|
||||
<i class="ti tabler-shield-lock me-1"></i>Exclude 18+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs: All | Selected | Unselected | 18+ -->
|
||||
<div class="d-flex flex-wrap gap-1 mb-3">
|
||||
<button type="button" class="btn btn-xs btn-label-secondary bq-filter-tab active" data-filter="all">
|
||||
All (<span class="tab-count-all"><?= count($rBouquets); ?></span>)
|
||||
</button>
|
||||
<button type="button" class="btn btn-xs btn-label-secondary bq-filter-tab" data-filter="selected">
|
||||
Selected (<span class="tab-count-sel"><?= count($rBouquets); ?></span>)
|
||||
</button>
|
||||
<button type="button" class="btn btn-xs btn-label-secondary bq-filter-tab" data-filter="unselected">
|
||||
Unselected (<span class="tab-count-unsel">0</span>)
|
||||
</button>
|
||||
<?php
|
||||
$adultCount = 0;
|
||||
foreach ($rBouquets as $b) {
|
||||
$bn = (string)$b['bouquet_name'];
|
||||
if (stripos($bn, 'adult') !== false || stripos($bn, 'xxx') !== false || stripos($bn, '+18') !== false || stripos($bn, '18+') !== false) {
|
||||
$adultCount++;
|
||||
}
|
||||
}
|
||||
if ($adultCount > 0):
|
||||
?>
|
||||
<button type="button" class="btn btn-xs btn-label-danger bq-filter-tab" data-filter="adult">
|
||||
<i class="ti tabler-lock me-1"></i>18+ Adult (<?= $adultCount; ?>)
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Bouquets Grid with Custom Scroll -->
|
||||
<div class="bq-scroll-area p-1">
|
||||
<div class="row g-2" id="bouquets-grid">
|
||||
<?php foreach ($rBouquets as $bq):
|
||||
$bqName = (string)$bq['bouquet_name'];
|
||||
$isAdult = (stripos($bqName, 'adult') !== false || stripos($bqName, 'xxx') !== false || stripos($bqName, '+18') !== false || stripos($bqName, '18+') !== false);
|
||||
?>
|
||||
<div class="col-12 col-md-6 bouquet-item" data-name="<?= strtolower(htmlspecialchars($bqName, ENT_QUOTES)); ?>" data-adult="<?= $isAdult ? 1 : 0; ?>">
|
||||
<label class="bq-tile is-checked d-flex align-items-center justify-content-between p-2 px-3 w-100 mb-0" for="abq_<?= (int)$bq['id']; ?>">
|
||||
<div class="d-flex align-items-center gap-2 text-truncate me-2">
|
||||
<input class="form-check-input bq-checkbox m-0 flex-shrink-0" type="checkbox" name="bouquets_selected[]" value="<?= (int)$bq['id']; ?>" id="abq_<?= (int)$bq['id']; ?>" checked>
|
||||
<span class="bq-name small fw-semibold text-truncate text-body"><?= htmlspecialchars($bqName, ENT_QUOTES); ?></span>
|
||||
</div>
|
||||
<?php if ($isAdult): ?>
|
||||
<span class="badge bg-label-danger flex-shrink-0 px-2 py-1" style="font-size: 0.65rem;">
|
||||
<i class="ti tabler-lock me-1"></i>18+
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-label-secondary flex-shrink-0 px-2 py-1" style="font-size: 0.65rem;">
|
||||
<i class="ti tabler-device-tv text-primary"></i>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div id="bq-no-results" class="col-12 text-center py-4 text-muted d-none">
|
||||
<i class="ti tabler-folder-off fs-1 mb-1 d-block text-secondary"></i>
|
||||
<div>No bouquets found matching your filter.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Routing & Geo -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold" for="dns_base">Portal Base DNS (Optional)</label>
|
||||
<input type="text" id="dns_base" name="dns_base" class="form-control" placeholder="http://domain.com:port">
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold" for="forced_country">Forced Geo-Lock Country</label>
|
||||
<input type="text" id="forced_country" name="forced_country" class="form-control text-uppercase" maxlength="2" placeholder="e.g. US, EG, SA">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-3 pt-3 border-top">
|
||||
<button type="reset" class="btn btn-label-secondary">Reset</button>
|
||||
<button type="submit" class="btn btn-primary px-4" id="btn-admin-generate">
|
||||
<i class="ti tabler-sparkles me-1"></i>Generate Now
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side Info: Admin Privileges & Live Batch Overview -->
|
||||
<div class="col-12 col-xl-4">
|
||||
<!-- Admin Privileges Card -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header border-bottom py-3 d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="avatar avatar-sm bg-label-warning rounded-2 d-flex align-items-center justify-content-center p-2" style="width: 38px; height: 38px;">
|
||||
<i class="ti tabler-crown fs-4 text-warning"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="card-title mb-0 fw-bold">Admin Privileges Active</h6>
|
||||
<small class="text-muted">Unrestricted System Authority</small>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge bg-label-warning fw-bold px-2 py-1">
|
||||
<i class="ti tabler-shield-check me-1"></i>Root Authority
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<!-- 2x2 Capabilities Matrix -->
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border h-100">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<i class="ti tabler-coins text-warning fs-5"></i>
|
||||
<span class="small text-muted fw-semibold">Credit Cost</span>
|
||||
</div>
|
||||
<div class="fs-6 fw-bold text-success">FREE (0.00)</div>
|
||||
<small class="text-muted d-block" style="font-size: 0.72rem;">No credit deduction</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border h-100">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<i class="ti tabler-hourglass-empty text-primary fs-5"></i>
|
||||
<span class="small text-muted fw-semibold">Delayed Timer</span>
|
||||
</div>
|
||||
<div class="fs-6 fw-bold text-primary">On 1st Stream</div>
|
||||
<small class="text-muted d-block" style="font-size: 0.72rem;">Starts upon activation</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border h-100">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<i class="ti tabler-users text-info fs-5"></i>
|
||||
<span class="small text-muted fw-semibold">Assignment</span>
|
||||
</div>
|
||||
<div class="fs-6 fw-bold text-info">Any Reseller</div>
|
||||
<small class="text-muted d-block" style="font-size: 0.72rem;">Direct stock transfer</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border h-100">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<i class="ti tabler-shield-check text-success fs-5"></i>
|
||||
<span class="small text-muted fw-semibold">Stock Protection</span>
|
||||
</div>
|
||||
<div class="fs-6 fw-bold text-success">Protected</div>
|
||||
<small class="text-muted d-block" style="font-size: 0.72rem;">Never expires in stock</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Generation Batch Overview -->
|
||||
<div class="p-3 rounded-3 border mb-3" style="background: rgba(115, 103, 240, 0.05); border-color: rgba(115, 103, 240, 0.25) !important;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2 pb-2 border-bottom border-secondary border-opacity-10">
|
||||
<span class="small fw-semibold text-uppercase d-flex align-items-center" style="color: #7367f0;">
|
||||
<i class="ti tabler-eye me-1 fs-5"></i>Live Batch Preview
|
||||
</span>
|
||||
<span class="badge bg-label-primary fs-6 px-2 py-1" id="preview-qty-pill">5 Vouchers</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center py-1 small">
|
||||
<span class="text-muted">Target Owner:</span>
|
||||
<span class="fw-semibold text-body text-truncate ms-2" id="preview-owner-name">System Administrator</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center py-1 small">
|
||||
<span class="text-muted">Target Package:</span>
|
||||
<span class="fw-semibold text-body text-truncate ms-2" id="preview-package-name">-- Not Selected --</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center py-1 small">
|
||||
<span class="text-muted">Code Format:</span>
|
||||
<span class="font-monospace text-body" id="preview-format-val">Alphanumeric (10 chars)</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center pt-2 mt-1 border-top border-secondary border-opacity-10 small">
|
||||
<span class="text-muted">Total Admin Cost:</span>
|
||||
<span class="fw-bold text-success fs-6">0.00 Credits (Exempt)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guidance Note -->
|
||||
<div class="d-flex align-items-start gap-2 p-2 px-3 rounded-2 bg-light-subtle border small text-muted">
|
||||
<i class="ti tabler-info-circle text-primary fs-5 mt-1 flex-shrink-0"></i>
|
||||
<div style="font-size: 0.82rem; line-height: 1.45;">
|
||||
Admin vouchers bypass all balance checks. Created codes remain in <strong>Ready (Stock)</strong> status indefinitely until redeemed by end-users in the Activation Portal.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Card -->
|
||||
<div id="admin-results-card" class="card shadow-sm border-0 d-none mt-4">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center py-3">
|
||||
<h5 class="text-white mb-0"><i class="ti tabler-circle-check me-2"></i>Active Codes Generated!</h5>
|
||||
<button type="button" class="btn btn-sm btn-light" id="btn-copy-all-admin">
|
||||
<i class="ti tabler-copy me-1"></i>Copy All Codes
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="alert alert-primary d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<strong>Batch:</strong> <span class="font-monospace fw-bold" id="admin-res-batch"></span>
|
||||
<span class="mx-2">•</span>
|
||||
<strong>Count:</strong> <span class="fw-bold" id="admin-res-count"></span>
|
||||
</div>
|
||||
<a href="active_codes_batch" class="btn btn-sm btn-primary">Open Batch Manager</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Activation Code</th>
|
||||
<th>Username</th>
|
||||
<th>Password</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-res-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
require_once __DIR__ . '/../layouts/footer.php';
|
||||
renderUnifiedLayoutFooter('admin');
|
||||
?>
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
$(function() {
|
||||
let generatedAdminCodes = [];
|
||||
|
||||
function updateLivePreview() {
|
||||
const qty = jQuery('#num_codes').val() || 1;
|
||||
jQuery('#preview-qty-pill').text(`${qty} Voucher${qty > 1 ? 's' : ''}`);
|
||||
|
||||
const ownerText = jQuery('#created_by option:selected').text().split('(')[0].trim();
|
||||
jQuery('#preview-owner-name').text(ownerText || 'System Administrator');
|
||||
|
||||
const pkgText = jQuery('#package_id option:selected').text().trim();
|
||||
jQuery('#preview-package-name').text(pkgText && !pkgText.startsWith('--') ? pkgText : '-- Not Selected --');
|
||||
|
||||
const format = jQuery('#code_format').val();
|
||||
const length = jQuery('#code_length').val() || 10;
|
||||
const fmtTitle = format === 'numeric' ? 'Numeric PIN' : 'Alphanumeric';
|
||||
jQuery('#preview-format-val').text(`${fmtTitle} (${length} chars)`);
|
||||
}
|
||||
|
||||
jQuery('#num_codes, #created_by, #package_id, #code_format, #code_length').on('change input', updateLivePreview);
|
||||
|
||||
jQuery('.qty-pill').on('click', function() {
|
||||
jQuery('#num_codes').val(jQuery(this).data('qty'));
|
||||
updateLivePreview();
|
||||
});
|
||||
|
||||
updateLivePreview();
|
||||
|
||||
jQuery('#btn-rand-batch').on('click', function() {
|
||||
const rand = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
const dateStr = new Date().toISOString().slice(0,10).replace(/-/g,"");
|
||||
jQuery('#batch_name').val(`BATCH-${dateStr}-${rand}`);
|
||||
});
|
||||
|
||||
// Bouquets Selection & Interactive Filtering
|
||||
function updateBouquetCounts() {
|
||||
const total = jQuery('.bq-checkbox').length;
|
||||
const selected = jQuery('.bq-checkbox:checked').length;
|
||||
const unselected = total - selected;
|
||||
|
||||
jQuery('#bq-selected-count').text(`${selected} / ${total} Selected`);
|
||||
jQuery('.tab-count-all').text(total);
|
||||
jQuery('.tab-count-sel').text(selected);
|
||||
jQuery('.tab-count-unsel').text(unselected);
|
||||
|
||||
// Update tile styles
|
||||
jQuery('.bq-checkbox').each(function() {
|
||||
const tile = jQuery(this).closest('.bq-tile');
|
||||
if (this.checked) {
|
||||
tile.addClass('is-checked');
|
||||
} else {
|
||||
tile.removeClass('is-checked');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle tile state on change
|
||||
jQuery(document).on('change', '.bq-checkbox', function() {
|
||||
updateBouquetCounts();
|
||||
applyBouquetFilter();
|
||||
});
|
||||
|
||||
// Select All
|
||||
jQuery('#btn-select-all-bq').on('click', function() {
|
||||
jQuery('.bouquet-item:visible .bq-checkbox').prop('checked', true);
|
||||
updateBouquetCounts();
|
||||
});
|
||||
|
||||
// Clear All
|
||||
jQuery('#btn-deselect-all-bq').on('click', function() {
|
||||
jQuery('.bouquet-item:visible .bq-checkbox').prop('checked', false);
|
||||
updateBouquetCounts();
|
||||
});
|
||||
|
||||
// Invert Selection
|
||||
jQuery('#btn-invert-bq').on('click', function() {
|
||||
jQuery('.bouquet-item:visible .bq-checkbox').each(function() {
|
||||
this.checked = !this.checked;
|
||||
});
|
||||
updateBouquetCounts();
|
||||
});
|
||||
|
||||
// Exclude Adult
|
||||
jQuery('#btn-filter-adult').on('click', function() {
|
||||
jQuery('.bouquet-item[data-adult="1"] .bq-checkbox').prop('checked', false);
|
||||
updateBouquetCounts();
|
||||
});
|
||||
|
||||
// Tab Filter & Search Combined Function
|
||||
let currentBqFilter = 'all';
|
||||
function applyBouquetFilter() {
|
||||
const term = (jQuery('#filter-bq-input').val() || '').toLowerCase().trim();
|
||||
let visibleCount = 0;
|
||||
|
||||
jQuery('.bouquet-item').each(function() {
|
||||
const item = jQuery(this);
|
||||
const name = item.data('name') || '';
|
||||
const isAdult = item.data('adult') == 1;
|
||||
const isChecked = item.find('.bq-checkbox').is(':checked');
|
||||
|
||||
const matchesSearch = !term || name.includes(term);
|
||||
let matchesTab = true;
|
||||
|
||||
if (currentBqFilter === 'selected') {
|
||||
matchesTab = isChecked;
|
||||
} else if (currentBqFilter === 'unselected') {
|
||||
matchesTab = !isChecked;
|
||||
} else if (currentBqFilter === 'adult') {
|
||||
matchesTab = isAdult;
|
||||
}
|
||||
|
||||
const isVisible = matchesSearch && matchesTab;
|
||||
item.toggle(isVisible);
|
||||
if (isVisible) visibleCount++;
|
||||
});
|
||||
|
||||
jQuery('#bq-no-results').toggleClass('d-none', visibleCount > 0);
|
||||
}
|
||||
|
||||
jQuery('.bq-filter-tab').on('click', function() {
|
||||
jQuery('.bq-filter-tab').removeClass('active');
|
||||
jQuery(this).addClass('active');
|
||||
currentBqFilter = jQuery(this).data('filter');
|
||||
applyBouquetFilter();
|
||||
});
|
||||
|
||||
jQuery('#filter-bq-input').on('input keyup', function() {
|
||||
const hasText = (this.value.trim().length > 0);
|
||||
jQuery('#btn-clear-bq-search').toggleClass('d-none', !hasText);
|
||||
applyBouquetFilter();
|
||||
});
|
||||
|
||||
jQuery('#btn-clear-bq-search').on('click', function() {
|
||||
jQuery('#filter-bq-input').val('');
|
||||
jQuery(this).addClass('d-none');
|
||||
applyBouquetFilter();
|
||||
jQuery('#filter-bq-input').focus();
|
||||
});
|
||||
|
||||
updateBouquetCounts();
|
||||
|
||||
jQuery('#admin-active-code-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const btn = jQuery('#btn-admin-generate');
|
||||
const orig = btn.html();
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Generating...');
|
||||
|
||||
const formData = jQuery(this).serializeArray();
|
||||
const postData = { action: 'generate_active_codes' };
|
||||
formData.forEach(item => {
|
||||
if (item.name.endsWith('[]')) {
|
||||
const key = item.name.slice(0, -2);
|
||||
if (!postData[key]) postData[key] = [];
|
||||
postData[key].push(item.value);
|
||||
} else {
|
||||
postData[item.name] = item.value;
|
||||
}
|
||||
});
|
||||
|
||||
jQuery.post('./api', postData, function(res) {
|
||||
btn.prop('disabled', false).html(orig);
|
||||
let data = res;
|
||||
if (typeof res === 'string') {
|
||||
try { data = JSON.parse(res); } catch(e) {}
|
||||
}
|
||||
|
||||
if (!data.result) {
|
||||
alert(data.message || 'Generation failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
generatedAdminCodes = data.codes || [];
|
||||
jQuery('#admin-res-batch').text(data.batch_name);
|
||||
jQuery('#admin-res-count').text(data.qty);
|
||||
|
||||
let html = '';
|
||||
generatedAdminCodes.forEach((c, idx) => {
|
||||
html += `
|
||||
<tr>
|
||||
<td>${idx + 1}</td>
|
||||
<td><code class="fw-bold font-monospace text-primary fs-6">${c.code}</code></td>
|
||||
<td class="font-monospace">${c.username}</td>
|
||||
<td class="font-monospace">${c.password}</td>
|
||||
<td><span class="badge bg-label-success badge-pulse">Ready (Stock)</span></td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
jQuery('#admin-res-table').html(html);
|
||||
jQuery('#admin-results-card').removeClass('d-none');
|
||||
document.getElementById('admin-results-card').scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = String(text);
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
textarea.setAttribute('readonly', '');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
success ? resolve() : reject();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
jQuery('#btn-copy-all-admin').on('click', function() {
|
||||
if (!generatedAdminCodes.length) return;
|
||||
const text = generatedAdminCodes.map(c => c.code).join("\n");
|
||||
const btn = jQuery(this);
|
||||
const orig = btn.html();
|
||||
copyToClipboard(text).then(() => {
|
||||
btn.html('<i class="ti tabler-check me-1"></i>Copied!');
|
||||
setTimeout(() => btn.html(orig), 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy codes:', text);
|
||||
});
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Admin Active Codes Management (Bootstrap 5)
|
||||
*
|
||||
* Full system overview of Smart Activation Codes across all resellers.
|
||||
* Features reseller filter, batch filter, package filter, status filter,
|
||||
* floating bulk operations toolbar, and voucher details modal.
|
||||
*/
|
||||
|
||||
?>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<h5 class="card-title mb-1"><i class="ti tabler-key text-primary me-2"></i>Active Codes Management</h5>
|
||||
<p class="text-muted small mb-0">System-wide active codes inventory across all resellers with delayed countdown lifecycle.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="active_codes_batch" class="btn btn-label-secondary">
|
||||
<i class="ti tabler-folders me-1"></i>Batch Manager
|
||||
</a>
|
||||
<a href="active_codes_mass" class="btn btn-label-info">
|
||||
<i class="ti tabler-adjustments me-1"></i>Mass Edit
|
||||
</a>
|
||||
<a href="active_code" class="btn btn-primary">
|
||||
<i class="ti tabler-plus me-1"></i>Generate Codes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="card-body border-bottom bg-light-subtle">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label small text-uppercase fw-semibold" for="filter-search">Search</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="ti tabler-search"></i></span>
|
||||
<input type="text" id="filter-search" class="form-control" placeholder="Code, batch, user, MAC...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small text-uppercase fw-semibold" for="filter-reseller">Reseller</label>
|
||||
<select id="filter-reseller" class="form-select">
|
||||
<option value="0">All Resellers</option>
|
||||
<?php foreach ($resellers as $res): ?>
|
||||
<option value="<?= (int)$res['id']; ?>">
|
||||
<?= htmlspecialchars((string)$res['username'], ENT_QUOTES); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small text-uppercase fw-semibold" for="filter-status">Status</label>
|
||||
<select id="filter-status" class="form-select">
|
||||
<option value="0">All Statuses</option>
|
||||
<option value="1">Ready (Stock / Unused)</option>
|
||||
<option value="2">Active (Streaming)</option>
|
||||
<option value="3">Expired</option>
|
||||
<option value="4">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-uppercase fw-semibold" for="filter-batch">Batch</label>
|
||||
<select id="filter-batch" class="form-select">
|
||||
<option value="">All Batches</option>
|
||||
<?php foreach ($batches as $b): ?>
|
||||
<option value="<?= htmlspecialchars((string)$b['batch_name'], ENT_QUOTES); ?>">
|
||||
<?= htmlspecialchars((string)$b['batch_name'], ENT_QUOTES); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<label class="form-label small text-uppercase fw-semibold" for="filter-package">Package</label>
|
||||
<select id="filter-package" class="form-select">
|
||||
<option value="0">All Packages</option>
|
||||
<?php foreach ($rPackages as $pkg): ?>
|
||||
<option value="<?= (int)$pkg['id']; ?>">
|
||||
<?= htmlspecialchars((string)$pkg['package_name'], ENT_QUOTES); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DataTable Container -->
|
||||
<div class="card-datatable table-responsive">
|
||||
<table id="admin-active-codes-table" class="table table-hover border-top">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="control" style="width: 20px;"></th>
|
||||
<th style="width: 35px;">
|
||||
<input type="checkbox" id="select-all" class="form-check-input">
|
||||
</th>
|
||||
<th>Code</th>
|
||||
<th>Batch</th>
|
||||
<th>Package</th>
|
||||
<th>Owner / Reseller</th>
|
||||
<th>Status</th>
|
||||
<th>Expiration</th>
|
||||
<th>Subscriber</th>
|
||||
<th>Device Lock</th>
|
||||
<th>Created</th>
|
||||
<th class="text-center" style="width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Mass Actions Bar -->
|
||||
<div id="mass-action-bar" class="position-fixed bottom-0 start-50 translate-middle-x p-3 bg-dark text-white rounded-4 shadow-lg d-none align-items-center gap-3" style="z-index: 1080; min-width: 480px; max-width: 90%;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-primary fs-6 px-2 py-1" id="selected-count">0</span>
|
||||
<span class="small fw-semibold">Codes Selected</span>
|
||||
</div>
|
||||
<div class="vr bg-secondary opacity-50 my-1"></div>
|
||||
<div class="d-flex flex-wrap gap-2 ms-auto">
|
||||
<button type="button" class="btn btn-sm btn-success" id="btn-mass-enable">
|
||||
<i class="ti tabler-check me-1"></i>Enable
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-warning" id="btn-mass-disable">
|
||||
<i class="ti tabler-ban me-1"></i>Disable
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-info" id="btn-mass-extend">
|
||||
<i class="ti tabler-calendar-plus me-1"></i>Extend
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="btn-mass-reset">
|
||||
<i class="ti tabler-device-desktop-off me-1"></i>Reset Device
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" id="btn-mass-delete">
|
||||
<i class="ti tabler-trash me-1"></i>Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light" id="btn-mass-cancel">
|
||||
<i class="ti tabler-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code Details Modal -->
|
||||
<div class="modal fade" id="codeDetailsModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-bottom">
|
||||
<h5 class="modal-title d-flex align-items-center gap-2">
|
||||
<i class="ti tabler-key text-primary"></i>
|
||||
<span>Activation Code Voucher Details</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4" id="modal-details-body">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-top">
|
||||
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extend Days Modal -->
|
||||
<div class="modal fade" id="extendModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Extend Active Codes</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label" for="extend-days">Number of Days</label>
|
||||
<input type="number" id="extend-days" class="form-control" value="30" min="1" max="365">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-confirm-extend">Extend</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-bottom">
|
||||
<h5 class="modal-title text-danger"><i class="ti tabler-alert-triangle me-2"></i>Delete Codes</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
<p>Are you sure you want to delete the selected codes? Associated lines will also be removed.</p>
|
||||
</div>
|
||||
<div class="modal-footer border-top">
|
||||
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-confirm-delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.badge-pulse {
|
||||
animation: pulse-green 2s infinite;
|
||||
}
|
||||
@keyframes pulse-green {
|
||||
0% { box-shadow: 0 0 0 0 rgba(40, 199, 111, 0.7); }
|
||||
70% { box-shadow: 0 0 0 8px rgba(40, 199, 111, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(40, 199, 111, 0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<?php
|
||||
require_once __DIR__ . '/../layouts/footer.php';
|
||||
renderUnifiedLayoutFooter('admin');
|
||||
?>
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
$(function() {
|
||||
const tableEl = $('#admin-active-codes-table');
|
||||
let selectedIds = new Set();
|
||||
|
||||
// Escape helpers + cell renderers (server now returns a clean keyed payload;
|
||||
// all badges / actions are built client-side).
|
||||
function escHtml(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s).replace(/[&<>"']/g, function(c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function renderCheckbox(d, type, row) {
|
||||
const code = escHtml(row.code);
|
||||
return '<input type="checkbox" class="form-check-input row-select" value="' + row.id + '" data-code="' + code + '">';
|
||||
}
|
||||
function renderCode(d, type, row) {
|
||||
const code = escHtml(row.code);
|
||||
return '<div class="d-flex align-items-center gap-2">' +
|
||||
'<code class="fw-bold font-monospace text-primary fs-6 user-select-all">' + code + '</code>' +
|
||||
'<button type="button" class="btn btn-sm btn-icon btn-outline-secondary btn-copy-code" data-code="' + code + '" title="Copy Code"><i class="ti tabler-copy"></i></button>' +
|
||||
'</div>';
|
||||
}
|
||||
function renderBatch(d) {
|
||||
return '<span class="badge bg-label-secondary font-monospace">' + escHtml(d) + '</span>';
|
||||
}
|
||||
function renderPackage(d, type, row) {
|
||||
let html = '<span class="badge bg-label-info">' + escHtml(row.package_name) + '</span>';
|
||||
if (row.is_trial) html += ' <span class="badge bg-label-warning ms-1">Trial</span>';
|
||||
return html;
|
||||
}
|
||||
function renderCreator(d) {
|
||||
return '<span class="badge bg-label-dark"><i class="ti tabler-user me-1"></i>' + escHtml(d) + '</span>';
|
||||
}
|
||||
function renderStatus(d, type, row) {
|
||||
if (row.status === 0) return '<span class="badge bg-label-danger"><i class="ti tabler-ban me-1"></i>Disabled</span>';
|
||||
if (row.status === 1) return '<span class="badge bg-label-success badge-pulse"><i class="ti tabler-sparkles me-1"></i>Ready (Stock)</span>';
|
||||
if (row.exp_expired) return '<span class="badge bg-label-secondary"><i class="ti tabler-clock-off me-1"></i>Expired</span>';
|
||||
return '<span class="badge bg-label-primary"><i class="ti tabler-player-play me-1"></i>Active</span>';
|
||||
}
|
||||
function renderExpiry(d, type, row) {
|
||||
if (row.status === 0) return '<span class="text-muted fst-italic">Suspended</span>';
|
||||
if (row.status === 1) return '<span class="text-muted"><i class="ti tabler-snowflake me-1"></i>Frozen</span>';
|
||||
if (row.exp_expired) return '<span class="text-danger fw-semibold">' + escHtml(row.exp_str) + '</span>';
|
||||
return '<span class="text-primary">' + (row.exp_unix ? escHtml(row.exp_str) : 'Never') + '</span> <small class="text-muted">(' + row.remaining_days + 'd)</small>';
|
||||
}
|
||||
function renderSubscriber(d) {
|
||||
return d ? '<span class="fw-semibold">' + escHtml(d) + '</span>' : '<span class="text-muted fst-italic">Auto-assigned</span>';
|
||||
}
|
||||
function renderMac(d) {
|
||||
return d ? '<span class="badge bg-label-dark font-monospace">' + escHtml(d) + '</span>' : '<span class="text-muted">-</span>';
|
||||
}
|
||||
function renderActions(d, type, row) {
|
||||
const id = row.id, status = row.status;
|
||||
return '<div class="d-inline-block text-nowrap">' +
|
||||
'<button class="btn btn-sm btn-icon btn-text-secondary rounded-pill btn-view-code" data-id="' + id + '" title="View Details"><i class="ti tabler-eye"></i></button>' +
|
||||
'<button class="btn btn-sm btn-icon btn-text-secondary rounded-pill btn-toggle-code" data-id="' + id + '" data-status="' + status + '" title="' + (status == 0 ? 'Enable' : 'Disable') + '"><i class="ti ' + (status == 0 ? 'tabler-check text-success' : 'tabler-ban text-warning') + '"></i></button>' +
|
||||
'<button class="btn btn-sm btn-icon btn-text-secondary rounded-pill btn-reset-code-device" data-id="' + id + '" title="Reset Device Lock"><i class="ti tabler-device-desktop-off"></i></button>' +
|
||||
'<button class="btn btn-sm btn-icon btn-text-secondary rounded-pill btn-delete-code" data-id="' + id + '" title="Delete Code"><i class="ti tabler-trash text-danger"></i></button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
const dt = tableEl.DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
responsive: true,
|
||||
order: [[10, 'desc']],
|
||||
pageLength: 25,
|
||||
lengthMenu: [10, 25, 50, 100],
|
||||
ajax: {
|
||||
url: './table',
|
||||
type: 'POST',
|
||||
data: function(d) {
|
||||
d.id = 'active_codes';
|
||||
d.reseller = jQuery('#filter-reseller').val();
|
||||
d.filter = jQuery('#filter-status').val();
|
||||
d.batch = jQuery('#filter-batch').val();
|
||||
d.package = jQuery('#filter-package').val();
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{ data: null, className: 'control', orderable: false, searchable: false, defaultContent: '' },
|
||||
{ data: null, orderable: false, searchable: false, render: renderCheckbox },
|
||||
{ data: 'code', render: renderCode },
|
||||
{ data: 'batch', render: renderBatch },
|
||||
{ data: null, render: renderPackage },
|
||||
{ data: 'creator', render: renderCreator },
|
||||
{ data: null, render: renderStatus },
|
||||
{ data: null, render: renderExpiry },
|
||||
{ data: 'sub_username', render: renderSubscriber },
|
||||
{ data: 'mac', render: renderMac },
|
||||
{ data: 'created_str', render: escHtml },
|
||||
{ data: null, orderable: false, searchable: false, render: renderActions }
|
||||
],
|
||||
drawCallback: function() {
|
||||
updateFloatingBar();
|
||||
tableEl.find('.row-select').each(function() {
|
||||
if (selectedIds.has(this.value)) {
|
||||
this.checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
jQuery('#filter-search').on('keyup', function() {
|
||||
dt.search(this.value).draw();
|
||||
});
|
||||
jQuery('#filter-reseller, #filter-status, #filter-batch, #filter-package').on('change', function() {
|
||||
dt.ajax.reload();
|
||||
});
|
||||
|
||||
tableEl.on('change', '.row-select', function() {
|
||||
if (this.checked) {
|
||||
selectedIds.add(this.value);
|
||||
} else {
|
||||
selectedIds.delete(this.value);
|
||||
}
|
||||
updateFloatingBar();
|
||||
});
|
||||
|
||||
jQuery('#select-all').on('change', function() {
|
||||
const checked = this.checked;
|
||||
tableEl.find('.row-select').each(function() {
|
||||
this.checked = checked;
|
||||
if (checked) {
|
||||
selectedIds.add(this.value);
|
||||
} else {
|
||||
selectedIds.delete(this.value);
|
||||
}
|
||||
});
|
||||
updateFloatingBar();
|
||||
});
|
||||
|
||||
function updateFloatingBar() {
|
||||
const bar = jQuery('#mass-action-bar');
|
||||
const count = selectedIds.size;
|
||||
jQuery('#selected-count').text(count);
|
||||
if (count > 0) {
|
||||
bar.removeClass('d-none').addClass('d-flex');
|
||||
} else {
|
||||
bar.removeClass('d-flex').addClass('d-none');
|
||||
jQuery('#select-all').prop('checked', false);
|
||||
}
|
||||
}
|
||||
|
||||
jQuery('#btn-mass-cancel').on('click', function() {
|
||||
selectedIds.clear();
|
||||
tableEl.find('.row-select').prop('checked', false);
|
||||
updateFloatingBar();
|
||||
});
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = String(text);
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
textarea.setAttribute('readonly', '');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
success ? resolve() : reject();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 1-Click Copy
|
||||
jQuery(document).on('click', '.btn-copy-code', function(e) {
|
||||
e.preventDefault();
|
||||
const code = jQuery(this).data('code');
|
||||
const btn = jQuery(this);
|
||||
const orig = btn.html();
|
||||
copyToClipboard(code).then(() => {
|
||||
btn.html('<i class="ti tabler-check text-success"></i>');
|
||||
setTimeout(() => btn.html(orig), 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy to clipboard:', code);
|
||||
});
|
||||
});
|
||||
|
||||
// View Details Modal
|
||||
const detailsModal = new bootstrap.Modal(document.getElementById('codeDetailsModal'));
|
||||
jQuery(document).on('click', '.btn-view-code', function() {
|
||||
const id = jQuery(this).data('id');
|
||||
jQuery('#modal-details-body').html(`
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<div class="mt-2 text-muted">Loading voucher details...</div>
|
||||
</div>
|
||||
`);
|
||||
detailsModal.show();
|
||||
|
||||
jQuery.getJSON('./api', { action: 'active_code_details', id: id }, function(res) {
|
||||
if (!res.result) {
|
||||
jQuery('#modal-details-body').html(`<div class="alert alert-danger">${res.message || 'Error loading details'}</div>`);
|
||||
return;
|
||||
}
|
||||
const d = res.data;
|
||||
const html = `
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border">
|
||||
<label class="small text-muted text-uppercase fw-semibold d-block">Activation Code</label>
|
||||
<div class="d-flex align-items-center justify-content-between mt-1">
|
||||
<span class="fs-4 fw-bold font-monospace text-primary">${d.code}</span>
|
||||
<button class="btn btn-sm btn-outline-primary btn-copy-code" data-code="${d.code}"><i class="ti tabler-copy me-1"></i>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border">
|
||||
<label class="small text-muted text-uppercase fw-semibold d-block">Current Status</label>
|
||||
<div class="mt-1">
|
||||
<span class="badge ${d.status === 1 ? 'bg-success' : (d.status === 2 ? 'bg-primary' : 'bg-danger')} fs-6 px-3 py-2">
|
||||
${d.status_text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Package:</span>
|
||||
<span class="fw-semibold">${d.package_name}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Batch:</span>
|
||||
<span class="font-monospace">${d.batch_name}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Max Connections:</span>
|
||||
<span class="fw-semibold">${d.max_connections}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Device / MAC:</span>
|
||||
<span class="font-monospace">${d.mac}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Countdown Expiry:</span>
|
||||
<span class="fw-semibold ${d.status === 1 ? 'text-info' : 'text-primary'}">${d.exp_date}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">First Activated:</span>
|
||||
<span>${d.activated_at}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Created:</span>
|
||||
<span>${d.created_at}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card bg-dark text-white border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-white d-flex align-items-center gap-2 mb-3">
|
||||
<i class="ti tabler-device-tv text-warning"></i>Streaming & Xtream Codes Credentials
|
||||
</h6>
|
||||
<div class="row g-2 text-start">
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-secondary d-block">Username</small>
|
||||
<div class="font-monospace fw-semibold">${d.username}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-secondary d-block">Password</small>
|
||||
<div class="font-monospace fw-semibold">${d.password}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<small class="text-secondary d-block">Server URL</small>
|
||||
<div class="font-monospace small">${d.portal_url}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<small class="text-secondary d-block">Port</small>
|
||||
<div class="font-monospace">${d.port}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="border-secondary my-3">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="${d.m3u_hls}" class="btn btn-sm btn-outline-light" target="_blank">
|
||||
<i class="ti tabler-download me-1"></i>Download M3U (HLS)
|
||||
</a>
|
||||
<a href="${d.m3u_ts}" class="btn btn-sm btn-outline-light" target="_blank">
|
||||
<i class="ti tabler-download me-1"></i>Download M3U (TS)
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-info btn-copy-code" data-code="${d.m3u_hls}">
|
||||
<i class="ti tabler-copy me-1"></i>Copy M3U
|
||||
</button>
|
||||
${d.direct_activate_url ? `
|
||||
<button class="btn btn-sm btn-primary btn-copy-code" data-code="${d.direct_activate_url}">
|
||||
<i class="ti tabler-link me-1"></i>Copy Activation Portal Link
|
||||
</button>
|
||||
<a href="${d.direct_activate_url}" class="btn btn-sm btn-outline-light" target="_blank" title="Test Subscriber Activation Portal">
|
||||
<i class="ti tabler-external-link me-1"></i>Open Portal
|
||||
</a>
|
||||
` : ''}
|
||||
${d.web_player_url ? `
|
||||
<a href="${d.web_player_url}" class="btn btn-sm btn-warning text-dark fw-bold" target="_blank">
|
||||
<i class="ti tabler-player-play me-1"></i>Launch Web Player
|
||||
</a>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
jQuery('#modal-details-body').html(html);
|
||||
});
|
||||
});
|
||||
|
||||
// Mass Action buttons
|
||||
jQuery('#btn-mass-enable').on('click', () => execMassAction('mass_enable'));
|
||||
jQuery('#btn-mass-disable').on('click', () => execMassAction('mass_disable'));
|
||||
jQuery('#btn-mass-reset').on('click', () => execMassAction('mass_reset_device'));
|
||||
|
||||
// Single Toggle Code
|
||||
tableEl.on('click', '.btn-toggle-code', function() {
|
||||
const id = jQuery(this).data('id');
|
||||
const currentStatus = jQuery(this).data('status');
|
||||
const newAction = (currentStatus == 0) ? 'mass_enable' : 'mass_disable';
|
||||
executeApiAction(newAction, [id]);
|
||||
});
|
||||
|
||||
// Single Reset Device
|
||||
tableEl.on('click', '.btn-reset-code-device', function() {
|
||||
const id = jQuery(this).data('id');
|
||||
executeApiAction('mass_reset_device', [id]);
|
||||
});
|
||||
|
||||
// Single Delete Code
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
|
||||
tableEl.on('click', '.btn-delete-code', function() {
|
||||
const id = jQuery(this).data('id');
|
||||
selectedIds.clear();
|
||||
selectedIds.add(id);
|
||||
deleteModal.show();
|
||||
});
|
||||
|
||||
// Mass Extend
|
||||
const extendModal = new bootstrap.Modal(document.getElementById('extendModal'));
|
||||
jQuery('#btn-mass-extend').on('click', () => extendModal.show());
|
||||
jQuery('#btn-confirm-extend').on('click', function() {
|
||||
const days = jQuery('#extend-days').val();
|
||||
execMassAction('mass_extend', { days: days });
|
||||
extendModal.hide();
|
||||
});
|
||||
|
||||
// Mass Delete
|
||||
jQuery('#btn-mass-delete').on('click', () => deleteModal.show());
|
||||
jQuery('#btn-confirm-delete').on('click', function() {
|
||||
execMassAction('mass_delete');
|
||||
deleteModal.hide();
|
||||
});
|
||||
|
||||
function execMassAction(subAction, extra = {}) {
|
||||
if (selectedIds.size === 0) return;
|
||||
executeApiAction(subAction, Array.from(selectedIds), extra);
|
||||
}
|
||||
|
||||
function executeApiAction(subAction, ids, extra = {}) {
|
||||
const postData = Object.assign({
|
||||
action: 'multi',
|
||||
type: 'active_code',
|
||||
sub: subAction,
|
||||
ids: JSON.stringify(ids)
|
||||
}, extra);
|
||||
|
||||
jQuery.post('./api', postData, function(res) {
|
||||
let data = res;
|
||||
if (typeof res === 'string') {
|
||||
try { data = JSON.parse(res); } catch(e) {}
|
||||
}
|
||||
if (data.result) {
|
||||
selectedIds.clear();
|
||||
updateFloatingBar();
|
||||
dt.ajax.reload(null, false);
|
||||
} else {
|
||||
alert(data.message || 'Action failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Admin Active Codes Batch Manager (Bootstrap 5)
|
||||
*
|
||||
* Manage, audit, export, and toggle voucher batches across the entire system.
|
||||
*/
|
||||
|
||||
$batches = $batches ?? [];
|
||||
|
||||
$totalBatches = count($batches);
|
||||
$totalCodes = array_sum(array_column($batches, 'total_codes'));
|
||||
$totalStock = array_sum(array_column($batches, 'stock_count'));
|
||||
$totalActive = array_sum(array_column($batches, 'active_count'));
|
||||
|
||||
?>
|
||||
|
||||
<!-- Metrics Summary -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted fw-normal small text-uppercase mb-1">Total Batches</h6>
|
||||
<h3 class="mb-0 fw-bold"><?= $totalBatches; ?></h3>
|
||||
</div>
|
||||
<div class="avatar avatar-lg bg-label-primary rounded p-2">
|
||||
<i class="ti tabler-folders fs-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted fw-normal small text-uppercase mb-1">Total Codes</h6>
|
||||
<h3 class="mb-0 fw-bold text-info"><?= $totalCodes; ?></h3>
|
||||
</div>
|
||||
<div class="avatar avatar-lg bg-label-info rounded p-2">
|
||||
<i class="ti tabler-key fs-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted fw-normal small text-uppercase mb-1">Unused Stock</h6>
|
||||
<h3 class="mb-0 fw-bold text-success"><?= $totalStock; ?></h3>
|
||||
</div>
|
||||
<div class="avatar avatar-lg bg-label-success rounded p-2">
|
||||
<i class="ti tabler-snowflake fs-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted fw-normal small text-uppercase mb-1">Active Subscriptions</h6>
|
||||
<h3 class="mb-0 fw-bold text-primary"><?= $totalActive; ?></h3>
|
||||
</div>
|
||||
<div class="avatar avatar-lg bg-label-warning rounded p-2">
|
||||
<i class="ti tabler-player-play fs-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header border-bottom d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<h5 class="card-title mb-1"><i class="ti tabler-folders text-primary me-2"></i>Voucher Batches (All Resellers)</h5>
|
||||
<p class="text-muted small mb-0">System-wide batch operations, scratch card exporting, and inventory audit.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="active_code" class="btn btn-primary">
|
||||
<i class="ti tabler-plus me-1"></i>New Batch
|
||||
</a>
|
||||
<a href="active_codes" class="btn btn-label-secondary">
|
||||
<i class="ti tabler-list me-1"></i>All Codes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<?php if (empty($batches)): ?>
|
||||
<div class="text-center py-5">
|
||||
<div class="avatar avatar-xl bg-label-secondary rounded-circle mx-auto mb-3 p-3">
|
||||
<i class="ti tabler-folders-off fs-1"></i>
|
||||
</div>
|
||||
<h5>No batches created yet</h5>
|
||||
<p class="text-muted">Generate a batch of active codes to start managing vouchers.</p>
|
||||
<a href="active_code" class="btn btn-primary mt-2">Generate Codes</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Batch Name</th>
|
||||
<th>Creator / Reseller</th>
|
||||
<th>Package</th>
|
||||
<th style="min-width: 200px;">Breakdown</th>
|
||||
<th>Date Created</th>
|
||||
<th class="text-end" style="width: 180px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($batches as $b):
|
||||
$tot = max(1, (int)$b['total_codes']);
|
||||
$stockPct = round(((int)$b['stock_count'] / $tot) * 100);
|
||||
$activePct = round(((int)$b['active_count'] / $tot) * 100);
|
||||
$bName = (string)$b['batch_name'];
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="avatar avatar-sm bg-label-primary rounded p-1">
|
||||
<i class="ti tabler-folder"></i>
|
||||
</div>
|
||||
<div>
|
||||
<span class="fw-bold font-monospace fs-6"><?= htmlspecialchars($bName, ENT_QUOTES); ?></span>
|
||||
<a href="active_codes?batch=<?= urlencode($bName); ?>" class="d-block small text-muted text-decoration-none">
|
||||
View codes <i class="ti tabler-arrow-right small"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-label-dark"><i class="ti tabler-user me-1"></i><?= htmlspecialchars((string)$b['creator_name'], ENT_QUOTES); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-label-info"><?= htmlspecialchars((string)$b['package_name'], ENT_QUOTES); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-success fw-semibold"><i class="ti tabler-snowflake"></i> <?= (int)$b['stock_count']; ?> Stock</span>
|
||||
<span class="text-primary fw-semibold"><i class="ti tabler-player-play"></i> <?= (int)$b['active_count']; ?> Active</span>
|
||||
<span class="text-muted"><?= (int)$b['total_codes']; ?> Total</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: <?= $stockPct; ?>%"></div>
|
||||
<div class="progress-bar bg-primary" role="progressbar" style="width: <?= $activePct; ?>%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small"><?= $b['created_at'] ? date('Y-m-d H:i', (int)$b['created_at']) : '-'; ?></span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group">
|
||||
<a href="./api?action=active_codes_export_txt&batch_name=<?= urlencode($bName); ?>" class="btn btn-sm btn-outline-primary" title="Export Scratch Card Vouchers .txt">
|
||||
<i class="ti tabler-printer me-1"></i>Export
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm">
|
||||
<li>
|
||||
<a class="dropdown-item btn-batch-enable" href="#" data-batch="<?= htmlspecialchars($bName, ENT_QUOTES); ?>">
|
||||
<i class="ti tabler-check text-success me-2"></i>Enable Batch
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item btn-batch-disable" href="#" data-batch="<?= htmlspecialchars($bName, ENT_QUOTES); ?>">
|
||||
<i class="ti tabler-ban text-warning me-2"></i>Disable Batch
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item text-danger btn-batch-delete" href="#" data-batch="<?= htmlspecialchars($bName, ENT_QUOTES); ?>">
|
||||
<i class="ti tabler-trash me-2"></i>Delete Batch
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
require_once __DIR__ . '/../layouts/footer.php';
|
||||
renderUnifiedLayoutFooter('admin');
|
||||
?>
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
$(function() {
|
||||
$('.btn-batch-enable').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
execAdminBatchAction(jQuery(this).data('batch'), 'mass_enable');
|
||||
});
|
||||
|
||||
jQuery('.btn-batch-disable').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
execAdminBatchAction(jQuery(this).data('batch'), 'mass_disable');
|
||||
});
|
||||
|
||||
jQuery('.btn-batch-delete').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const batch = jQuery(this).data('batch');
|
||||
if (confirm(`Are you sure you want to delete batch "${batch}" and all its codes?`)) {
|
||||
execAdminBatchAction(batch, 'mass_delete');
|
||||
}
|
||||
});
|
||||
|
||||
function execAdminBatchAction(batchName, subAction) {
|
||||
jQuery.post('./api', {
|
||||
action: 'active_codes_batch_action',
|
||||
batch_name: batchName,
|
||||
sub_action: subAction
|
||||
}, function(res) {
|
||||
let data = res;
|
||||
if (typeof res === 'string') {
|
||||
try { data = JSON.parse(res); } catch(e) {}
|
||||
}
|
||||
if (data.result) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || 'Action failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Mass Edit Active Codes (Bootstrap 5, Admin)
|
||||
*/
|
||||
|
||||
$rPackages = $rPackages ?? [];
|
||||
$batches = $batches ?? [];
|
||||
$resellers = $resellers ?? [];
|
||||
|
||||
?>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-9">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header border-bottom d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="card-title mb-1"><i class="ti tabler-adjustments text-primary me-2"></i>Mass Edit Active Codes</h5>
|
||||
<p class="text-muted small mb-0">Apply bulk changes to codes across a batch, a reseller, or specific codes.</p>
|
||||
</div>
|
||||
<a href="active_codes" class="btn btn-sm btn-label-secondary">
|
||||
<i class="ti tabler-arrow-left me-1"></i>Back to Codes
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<form id="mass-edit-form">
|
||||
<!-- Target Selection Mode -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">1. Select Target Codes</label>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label small" for="target_batch">By Batch</label>
|
||||
<select id="target_batch" name="target_batch" class="form-select">
|
||||
<option value="">-- All Batches --</option>
|
||||
<?php foreach ($batches as $b): ?>
|
||||
<option value="<?= htmlspecialchars((string)$b['batch_name'], ENT_QUOTES); ?>">
|
||||
<?= htmlspecialchars((string)$b['batch_name'], ENT_QUOTES); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label small" for="target_reseller">By Reseller</label>
|
||||
<select id="target_reseller" name="target_reseller" class="form-select">
|
||||
<option value="">-- All Resellers --</option>
|
||||
<?php foreach ($resellers as $res): ?>
|
||||
<option value="<?= (int)$res['id']; ?>">
|
||||
<?= htmlspecialchars((string)$res['username'], ENT_QUOTES); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="text-muted opacity-25 my-4">
|
||||
|
||||
<!-- Action Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">2. Choose Mass Operation</label>
|
||||
<select id="mass_action_type" name="mass_action_type" class="form-select form-select-lg">
|
||||
<option value="mass_enable">Enable Selected Codes & Subscribers</option>
|
||||
<option value="mass_disable">Disable / Suspend Selected Codes</option>
|
||||
<option value="mass_extend">Extend Subscription Expiry (Add Days)</option>
|
||||
<option value="mass_change_package">Change Package & Bouquets</option>
|
||||
<option value="mass_reset_device">Reset Device / MAC Hardware Lock</option>
|
||||
<option value="mass_delete" class="text-danger">Delete Codes & Associated Lines</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Options -->
|
||||
<div id="option-extend-box" class="p-3 bg-light-subtle rounded-3 border mb-4 d-none">
|
||||
<label class="form-label fw-semibold" for="mass_days">Days to Extend</label>
|
||||
<input type="number" id="mass_days" name="days" class="form-control" value="30" min="1" max="365">
|
||||
<div class="form-text small">Adds this number of days to all currently active codes matching the filter.</div>
|
||||
</div>
|
||||
|
||||
<div id="option-package-box" class="p-3 bg-light-subtle rounded-3 border mb-4 d-none">
|
||||
<label class="form-label fw-semibold" for="new_package_id">New Target Package</label>
|
||||
<select id="new_package_id" name="new_package_id" class="form-select">
|
||||
<?php foreach ($rPackages as $pkg): ?>
|
||||
<option value="<?= (int)$pkg['id']; ?>">
|
||||
<?= htmlspecialchars((string)$pkg['package_name'], ENT_QUOTES); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text small">Updates both the voucher package and bouquets on the linked subscriber line.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-3 pt-3 border-top">
|
||||
<button type="reset" class="btn btn-label-secondary">Reset</button>
|
||||
<button type="submit" class="btn btn-primary px-4" id="btn-apply-mass">
|
||||
<i class="ti tabler-bolt me-1"></i>Apply Mass Action
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
require_once __DIR__ . '/../layouts/footer.php';
|
||||
renderUnifiedLayoutFooter('admin');
|
||||
?>
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
$(function() {
|
||||
const actionSelect = $('#mass_action_type');
|
||||
const extendBox = $('#option-extend-box');
|
||||
const packageBox = $('#option-package-box');
|
||||
|
||||
actionSelect.on('change', function() {
|
||||
const val = this.value;
|
||||
extendBox.toggleClass('d-none', val !== 'mass_extend');
|
||||
packageBox.toggleClass('d-none', val !== 'mass_change_package');
|
||||
|
||||
const btn = jQuery('#btn-apply-mass');
|
||||
if (val === 'mass_delete') {
|
||||
btn.removeClass('btn-primary').addClass('btn-danger').html('<i class="ti tabler-trash me-1"></i>Delete Matching Codes');
|
||||
} else {
|
||||
btn.removeClass('btn-danger').addClass('btn-primary').html('<i class="ti tabler-bolt me-1"></i>Apply Mass Action');
|
||||
}
|
||||
});
|
||||
|
||||
jQuery('#mass-edit-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const action = actionSelect.val();
|
||||
const batch = jQuery('#target_batch').val();
|
||||
const reseller = jQuery('#target_reseller').val();
|
||||
|
||||
if (!batch && !reseller) {
|
||||
if (!confirm('You have not selected a specific batch or reseller. This will apply to ALL codes in the system. Proceed?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const btn = jQuery('#btn-apply-mass');
|
||||
btn.prop('disabled', true);
|
||||
|
||||
// Fetch matching code IDs
|
||||
jQuery.post('./table', {
|
||||
id: 'active_codes',
|
||||
batch: batch,
|
||||
reseller: reseller,
|
||||
start: 0,
|
||||
length: 1000
|
||||
}, function(tableRes) {
|
||||
let res = tableRes;
|
||||
if (typeof tableRes === 'string') {
|
||||
try { res = JSON.parse(tableRes); } catch(e) {}
|
||||
}
|
||||
|
||||
const ids = (res.data || []).map(row => {
|
||||
const match = row[1] && row[1].match(/value="(\d+)"/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
if (!ids.length) {
|
||||
btn.prop('disabled', false);
|
||||
alert('No matching codes found for the selected target.');
|
||||
return;
|
||||
}
|
||||
|
||||
const postData = {
|
||||
action: 'multi',
|
||||
type: 'active_code',
|
||||
sub: action,
|
||||
ids: JSON.stringify(ids),
|
||||
days: jQuery('#mass_days').val(),
|
||||
package_id: jQuery('#new_package_id').val()
|
||||
};
|
||||
|
||||
jQuery.post('./api', postData, function(actionRes) {
|
||||
btn.prop('disabled', false);
|
||||
let aRes = actionRes;
|
||||
if (typeof actionRes === 'string') {
|
||||
try { aRes = JSON.parse(actionRes); } catch(e) {}
|
||||
}
|
||||
if (aRes.result) {
|
||||
alert(aRes.message || 'Mass action applied successfully!');
|
||||
window.location.href = 'active_codes';
|
||||
} else {
|
||||
alert(aRes.message || 'Action failed.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -14,7 +14,7 @@ use XcVm\Domain\User\GroupService;
|
||||
$rIsEdit = isset($rCode);
|
||||
$rCodeGroups = ($rIsEdit && !empty($rCode['groups'])) ? (json_decode((string) $rCode['groups'], true) ?: []) : [];
|
||||
$rWhitelist = ($rIsEdit && !empty($rCode['whitelist'])) ? (json_decode((string) $rCode['whitelist'], true) ?: []) : [];
|
||||
$rTypes = ['Admin', 'Reseller', 'Ministra', 'Admin API', 'Reseller API', 6 => 'Web Player'];
|
||||
$rTypes = ['Admin', 'Reseller', 'Ministra', 'Admin API', 'Reseller API', 6 => 'Web Player', 7 => 'Active Code Portal'];
|
||||
?>
|
||||
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
@@ -67,6 +67,27 @@ $rTypes = ['Admin', 'Reseller', 'Ministra', 'Admin API', 'Reseller API', 6 => 'W
|
||||
<input class="form-check-input" type="checkbox" id="enabled" name="enabled" value="1" <?= (!$rIsEdit || $rCode['enabled'] == 1) ? 'checked' : ''; ?>>
|
||||
<label class="form-check-label" for="enabled"><?= $language::get('enabled'); ?></label>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-primary bg-primary bg-opacity-10 border-primary border-opacity-25 mt-4 d-none" id="portal-preview-box">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="avatar avatar-sm bg-primary text-white rounded-circle d-flex align-items-center justify-content-center mt-1 flex-shrink-0">
|
||||
<i class="icon-base ti tabler-key fs-5" id="preview-box-icon"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<h6 class="alert-heading fw-bold mb-1" id="preview-box-title">Active Code Portal Direct Access</h6>
|
||||
<p class="mb-2 small text-body-secondary" id="preview-box-desc">
|
||||
Subscribers can open this URL to input their activation codes, receive Xtream Codes credentials, and download playlists.
|
||||
</p>
|
||||
<div class="d-flex align-items-center gap-2 bg-body p-2 rounded-2 border">
|
||||
<i class="icon-base ti tabler-link text-primary flex-shrink-0"></i>
|
||||
<span class="font-monospace small fw-bold text-truncate" id="portal-url-preview"></span>
|
||||
<button type="button" class="btn btn-xs btn-label-secondary ms-auto flex-shrink-0" id="btn-copy-preview-url" title="Copy URL">
|
||||
<i class="icon-base ti tabler-copy me-1"></i>Copy URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-groups" role="tabpanel">
|
||||
<div class="d-flex justify-content-end mb-4">
|
||||
@@ -134,6 +155,89 @@ renderUnifiedLayoutFooter('admin');
|
||||
}
|
||||
<?php endif; ?>
|
||||
|
||||
// Dynamic preview for Web Player (6) and Active Code Portal (7)
|
||||
var typeSelect = document.getElementById('type');
|
||||
var codeInput = document.getElementById('code');
|
||||
var previewBox = document.getElementById('portal-preview-box');
|
||||
var previewTitle = document.getElementById('preview-box-title');
|
||||
var previewDesc = document.getElementById('preview-box-desc');
|
||||
var previewUrl = document.getElementById('portal-url-preview');
|
||||
var previewIcon = document.getElementById('preview-box-icon');
|
||||
var copyPreviewBtn = document.getElementById('btn-copy-preview-url');
|
||||
var tabGroupsBtn = document.querySelector('button[data-bs-target="#tab-groups"]');
|
||||
|
||||
function updatePreview() {
|
||||
var typeVal = parseInt(typeSelect.value, 10);
|
||||
var codeVal = codeInput.value.trim();
|
||||
var origin = window.location.origin;
|
||||
var fullUrl = origin + '/' + (codeVal ? encodeURIComponent(codeVal) : '...') + '/';
|
||||
|
||||
if (typeVal === 7) {
|
||||
previewBox.classList.remove('d-none');
|
||||
previewTitle.textContent = 'Active Code Portal (Subscriber Activation)';
|
||||
previewDesc.textContent = 'Subscribers open this URL to enter their activation codes, view Xtream Codes credentials, and download playlists.';
|
||||
previewIcon.className = 'icon-base ti tabler-key fs-5';
|
||||
previewUrl.textContent = fullUrl;
|
||||
if (tabGroupsBtn) {
|
||||
tabGroupsBtn.classList.add('disabled', 'opacity-50');
|
||||
tabGroupsBtn.title = 'Groups do not apply to subscriber portals';
|
||||
}
|
||||
} else if (typeVal === 6) {
|
||||
previewBox.classList.remove('d-none');
|
||||
previewTitle.textContent = 'Web Player Direct Access';
|
||||
previewDesc.textContent = 'Subscribers open this URL to stream channels and VOD directly in their web browser.';
|
||||
previewIcon.className = 'icon-base ti tabler-device-tv fs-5';
|
||||
previewUrl.textContent = fullUrl;
|
||||
if (tabGroupsBtn) {
|
||||
tabGroupsBtn.classList.add('disabled', 'opacity-50');
|
||||
tabGroupsBtn.title = 'Groups do not apply to web players';
|
||||
}
|
||||
} else {
|
||||
previewBox.classList.add('d-none');
|
||||
if (tabGroupsBtn) {
|
||||
tabGroupsBtn.classList.remove('disabled', 'opacity-50');
|
||||
tabGroupsBtn.removeAttribute('title');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typeSelect.addEventListener('change', updatePreview);
|
||||
codeInput.addEventListener('input', updatePreview);
|
||||
updatePreview();
|
||||
|
||||
if (copyPreviewBtn) {
|
||||
copyPreviewBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var txt = previewUrl.textContent;
|
||||
if (!txt) return;
|
||||
var p = (navigator.clipboard && window.isSecureContext)
|
||||
? navigator.clipboard.writeText(txt)
|
||||
: new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.value = String(txt);
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
textarea.setAttribute('readonly', '');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
var success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
success ? resolve() : reject();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
p.then(function() {
|
||||
xcToast('Copied portal URL to clipboard!', 'success');
|
||||
}).catch(function() {
|
||||
prompt('Copy portal URL:', txt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Group select-all / none.
|
||||
document.getElementById('grp-all').addEventListener('click', function() {
|
||||
document.querySelectorAll('.group-checkbox').forEach(function(c) {
|
||||
|
||||
@@ -20,12 +20,24 @@ if (!Authorization::check('adv', 'add_code')):
|
||||
endif;
|
||||
|
||||
// Access-code type labels (matches the code form's type select).
|
||||
$rCodeTypes = [0 => 'Admin', 1 => 'Reseller', 2 => 'Ministra', 3 => 'Admin API', 4 => 'Reseller API', 6 => 'Web Player'];
|
||||
$rCodeTypes = [0 => 'Admin', 1 => 'Reseller', 2 => 'Ministra', 3 => 'Admin API', 4 => 'Reseller API', 6 => 'Web Player', 7 => 'Active Code Portal'];
|
||||
$rTypeBadges = [
|
||||
0 => 'bg-label-primary',
|
||||
1 => 'bg-label-warning',
|
||||
2 => 'bg-label-secondary',
|
||||
3 => 'bg-label-dark',
|
||||
4 => 'bg-label-dark',
|
||||
6 => 'bg-label-success',
|
||||
7 => 'bg-label-info',
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<h5 class="card-title mb-0"><?= $language::get('access_codes'); ?></h5>
|
||||
<a href="code" class="btn btn-sm btn-primary">
|
||||
<i class="icon-base ti tabler-plus me-1"></i><?= $language::get('add'); ?> <?= $language::get('access_code'); ?>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-datatable table-responsive">
|
||||
<table id="codes-table" class="table" style="width:100%">
|
||||
@@ -44,12 +56,26 @@ $rCodeTypes = [0 => 'Admin', 1 => 'Reseller', 2 => 'Ministra', 3 => 'Admin API',
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="text-center"><?= (int) $rCode['id']; ?></td>
|
||||
<td class="text-nowrap"><?= htmlspecialchars((string) $rCode['code'], ENT_QUOTES); ?></td>
|
||||
<td class="text-center"><span class="badge bg-label-primary"><?= htmlspecialchars($rCodeTypes[(int) $rCode['type']] ?? (string) $rCode['type'], ENT_QUOTES); ?></span></td>
|
||||
<td class="text-nowrap">
|
||||
<span class="font-monospace fw-semibold"><?= htmlspecialchars((string) $rCode['code'], ENT_QUOTES); ?></span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge <?= $rTypeBadges[(int) $rCode['type']] ?? 'bg-label-primary'; ?>">
|
||||
<?= htmlspecialchars($rCodeTypes[(int) $rCode['type']] ?? (string) $rCode['type'], ENT_QUOTES); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center" data-order="<?= (int) (bool) $rCode['enabled']; ?>">
|
||||
<i class="icon-base ti tabler-square-filled <?= $rCode['enabled'] ? 'text-success' : 'text-body-secondary'; ?>"></i>
|
||||
</td>
|
||||
<td class="text-center text-nowrap">
|
||||
<?php if ($rCode['enabled']): ?>
|
||||
<a href="/<?= htmlspecialchars((string) $rCode['code'], ENT_QUOTES); ?>/" target="_blank" class="btn btn-sm btn-icon btn-label-info me-1" title="Open Portal / Access Link">
|
||||
<i class="icon-base ti tabler-external-link"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-icon btn-label-secondary js-copy-link me-1" data-code="<?= htmlspecialchars((string) $rCode['code'], ENT_QUOTES); ?>" title="Copy Full URL">
|
||||
<i class="icon-base ti tabler-copy"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<a href="code?id=<?= (int) $rCode['id']; ?>" class="btn btn-sm btn-icon btn-label-secondary" title="<?= $language::get('edit_code'); ?>"><i class="icon-base ti tabler-pencil"></i></a>
|
||||
<button type="button" class="btn btn-sm btn-icon btn-label-danger js-del" data-id="<?= (int) $rCode['id']; ?>" title="<?= $language::get('delete_code'); ?>"><i class="icon-base ti tabler-trash"></i></button>
|
||||
</td>
|
||||
@@ -124,6 +150,40 @@ renderUnifiedLayoutFooter('admin');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.value = String(text);
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
textarea.setAttribute('readonly', '');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
var success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
success ? resolve() : reject();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
jQuery('#codes-table tbody').on('click', '.js-copy-link', function() {
|
||||
var code = this.getAttribute('data-code');
|
||||
var fullUrl = window.location.origin + '/' + encodeURIComponent(code) + '/';
|
||||
copyToClipboard(fullUrl).then(function() {
|
||||
xcToast('Access URL copied to clipboard!', 'success');
|
||||
}).catch(function() {
|
||||
prompt('Copy Access URL:', fullUrl);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -100,6 +100,17 @@ $xmMenu = [
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'active_codes',
|
||||
'icon' => 'ti tabler-key',
|
||||
'url' => '#',
|
||||
'show' => !empty($xmPermissions['create_line']),
|
||||
'children' => [
|
||||
['label' => 'generate_codes', 'url' => 'active_code', 'show' => true],
|
||||
['label' => 'manage_codes', 'url' => 'active_codes', 'show' => true],
|
||||
['label' => 'batch_manager', 'url' => 'active_codes_batch', 'show' => true],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'content',
|
||||
'icon' => 'ti tabler-player-play',
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Subscriber Activation Portal (Bootstrap 5 / Sneat-Vuexy Platform Theme)
|
||||
*
|
||||
* Designed to seamlessly match the XC_VM platform's active codes management UI,
|
||||
* featuring Public Sans & JetBrains Mono typography, Tabler icons, Vuexy dark styling,
|
||||
* live delayed-countdown timer, Xtream Codes credentials viewer, and M3U downloads.
|
||||
*/
|
||||
|
||||
use XcVm\Core\Config\SettingsManager;
|
||||
|
||||
$serverName = class_exists(SettingsManager::class)
|
||||
? (SettingsManager::get('server_name') ?: 'XC_VM')
|
||||
: 'XC_VM';
|
||||
$serverLogo = '/assets/admin/img/logo-topbar.png';
|
||||
$initialCode = trim((string)($_GET['code'] ?? ''));
|
||||
?>
|
||||
<!doctype html>
|
||||
<html
|
||||
lang="en"
|
||||
class="layout-navbar-fixed layout-menu-fixed layout-compact"
|
||||
dir="ltr"
|
||||
data-skin="default"
|
||||
data-bs-theme="dark"
|
||||
data-assets-path="/assets/admin/"
|
||||
data-template="vertical-menu-template">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title><?= htmlspecialchars($serverName, ENT_QUOTES); ?> | Subscriber Activation Portal</title>
|
||||
<meta name="description" content="Redeem your voucher code to activate streaming access, obtain Xtream Codes credentials, and download M3U playlists.">
|
||||
<link rel="icon" type="image/x-icon" href="/assets/admin/img/favicon/favicon.ico">
|
||||
|
||||
<!-- Fonts: Public Sans & JetBrains Mono (Platform Standard) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap">
|
||||
|
||||
<!-- Tabler Icons -->
|
||||
<link rel="stylesheet" href="/assets/admin/vendor/fonts/iconify-icons.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@2.44.0/tabler-icons.min.css">
|
||||
|
||||
<!-- Core Platform Styles (Bootstrap 5 + Vuexy Theme) -->
|
||||
<link rel="stylesheet" href="/assets/admin/vendor/css/bootstrap.css">
|
||||
<link rel="stylesheet" href="/assets/admin/vendor/css/custom.css">
|
||||
<link rel="stylesheet" href="/assets/admin/xcvm/custom.css">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bs-primary: #7367f0;
|
||||
--bs-primary-rgb: 115, 103, 240;
|
||||
--bs-body-bg: #25293c;
|
||||
--bs-body-color: #cfcde4;
|
||||
--bs-card-bg: #2f3349;
|
||||
--bs-border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Public Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #25293c;
|
||||
color: #cfcde4;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.portal-topbar {
|
||||
background-color: #2f3349;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 0.85rem 0;
|
||||
}
|
||||
|
||||
.app-brand-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.portal-container {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #2f3349;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.25rem 1.125rem rgba(15, 20, 34, 0.4);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
background-color: transparent;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.code-input-lg {
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
font-size: 1.5rem !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 3px !important;
|
||||
text-transform: uppercase !important;
|
||||
text-align: center !important;
|
||||
padding: 0.9rem 1.25rem !important;
|
||||
background-color: #25293c !important;
|
||||
color: #7367f0 !important;
|
||||
border-color: #434968 !important;
|
||||
}
|
||||
|
||||
.code-input-lg:focus {
|
||||
border-color: #7367f0 !important;
|
||||
box-shadow: 0 0 0 0.25rem rgba(115, 103, 240, 0.25) !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #7367f0 !important;
|
||||
border-color: #7367f0 !important;
|
||||
box-shadow: 0 0.125rem 0.25rem 0 rgba(115, 103, 240, 0.4);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover, .btn-primary:focus {
|
||||
background-color: #685dd8 !important;
|
||||
border-color: #685dd8 !important;
|
||||
box-shadow: 0 0.25rem 0.5rem 0 rgba(115, 103, 240, 0.5);
|
||||
}
|
||||
|
||||
.btn-label-primary {
|
||||
background-color: rgba(115, 103, 240, 0.16) !important;
|
||||
color: #7367f0 !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
.btn-label-primary:hover {
|
||||
background-color: #7367f0 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.btn-label-secondary {
|
||||
background-color: rgba(168, 170, 174, 0.16) !important;
|
||||
color: #a8aaae !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
.btn-label-secondary:hover {
|
||||
background-color: #a8aaae !important;
|
||||
color: #25293c !important;
|
||||
}
|
||||
|
||||
.btn-label-info {
|
||||
background-color: rgba(0, 186, 209, 0.16) !important;
|
||||
color: #00bad1 !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
.btn-label-info:hover {
|
||||
background-color: #00bad1 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.btn-label-success {
|
||||
background-color: rgba(40, 199, 111, 0.16) !important;
|
||||
color: #28c76f !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
.btn-label-warning {
|
||||
background-color: rgba(255, 159, 67, 0.16) !important;
|
||||
color: #ff9f43 !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
.badge.bg-label-primary {
|
||||
background-color: rgba(115, 103, 240, 0.16) !important;
|
||||
color: #7367f0 !important;
|
||||
}
|
||||
|
||||
.badge.bg-label-success {
|
||||
background-color: rgba(40, 199, 111, 0.16) !important;
|
||||
color: #28c76f !important;
|
||||
}
|
||||
|
||||
.badge.bg-label-info {
|
||||
background-color: rgba(0, 186, 209, 0.16) !important;
|
||||
color: #00bad1 !important;
|
||||
}
|
||||
|
||||
.badge.bg-label-warning {
|
||||
background-color: rgba(255, 159, 67, 0.16) !important;
|
||||
color: #ff9f43 !important;
|
||||
}
|
||||
|
||||
/* Countdown Segments */
|
||||
.countdown-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.countdown-tile {
|
||||
background-color: #25293c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 0.5rem;
|
||||
text-align: center;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.countdown-number {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 1.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #7367f0;
|
||||
}
|
||||
|
||||
.countdown-title {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: #8692d0;
|
||||
letter-spacing: 1px;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.cred-box {
|
||||
background-color: #25293c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #28c76f;
|
||||
box-shadow: 0 0 0 rgba(40, 199, 111, 0.6);
|
||||
animation: pulse-dot-anim 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot-anim {
|
||||
0% { box-shadow: 0 0 0 0 rgba(40, 199, 111, 0.7); }
|
||||
70% { box-shadow: 0 0 0 7px rgba(40, 199, 111, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(40, 199, 111, 0); }
|
||||
}
|
||||
|
||||
.toast-copy-feedback {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1090;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.countdown-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.code-input-lg {
|
||||
font-size: 1.25rem !important;
|
||||
letter-spacing: 2px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Topbar matching Platform Admin/Reseller Header -->
|
||||
<header class="portal-topbar mb-4">
|
||||
<div class="container-xxl d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="app-brand-logo demo me-2">
|
||||
<img src="<?= htmlspecialchars($serverLogo, ENT_QUOTES); ?>" alt="<?= htmlspecialchars($serverName, ENT_QUOTES); ?>" height="28" onerror="this.style.display='none'">
|
||||
</span>
|
||||
<span class="app-brand-text"><?= htmlspecialchars($serverName, ENT_QUOTES); ?></span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-label-primary px-3 py-2 d-none d-sm-inline-flex align-items-center">
|
||||
<i class="ti tabler-sparkles me-1"></i>Subscriber Activation Portal
|
||||
</span>
|
||||
<span class="badge bg-label-success px-3 py-2 d-inline-flex align-items-center">
|
||||
<span class="pulse-dot me-2"></span>Service Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Stage -->
|
||||
<main class="container-xxl flex-grow-1 d-flex flex-column justify-content-center py-3">
|
||||
<div class="portal-container">
|
||||
|
||||
<!-- Breadcrumb / Header Subtitle -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="fw-bold mb-1 d-flex align-items-center">
|
||||
<i class="ti tabler-ticket text-primary me-2 fs-3"></i>Smart Code Activation
|
||||
</h4>
|
||||
<p class="text-muted small mb-0">Redeem voucher codes, stream IPTV, and access live playlist endpoints.</p>
|
||||
</div>
|
||||
<div class="text-end d-none d-md-block">
|
||||
<span class="badge bg-label-secondary"><i class="ti tabler-shield-check me-1"></i>Secure Stream Provisioning</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACTIVATION INPUT CARD -->
|
||||
<div class="card shadow-sm border-0 mb-4" id="activation-card">
|
||||
<div class="card-header border-bottom d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="card-title mb-1"><i class="ti tabler-key text-primary me-2"></i>Enter Activation Voucher</h5>
|
||||
<p class="text-muted small mb-0">Enter your active voucher PIN to unlock your stream access.</p>
|
||||
</div>
|
||||
<span class="badge bg-label-info">
|
||||
<i class="ti tabler-bolt me-1"></i>Instant Provision
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<form id="portal-form">
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold text-uppercase small text-muted mb-2 d-block text-center" for="activation-code">
|
||||
Activation Code / Voucher PIN <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<span class="input-group-text bg-light-subtle"><i class="ti tabler-ticket text-primary fs-4"></i></span>
|
||||
<input
|
||||
type="text"
|
||||
id="activation-code"
|
||||
name="code"
|
||||
class="form-control code-input-lg"
|
||||
placeholder="8X7K9P2M"
|
||||
maxlength="32"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
required
|
||||
value="<?= htmlspecialchars($initialCode, ENT_QUOTES); ?>">
|
||||
<button type="button" class="btn btn-outline-secondary" id="btn-paste-code" title="Paste from clipboard">
|
||||
<i class="ti tabler-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-2 px-1">
|
||||
<small class="text-muted">
|
||||
<i class="ti tabler-info-circle me-1"></i>Letters and digits only • Case insensitive
|
||||
</small>
|
||||
<a href="javascript:void(0);" class="small text-decoration-none text-muted" id="btn-toggle-advanced">
|
||||
<i class="ti tabler-device-desktop me-1"></i>Device Binding (Optional)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional Advanced Device / MAC Lock -->
|
||||
<div class="d-none mb-4 p-3 bg-light-subtle rounded-3 border" id="advanced-device-box">
|
||||
<label class="form-label fw-semibold small text-muted text-uppercase" for="device_mac">
|
||||
Device MAC Address (Optional Lock)
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="ti tabler-cpu"></i></span>
|
||||
<input type="text" id="device_mac" name="mac" class="form-control font-monospace" placeholder="00:1A:79:XX:XX:XX">
|
||||
</div>
|
||||
<div class="form-text small">Leave blank unless your code is locked to a specific MAC / MAG STB.</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="d-grid mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg" id="btn-activate-submit">
|
||||
<span class="d-flex align-items-center justify-content-center gap-2">
|
||||
<i class="ti tabler-bolt fs-4"></i>
|
||||
<span class="fs-5">Activate Subscription</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<div id="portal-error-alert" class="alert alert-danger d-flex align-items-center mt-4 d-none" role="alert">
|
||||
<i class="ti tabler-alert-circle fs-3 me-2 flex-shrink-0"></i>
|
||||
<div id="portal-error-text" class="fw-semibold">Invalid activation code entered.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUCCESS RESULT CARD (Styled Exactly like Active Codes Details Modal) -->
|
||||
<div class="card shadow-sm border-0 d-none" id="success-card">
|
||||
<div class="card-header border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<h5 class="card-title mb-1 text-success d-flex align-items-center">
|
||||
<i class="ti tabler-circle-check text-success me-2 fs-4"></i>Subscription Activated Successfully
|
||||
</h5>
|
||||
<p class="text-muted small mb-0">Your streaming credentials and playlist links are now active and ready.</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-success fs-6 px-3 py-2" id="res-status-badge">
|
||||
<i class="ti tabler-circle-check me-1"></i>Active
|
||||
</span>
|
||||
<span class="badge bg-label-primary fs-6 px-3 py-2" id="res-pkg-badge">
|
||||
Premium Package
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<!-- Highlight Grid -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border h-100">
|
||||
<label class="small text-muted text-uppercase fw-semibold d-block">Activation Code</label>
|
||||
<div class="d-flex align-items-center justify-content-between mt-1">
|
||||
<span class="fs-4 fw-bold font-monospace text-primary" id="res-active-code">--------</span>
|
||||
<button class="btn btn-sm btn-outline-primary btn-copy-code" id="btn-copy-code-val" data-code="">
|
||||
<i class="ti tabler-copy me-1"></i>Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border h-100">
|
||||
<label class="small text-muted text-uppercase fw-semibold d-block">Package & Plan</label>
|
||||
<div class="d-flex align-items-center justify-content-between mt-1">
|
||||
<span class="fs-5 fw-bold text-white" id="res-pkg-name">Full IPTV Package</span>
|
||||
<span class="badge bg-label-info" id="res-conn-count">
|
||||
<i class="ti tabler-devices me-1"></i>1 Connection
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Digital Countdown Expiry Box -->
|
||||
<div class="p-3 bg-light-subtle rounded-3 border mb-4">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3">
|
||||
<span class="small text-muted text-uppercase fw-semibold">
|
||||
<i class="ti tabler-clock text-warning me-1"></i>Subscription Remaining Time
|
||||
</span>
|
||||
<span class="small text-muted">
|
||||
Expires on: <span id="res-exp-formatted" class="font-monospace text-white fw-bold"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="countdown-grid" id="countdown-timer">
|
||||
<div class="countdown-tile">
|
||||
<div class="countdown-number" id="cd-days">00</div>
|
||||
<div class="countdown-title">Days</div>
|
||||
</div>
|
||||
<div class="countdown-tile">
|
||||
<div class="countdown-number" id="cd-hours">00</div>
|
||||
<div class="countdown-title">Hours</div>
|
||||
</div>
|
||||
<div class="countdown-tile">
|
||||
<div class="countdown-number" id="cd-minutes">00</div>
|
||||
<div class="countdown-title">Minutes</div>
|
||||
</div>
|
||||
<div class="countdown-tile">
|
||||
<div class="countdown-number" id="cd-seconds">00</div>
|
||||
<div class="countdown-title">Seconds</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Xtream Codes Credentials Card (Platform Design Standard) -->
|
||||
<div class="card bg-dark text-white border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-3 p-md-4">
|
||||
<h6 class="card-title text-white d-flex align-items-center gap-2 mb-3">
|
||||
<i class="ti tabler-device-tv text-warning fs-5"></i>Xtream Codes & Streaming Credentials
|
||||
</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="cred-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-secondary text-uppercase fw-semibold">Username</small>
|
||||
<button class="btn btn-sm btn-icon btn-outline-secondary py-0 px-2 btn-copy-elem" data-target="res-username" title="Copy Username">
|
||||
<i class="ti tabler-copy fs-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="font-monospace fw-bold fs-5 text-white mt-1" id="res-username">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="cred-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-secondary text-uppercase fw-semibold">Password</small>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-icon btn-outline-secondary py-0 px-2" id="btn-toggle-pw" title="Show/Hide Password">
|
||||
<i class="ti tabler-eye fs-6" id="eye-pw-icon"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-icon btn-outline-secondary py-0 px-2 btn-copy-elem" data-target="res-password" title="Copy Password">
|
||||
<i class="ti tabler-copy fs-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-monospace fw-bold fs-5 text-white mt-1" id="res-password">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="cred-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-secondary text-uppercase fw-semibold">Server Host / URL</small>
|
||||
<button class="btn btn-sm btn-icon btn-outline-secondary py-0 px-2 btn-copy-elem" data-target="res-host" title="Copy Server Host">
|
||||
<i class="ti tabler-copy fs-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="font-monospace small text-white mt-1 text-truncate" id="res-host">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="cred-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-secondary text-uppercase fw-semibold">Port</small>
|
||||
<button class="btn btn-sm btn-icon btn-outline-secondary py-0 px-2 btn-copy-elem" data-target="res-port" title="Copy Port">
|
||||
<i class="ti tabler-copy fs-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="font-monospace fw-bold text-white mt-1" id="res-port">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary my-3">
|
||||
|
||||
<!-- Action Buttons Bar -->
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="#" id="res-m3u-hls" class="btn btn-outline-light flex-fill" target="_blank">
|
||||
<i class="ti tabler-download me-1"></i>Download M3U (HLS)
|
||||
</a>
|
||||
<a href="#" id="res-m3u-ts" class="btn btn-outline-light flex-fill" target="_blank">
|
||||
<i class="ti tabler-download me-1"></i>Download M3U (TS)
|
||||
</a>
|
||||
<button type="button" id="btn-copy-m3u" class="btn btn-primary flex-fill">
|
||||
<i class="ti tabler-copy me-1"></i>Copy M3U Link
|
||||
</button>
|
||||
<a href="#" id="res-launch-player" class="btn btn-warning fw-bold flex-fill d-none" target="_blank">
|
||||
<i class="ti tabler-player-play me-1"></i>Launch Web Player
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Options -->
|
||||
<div class="d-flex justify-content-between align-items-center pt-2">
|
||||
<button type="button" id="btn-portal-back" class="btn btn-label-secondary">
|
||||
<i class="ti tabler-arrow-left me-1"></i>Activate Another Voucher
|
||||
</button>
|
||||
<button type="button" onclick="window.print()" class="btn btn-label-secondary">
|
||||
<i class="ti tabler-printer me-1"></i>Print Voucher Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="text-center py-3 border-top mt-auto" style="border-color: rgba(255, 255, 255, 0.08) !important;">
|
||||
<div class="container-xxl">
|
||||
<small class="text-muted">
|
||||
© <?= date('Y'); ?> <?= htmlspecialchars($serverName, ENT_QUOTES); ?>. All rights reserved. Powered by Smart Activation Engine.
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap Bundle JS -->
|
||||
<script src="/assets/admin/vendor/js/bootstrap.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('portal-form');
|
||||
const codeInput = document.getElementById('activation-code');
|
||||
const submitBtn = document.getElementById('btn-activate-submit');
|
||||
const errorAlert = document.getElementById('portal-error-alert');
|
||||
const errorText = document.getElementById('portal-error-text');
|
||||
const activationCard = document.getElementById('activation-card');
|
||||
const successCard = document.getElementById('success-card');
|
||||
const toggleAdvBtn = document.getElementById('btn-toggle-advanced');
|
||||
const advBox = document.getElementById('advanced-device-box');
|
||||
const pasteBtn = document.getElementById('btn-paste-code');
|
||||
|
||||
let countdownInterval = null;
|
||||
let originalPassword = '';
|
||||
let passwordHidden = false;
|
||||
|
||||
// Auto uppercase & clean code input
|
||||
codeInput.addEventListener('input', function() {
|
||||
this.value = this.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
});
|
||||
|
||||
// Advanced device toggle
|
||||
if (toggleAdvBtn && advBox) {
|
||||
toggleAdvBtn.addEventListener('click', function() {
|
||||
advBox.classList.toggle('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
// Paste button
|
||||
if (pasteBtn && navigator.clipboard) {
|
||||
pasteBtn.addEventListener('click', function() {
|
||||
navigator.clipboard.readText().then(text => {
|
||||
if (text) {
|
||||
codeInput.value = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
codeInput.focus();
|
||||
}
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// Form Submit via AJAX
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const code = codeInput.value.trim();
|
||||
if (!code) return;
|
||||
|
||||
const mac = document.getElementById('device_mac') ? document.getElementById('device_mac').value.trim() : '';
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Activating...';
|
||||
errorAlert.classList.add('d-none');
|
||||
|
||||
const postUrl = window.location.pathname + window.location.search;
|
||||
fetch(postUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
action: 'activate',
|
||||
code: code,
|
||||
mac: mac
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="ti tabler-bolt fs-4 me-2"></i>Activate Subscription';
|
||||
|
||||
if (data.status !== 'SUCCESS') {
|
||||
errorText.textContent = data.message || 'Activation failed.';
|
||||
errorAlert.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate success card
|
||||
document.getElementById('res-active-code').textContent = code;
|
||||
document.getElementById('btn-copy-code-val').setAttribute('data-code', code);
|
||||
document.getElementById('res-pkg-badge').textContent = data.package_name || 'Active Package';
|
||||
document.getElementById('res-pkg-name').textContent = data.package_name || 'Active Package';
|
||||
document.getElementById('res-exp-formatted').textContent = data.exp_date_formatted || '';
|
||||
|
||||
if (data.credentials) {
|
||||
document.getElementById('res-host').textContent = data.credentials.host || '';
|
||||
document.getElementById('res-username').textContent = data.credentials.username || '';
|
||||
originalPassword = data.credentials.password || '';
|
||||
document.getElementById('res-password').textContent = originalPassword;
|
||||
|
||||
// Extract port if present
|
||||
const hostParts = (data.credentials.host || '').split(':');
|
||||
const port = hostParts.length > 2 ? hostParts[hostParts.length - 1].split('/')[0] : '80';
|
||||
document.getElementById('res-port').textContent = port;
|
||||
}
|
||||
|
||||
// Playlists
|
||||
if (data.playlists) {
|
||||
document.getElementById('res-m3u-hls').href = data.playlists.m3u_hls || '#';
|
||||
document.getElementById('res-m3u-ts').href = data.playlists.m3u_ts || '#';
|
||||
document.getElementById('btn-copy-m3u').setAttribute('data-url', data.playlists.m3u_hls || '');
|
||||
}
|
||||
|
||||
// Web Player button
|
||||
const pBtn = document.getElementById('res-launch-player');
|
||||
if (data.web_player_url && pBtn) {
|
||||
pBtn.href = data.web_player_url;
|
||||
pBtn.classList.remove('d-none');
|
||||
} else if (pBtn) {
|
||||
pBtn.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Start countdown
|
||||
startCountdown(data.exp_date);
|
||||
|
||||
// Transition cards
|
||||
activationCard.classList.add('d-none');
|
||||
successCard.classList.remove('d-none');
|
||||
successCard.scrollIntoView({ behavior: 'smooth' });
|
||||
})
|
||||
.catch(err => {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="ti tabler-bolt fs-4 me-2"></i>Activate Subscription';
|
||||
errorText.textContent = 'Network or connection error. Please try again.';
|
||||
errorAlert.classList.remove('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
// Countdown Timer Logic
|
||||
function startCountdown(expTimestamp) {
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
|
||||
function update() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = expTimestamp - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
document.getElementById('cd-days').textContent = '00';
|
||||
document.getElementById('cd-hours').textContent = '00';
|
||||
document.getElementById('cd-minutes').textContent = '00';
|
||||
document.getElementById('cd-seconds').textContent = '00';
|
||||
const badge = document.getElementById('res-status-badge');
|
||||
if (badge) {
|
||||
badge.className = 'badge bg-danger fs-6 px-3 py-2';
|
||||
badge.innerHTML = '<i class="ti tabler-clock-x me-1"></i>Expired';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(diff / 86400);
|
||||
const hours = Math.floor((diff % 86400) / 3600);
|
||||
const minutes = Math.floor((diff % 3600) / 60);
|
||||
const seconds = diff % 60;
|
||||
|
||||
document.getElementById('cd-days').textContent = String(days).padStart(2, '0');
|
||||
document.getElementById('cd-hours').textContent = String(hours).padStart(2, '0');
|
||||
document.getElementById('cd-minutes').textContent = String(minutes).padStart(2, '0');
|
||||
document.getElementById('cd-seconds').textContent = String(seconds).padStart(2, '0');
|
||||
}
|
||||
|
||||
update();
|
||||
countdownInterval = setInterval(update, 1000);
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = String(text);
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
textarea.setAttribute('readonly', '');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
success ? resolve() : reject();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Copy Handlers
|
||||
document.querySelectorAll('.btn-copy-elem').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const text = document.getElementById(targetId).textContent.trim();
|
||||
copyToClipboard(text).then(() => {
|
||||
const icon = this.querySelector('i');
|
||||
const origClass = icon.className;
|
||||
icon.className = 'ti tabler-check text-success fs-6';
|
||||
setTimeout(() => icon.className = origClass, 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy to clipboard:', text);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const copyCodeBtn = document.getElementById('btn-copy-code-val');
|
||||
if (copyCodeBtn) {
|
||||
copyCodeBtn.addEventListener('click', function() {
|
||||
const code = this.getAttribute('data-code');
|
||||
copyToClipboard(code).then(() => {
|
||||
const orig = copyCodeBtn.innerHTML;
|
||||
copyCodeBtn.innerHTML = '<i class="ti tabler-check text-success me-1"></i>Copied!';
|
||||
setTimeout(() => copyCodeBtn.innerHTML = orig, 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy code:', code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const copyM3uBtn = document.getElementById('btn-copy-m3u');
|
||||
if (copyM3uBtn) {
|
||||
copyM3uBtn.addEventListener('click', function() {
|
||||
const url = this.getAttribute('data-url');
|
||||
copyToClipboard(url).then(() => {
|
||||
const orig = copyM3uBtn.innerHTML;
|
||||
copyM3uBtn.innerHTML = '<i class="ti tabler-check me-1"></i>Copied M3U Link!';
|
||||
setTimeout(() => copyM3uBtn.innerHTML = orig, 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy M3U Link:', url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle password visibility
|
||||
const togglePwBtn = document.getElementById('btn-toggle-pw');
|
||||
if (togglePwBtn) {
|
||||
togglePwBtn.addEventListener('click', function() {
|
||||
const pwElem = document.getElementById('res-password');
|
||||
const icon = document.getElementById('eye-pw-icon');
|
||||
if (passwordHidden) {
|
||||
pwElem.textContent = originalPassword;
|
||||
icon.className = 'ti tabler-eye fs-6';
|
||||
passwordHidden = false;
|
||||
} else {
|
||||
pwElem.textContent = '••••••••••••';
|
||||
icon.className = 'ti tabler-eye-off fs-6';
|
||||
passwordHidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Back to activation form
|
||||
const backBtn = document.getElementById('btn-portal-back');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
codeInput.value = '';
|
||||
successCard.classList.add('d-none');
|
||||
activationCard.classList.remove('d-none');
|
||||
codeInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-submit if auto query param is present
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('auto') === '1' && codeInput.value.trim().length >= 4) {
|
||||
form.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,724 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Generate Active Codes (Bootstrap 5, Reseller)
|
||||
*
|
||||
* Interactive creation wizard for Smart Activation Codes with live credit calculation,
|
||||
* batch naming, bouquet customization, and instant result export.
|
||||
*/
|
||||
|
||||
|
||||
$rUserInfo = $GLOBALS['rUserInfo'] ?? [];
|
||||
$rPermissions = $GLOBALS['rPermissions'] ?? [];
|
||||
|
||||
$rPackages = $rPackages ?? [];
|
||||
$rBouquets = $rBouquets ?? [];
|
||||
$userCredits = floatval($rUserInfo['credits'] ?? 0);
|
||||
|
||||
// Pre-calculate package prices with override
|
||||
$packagePrices = [];
|
||||
$override = json_decode((string)($rUserInfo['override_packages'] ?? ''), true) ?: [];
|
||||
foreach ($rPackages as $pkg) {
|
||||
$pkgId = (int)$pkg['id'];
|
||||
$isTrial = !empty($pkg['is_trial']);
|
||||
if ($isTrial) {
|
||||
$cost = floatval($pkg['trial_credits'] ?? 0);
|
||||
} else {
|
||||
if (isset($override[$pkgId]['official_credits']) && strlen((string)$override[$pkgId]['official_credits']) > 0) {
|
||||
$cost = floatval($override[$pkgId]['official_credits']);
|
||||
} else {
|
||||
$cost = floatval($pkg['official_credits'] ?? 0);
|
||||
}
|
||||
}
|
||||
$packagePrices[$pkgId] = [
|
||||
'cost' => $cost,
|
||||
'is_trial' => $isTrial,
|
||||
'name' => (string)$pkg['package_name'],
|
||||
'duration' => (int)($isTrial ? $pkg['trial_duration'] : $pkg['official_duration']),
|
||||
'duration_in' => (string)($isTrial ? $pkg['trial_duration_in'] : $pkg['official_duration_in']),
|
||||
'max_connections' => (int)($pkg['max_connections'] ?: 1),
|
||||
'forced_country' => (string)($pkg['forced_country'] ?? ''),
|
||||
'bouquets' => json_decode((string)($pkg['bouquets'] ?? '[]'), true) ?: [],
|
||||
];
|
||||
}
|
||||
|
||||
// Streaming DNS options
|
||||
$dnsList = array_filter(array_map('trim', explode(',', (string)($rUserInfo['reseller_dns'] ?? ''))));
|
||||
?>
|
||||
<style>
|
||||
.bq-wrapper {
|
||||
background-color: var(--bs-body-bg, #25293c);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.bq-scroll-area {
|
||||
max-height: 290px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.bq-tile {
|
||||
background-color: var(--bs-card-bg, #2f3349);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bq-tile:hover {
|
||||
border-color: #7367f0;
|
||||
background-color: rgba(115, 103, 240, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bq-tile.is-checked {
|
||||
border-color: #7367f0 !important;
|
||||
background-color: rgba(115, 103, 240, 0.12) !important;
|
||||
}
|
||||
|
||||
.bq-filter-tab.active {
|
||||
background-color: #7367f0 !important;
|
||||
color: #fff !important;
|
||||
border-color: #7367f0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left Column: Generator Form -->
|
||||
<div class="col-12 col-xl-8" id="generator-form-col">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header border-bottom d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="card-title mb-1"><i class="ti tabler-plus text-primary me-2"></i><?= $language::get('generate_codes') ?: 'Generate Active Codes'; ?></h5>
|
||||
<p class="text-muted small mb-0">Pre-generate stock activation vouchers for clients. Credits are deducted, but countdown begins only upon first activation.</p>
|
||||
</div>
|
||||
<a href="active_codes" class="btn btn-sm btn-label-secondary">
|
||||
<i class="ti tabler-arrow-left me-1"></i>Back to Codes
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<form id="active-code-form">
|
||||
<!-- Batch Information -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-8">
|
||||
<label class="form-label fw-semibold" for="batch_name">Batch Name / Voucher Label</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="ti tabler-tag"></i></span>
|
||||
<input type="text" id="batch_name" name="batch_name" class="form-control font-monospace" placeholder="BATCH-202609-XXXX" value="BATCH-<?= date('Ymd'); ?>-<?= strtoupper(substr(bin2hex(random_bytes(2)), 0, 4)); ?>">
|
||||
<button type="button" class="btn btn-outline-secondary" id="btn-rand-batch" title="Generate New Batch ID">
|
||||
<i class="ti tabler-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text small">Used to group vouchers for batch export, printing, and management.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label fw-semibold" for="code_format">Code Format</label>
|
||||
<select id="code_format" name="code_format" class="form-select">
|
||||
<option value="alphanumeric" selected>Alphanumeric (8X7K9P2M)</option>
|
||||
<option value="numeric">Numeric PIN (87219430)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 text-muted opacity-25">
|
||||
|
||||
<!-- Quantity & Code Length -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-7">
|
||||
<label class="form-label fw-semibold" for="num_codes">Quantity of Codes to Generate</label>
|
||||
<div class="input-group">
|
||||
<input type="number" id="num_codes" name="num_codes" class="form-control fs-5 fw-bold text-center" value="1" min="1" max="500">
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="5">5</button>
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="10">10</button>
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="25">25</button>
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="50">50</button>
|
||||
<button type="button" class="btn btn-outline-secondary qty-pill" data-qty="100">100</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label fw-semibold" for="code_length">Code Character Length</label>
|
||||
<select id="code_length" name="code_length" class="form-select">
|
||||
<option value="8">8 Characters</option>
|
||||
<option value="10" selected>10 Characters (Standard)</option>
|
||||
<option value="12">12 Characters</option>
|
||||
<option value="16">16 Characters</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Package Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold" for="package_id">Target Subscription Package <span class="text-danger">*</span></label>
|
||||
<select id="package_id" name="package_id" class="form-select form-select-lg" required>
|
||||
<option value="" disabled selected>-- Select a Package --</option>
|
||||
<?php foreach ($rPackages as $pkg): ?>
|
||||
<option value="<?= (int)$pkg['id']; ?>" data-cost="<?= $packagePrices[(int)$pkg['id']]['cost']; ?>" data-trial="<?= $packagePrices[(int)$pkg['id']]['is_trial'] ? 1 : 0; ?>">
|
||||
<?= htmlspecialchars((string)$pkg['package_name'], ENT_QUOTES); ?>
|
||||
(<?= $packagePrices[(int)$pkg['id']]['cost']; ?> Credits)
|
||||
<?= $packagePrices[(int)$pkg['id']]['is_trial'] ? ' - [TRIAL]' : ''; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Bouquets Customization -->
|
||||
<div class="mb-4">
|
||||
<div class="bq-wrapper p-3">
|
||||
<!-- Header Toolbar -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="avatar avatar-xs bg-label-primary rounded-2 p-1 d-flex align-items-center justify-content-center" style="width: 34px; height: 34px;">
|
||||
<i class="ti tabler-category-2 fs-5 text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label fw-bold mb-0 fs-6">Bouquets Included</label>
|
||||
<div class="small text-muted">Select streaming content categories for this voucher</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-label-primary px-3 py-2 fs-6" id="bq-selected-count">
|
||||
<?= count($rBouquets); ?> / <?= count($rBouquets); ?> Selected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar & Action Buttons -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3 pb-2 border-bottom border-secondary border-opacity-10">
|
||||
<!-- Search -->
|
||||
<div class="input-group input-group-sm" style="max-width: 260px;">
|
||||
<span class="input-group-text bg-transparent"><i class="ti tabler-search"></i></span>
|
||||
<input type="text" id="filter-bq-input" class="form-control" placeholder="Search bouquets...">
|
||||
<button type="button" class="btn btn-outline-secondary d-none" id="btn-clear-bq-search">
|
||||
<i class="ti tabler-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<button type="button" class="btn btn-sm btn-label-primary" id="btn-select-all-bq" title="Select all bouquets">
|
||||
<i class="ti tabler-checks me-1"></i>Select All
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-label-secondary" id="btn-deselect-all-bq" title="Deselect all">
|
||||
<i class="ti tabler-x me-1"></i>Clear All
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-label-info" id="btn-invert-bq" title="Invert selection">
|
||||
<i class="ti tabler-arrows-shuffle me-1"></i>Invert
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-label-danger" id="btn-filter-adult" title="Exclude adult 18+ content">
|
||||
<i class="ti tabler-shield-lock me-1"></i>Exclude 18+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs: All | Selected | Unselected | 18+ -->
|
||||
<div class="d-flex flex-wrap gap-1 mb-3">
|
||||
<button type="button" class="btn btn-xs btn-label-secondary bq-filter-tab active" data-filter="all">
|
||||
All (<span class="tab-count-all"><?= count($rBouquets); ?></span>)
|
||||
</button>
|
||||
<button type="button" class="btn btn-xs btn-label-secondary bq-filter-tab" data-filter="selected">
|
||||
Selected (<span class="tab-count-sel"><?= count($rBouquets); ?></span>)
|
||||
</button>
|
||||
<button type="button" class="btn btn-xs btn-label-secondary bq-filter-tab" data-filter="unselected">
|
||||
Unselected (<span class="tab-count-unsel">0</span>)
|
||||
</button>
|
||||
<?php
|
||||
$adultCount = 0;
|
||||
foreach ($rBouquets as $b) {
|
||||
$bn = (string)$b['bouquet_name'];
|
||||
if (stripos($bn, 'adult') !== false || stripos($bn, 'xxx') !== false || stripos($bn, '+18') !== false || stripos($bn, '18+') !== false) {
|
||||
$adultCount++;
|
||||
}
|
||||
}
|
||||
if ($adultCount > 0):
|
||||
?>
|
||||
<button type="button" class="btn btn-xs btn-label-danger bq-filter-tab" data-filter="adult">
|
||||
<i class="ti tabler-lock me-1"></i>18+ Adult (<?= $adultCount; ?>)
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Bouquets Grid with Custom Scroll -->
|
||||
<div class="bq-scroll-area p-1">
|
||||
<div class="row g-2" id="bouquets-grid">
|
||||
<?php foreach ($rBouquets as $bq):
|
||||
$bqName = (string)$bq['bouquet_name'];
|
||||
$isAdult = (stripos($bqName, 'adult') !== false || stripos($bqName, 'xxx') !== false || stripos($bqName, '+18') !== false || stripos($bqName, '18+') !== false);
|
||||
?>
|
||||
<div class="col-12 col-md-6 bouquet-item" data-name="<?= strtolower(htmlspecialchars($bqName, ENT_QUOTES)); ?>" data-adult="<?= $isAdult ? 1 : 0; ?>">
|
||||
<label class="bq-tile is-checked d-flex align-items-center justify-content-between p-2 px-3 w-100 mb-0" for="bq_<?= (int)$bq['id']; ?>">
|
||||
<div class="d-flex align-items-center gap-2 text-truncate me-2">
|
||||
<input class="form-check-input bq-checkbox m-0 flex-shrink-0" type="checkbox" name="bouquets_selected[]" value="<?= (int)$bq['id']; ?>" id="bq_<?= (int)$bq['id']; ?>" checked>
|
||||
<span class="bq-name small fw-semibold text-truncate text-body"><?= htmlspecialchars($bqName, ENT_QUOTES); ?></span>
|
||||
</div>
|
||||
<?php if ($isAdult): ?>
|
||||
<span class="badge bg-label-danger flex-shrink-0 px-2 py-1" style="font-size: 0.65rem;">
|
||||
<i class="ti tabler-lock me-1"></i>18+
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-label-secondary flex-shrink-0 px-2 py-1" style="font-size: 0.65rem;">
|
||||
<i class="ti tabler-device-tv text-primary"></i>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div id="bq-no-results" class="col-12 text-center py-4 text-muted d-none">
|
||||
<i class="ti tabler-folder-off fs-1 mb-1 d-block text-secondary"></i>
|
||||
<div>No bouquets found matching your filter.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Routing Options -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold" for="dns_base">Custom DNS Portal (Optional)</label>
|
||||
<select id="dns_base" name="dns_base" class="form-select">
|
||||
<option value="">Default Server Domain</option>
|
||||
<?php foreach ($dnsList as $dns): ?>
|
||||
<option value="<?= htmlspecialchars($dns, ENT_QUOTES); ?>"><?= htmlspecialchars($dns, ENT_QUOTES); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold" for="forced_country">Geo-Lock Country (Optional)</label>
|
||||
<input type="text" id="forced_country" name="forced_country" class="form-control text-uppercase" maxlength="2" placeholder="e.g. US, EG, SA, GB, or leave empty">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex justify-content-end gap-3 pt-3 border-top">
|
||||
<button type="reset" class="btn btn-label-secondary">Reset</button>
|
||||
<button type="submit" class="btn btn-primary px-4" id="btn-generate-submit">
|
||||
<i class="ti tabler-sparkles me-1"></i>Generate Codes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Live Credit Calculator & Guidelines -->
|
||||
<div class="col-12 col-xl-4">
|
||||
<!-- Live Credit Calculator Card -->
|
||||
<div class="card shadow-sm border-0 mb-4 bg-primary text-white">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="text-white-50 text-uppercase fw-semibold small">Credit Accounting</span>
|
||||
<i class="ti tabler-wallet fs-3 text-white"></i>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<small class="text-white-50 d-block">Your Current Balance</small>
|
||||
<h2 class="text-white fw-bold mb-0" id="card-current-balance"><?= number_format($userCredits, 2); ?> <span class="fs-6 fw-normal">Credits</span></h2>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-white bg-opacity-10 rounded-3 mb-3">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="small text-white-50">Cost Per Voucher:</span>
|
||||
<span class="fw-semibold" id="card-cost-per-code">0.00 Credits</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="small text-white-50">Quantity:</span>
|
||||
<span class="fw-semibold" id="card-qty">1</span>
|
||||
</div>
|
||||
<hr class="border-white opacity-25 my-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">Total Cost:</span>
|
||||
<span class="fs-5 fw-bold text-warning" id="card-total-cost">0.00 Credits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center p-2 rounded-3 bg-black bg-opacity-25" id="balance-after-box">
|
||||
<span class="small text-white-50">Balance After Generation:</span>
|
||||
<span class="fw-bold" id="card-balance-after"><?= number_format($userCredits, 2); ?> Credits</span>
|
||||
</div>
|
||||
|
||||
<div id="insufficient-balance-alert" class="alert alert-danger bg-danger text-white border-0 mt-3 d-none mb-0">
|
||||
<i class="ti tabler-alert-circle me-1"></i> Insufficient balance for this generation request.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stock Mode Explainer Card -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
<h6 class="fw-semibold mb-3 d-flex align-items-center gap-2">
|
||||
<i class="ti tabler-info-circle text-info"></i>How Active Codes Work
|
||||
</h6>
|
||||
<ul class="list-unstyled mb-0 d-flex flex-column gap-3 small text-muted">
|
||||
<li class="d-flex gap-2">
|
||||
<i class="ti tabler-snowflake text-primary fs-5 mt-1"></i>
|
||||
<div>
|
||||
<strong class="text-dark d-block">Stock Mode (Delayed Timer)</strong>
|
||||
Codes sit safely in your inventory without aging. The 30-day (or 1-year) countdown is triggered only when the buyer inputs the code into their TV app or web portal.
|
||||
</div>
|
||||
</li>
|
||||
<li class="d-flex gap-2">
|
||||
<i class="ti tabler-folders text-success fs-5 mt-1"></i>
|
||||
<div>
|
||||
<strong class="text-dark d-block">Scratch-Card Batch Printing</strong>
|
||||
Group vouchers by batch and download ready-to-print formatted text files for physical cards or retail store distribution.
|
||||
</div>
|
||||
</li>
|
||||
<li class="d-flex gap-2">
|
||||
<i class="ti tabler-shield-check text-warning fs-5 mt-1"></i>
|
||||
<div>
|
||||
<strong class="text-dark d-block">Safe Refund on Unused Vouchers</strong>
|
||||
If you ever delete a batch of unused codes, your credits are automatically returned to your reseller balance.
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Results Container (Shown after generation) -->
|
||||
<div id="generation-results-card" class="card shadow-sm border-0 d-none mt-4">
|
||||
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center py-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="ti tabler-circle-check fs-4"></i>
|
||||
<h5 class="text-white mb-0" id="res-title">Codes Generated Successfully!</h5>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-light" id="btn-copy-all-res">
|
||||
<i class="ti tabler-copy me-1"></i>Copy All Codes
|
||||
</button>
|
||||
<a href="active_codes" class="btn btn-sm btn-outline-light">View in Inventory</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="alert alert-primary d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<strong>Batch Name:</strong> <span class="font-monospace fw-bold" id="res-batch-name"></span>
|
||||
<span class="mx-2">•</span>
|
||||
<strong>Total Vouchers:</strong> <span class="fw-bold" id="res-qty"></span>
|
||||
</div>
|
||||
<a href="#" id="res-batch-link" class="btn btn-sm btn-primary">Open in Batch Manager</a>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Activation Code</th>
|
||||
<th>Companion Username</th>
|
||||
<th>Companion Password</th>
|
||||
<th>Initial Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="res-codes-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
require_once __DIR__ . '/../layouts/footer.php';
|
||||
renderUnifiedLayoutFooter('reseller');
|
||||
?>
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
$(function() {
|
||||
const userCredits = <?= json_encode($userCredits); ?>;
|
||||
const packagePrices = <?= json_encode($packagePrices); ?>;
|
||||
let currentGeneratedCodes = [];
|
||||
|
||||
// Pill clicks for quick quantity
|
||||
jQuery('.qty-pill').on('click', function() {
|
||||
jQuery('#num_codes').val(jQuery(this).data('qty')).trigger('input');
|
||||
});
|
||||
|
||||
// Random Batch Name generator button
|
||||
jQuery('#btn-rand-batch').on('click', function() {
|
||||
const rand = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
jQuery('#batch_name').val(`BATCH-${dateStr}-${rand}`);
|
||||
});
|
||||
|
||||
// Dynamic Credit Calculator
|
||||
function updateCalculator() {
|
||||
const pkgId = jQuery('#package_id').val();
|
||||
const qty = Math.max(1, parseInt(jQuery('#num_codes').val(), 10) || 1);
|
||||
let costPerCode = 0;
|
||||
|
||||
if (pkgId && packagePrices[pkgId]) {
|
||||
costPerCode = packagePrices[pkgId].cost;
|
||||
}
|
||||
|
||||
const totalCost = qty * costPerCode;
|
||||
const balanceAfter = userCredits - totalCost;
|
||||
|
||||
jQuery('#card-qty').text(qty);
|
||||
jQuery('#card-cost-per-code').text(costPerCode.toFixed(2) + ' Credits');
|
||||
jQuery('#card-total-cost').text(totalCost.toFixed(2) + ' Credits');
|
||||
jQuery('#card-balance-after').text(balanceAfter.toFixed(2) + ' Credits');
|
||||
|
||||
if (balanceAfter < 0) {
|
||||
jQuery('#insufficient-balance-alert').removeClass('d-none');
|
||||
jQuery('#btn-generate-submit').prop('disabled', true);
|
||||
jQuery('#card-balance-after').addClass('text-danger');
|
||||
} else {
|
||||
jQuery('#insufficient-balance-alert').addClass('d-none');
|
||||
jQuery('#btn-generate-submit').prop('disabled', false);
|
||||
jQuery('#card-balance-after').removeClass('text-danger');
|
||||
}
|
||||
}
|
||||
|
||||
jQuery('#package_id, #num_codes').on('change input', updateCalculator);
|
||||
|
||||
// Bouquets Selection & Interactive Filtering
|
||||
function updateBouquetCounts() {
|
||||
const total = jQuery('.bq-checkbox').length;
|
||||
const selected = jQuery('.bq-checkbox:checked').length;
|
||||
const unselected = total - selected;
|
||||
|
||||
jQuery('#bq-selected-count').text(`${selected} / ${total} Selected`);
|
||||
jQuery('.tab-count-all').text(total);
|
||||
jQuery('.tab-count-sel').text(selected);
|
||||
jQuery('.tab-count-unsel').text(unselected);
|
||||
|
||||
// Update tile styles
|
||||
jQuery('.bq-checkbox').each(function() {
|
||||
const tile = jQuery(this).closest('.bq-tile');
|
||||
if (this.checked) {
|
||||
tile.addClass('is-checked');
|
||||
} else {
|
||||
tile.removeClass('is-checked');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle tile state on change
|
||||
jQuery(document).on('change', '.bq-checkbox', function() {
|
||||
updateBouquetCounts();
|
||||
applyBouquetFilter();
|
||||
});
|
||||
|
||||
// Select All
|
||||
jQuery('#btn-select-all-bq').on('click', function() {
|
||||
jQuery('.bouquet-item:visible .bq-checkbox').prop('checked', true);
|
||||
updateBouquetCounts();
|
||||
});
|
||||
|
||||
// Clear All
|
||||
jQuery('#btn-deselect-all-bq').on('click', function() {
|
||||
jQuery('.bouquet-item:visible .bq-checkbox').prop('checked', false);
|
||||
updateBouquetCounts();
|
||||
});
|
||||
|
||||
// Invert Selection
|
||||
jQuery('#btn-invert-bq').on('click', function() {
|
||||
jQuery('.bouquet-item:visible .bq-checkbox').each(function() {
|
||||
this.checked = !this.checked;
|
||||
});
|
||||
updateBouquetCounts();
|
||||
});
|
||||
|
||||
// Exclude Adult
|
||||
jQuery('#btn-filter-adult').on('click', function() {
|
||||
jQuery('.bouquet-item[data-adult="1"] .bq-checkbox').prop('checked', false);
|
||||
updateBouquetCounts();
|
||||
});
|
||||
|
||||
// Tab Filter & Search Combined Function
|
||||
let currentBqFilter = 'all';
|
||||
|
||||
function applyBouquetFilter() {
|
||||
const term = (jQuery('#filter-bq-input').val() || '').toLowerCase().trim();
|
||||
let visibleCount = 0;
|
||||
|
||||
jQuery('.bouquet-item').each(function() {
|
||||
const item = jQuery(this);
|
||||
const name = item.data('name') || '';
|
||||
const isAdult = item.data('adult') == 1;
|
||||
const isChecked = item.find('.bq-checkbox').is(':checked');
|
||||
|
||||
const matchesSearch = !term || name.includes(term);
|
||||
let matchesTab = true;
|
||||
|
||||
if (currentBqFilter === 'selected') {
|
||||
matchesTab = isChecked;
|
||||
} else if (currentBqFilter === 'unselected') {
|
||||
matchesTab = !isChecked;
|
||||
} else if (currentBqFilter === 'adult') {
|
||||
matchesTab = isAdult;
|
||||
}
|
||||
|
||||
const isVisible = matchesSearch && matchesTab;
|
||||
item.toggle(isVisible);
|
||||
if (isVisible) visibleCount++;
|
||||
});
|
||||
|
||||
jQuery('#bq-no-results').toggleClass('d-none', visibleCount > 0);
|
||||
}
|
||||
|
||||
jQuery('.bq-filter-tab').on('click', function() {
|
||||
jQuery('.bq-filter-tab').removeClass('active');
|
||||
jQuery(this).addClass('active');
|
||||
currentBqFilter = jQuery(this).data('filter');
|
||||
applyBouquetFilter();
|
||||
});
|
||||
|
||||
jQuery('#filter-bq-input').on('input keyup', function() {
|
||||
const hasText = (this.value.trim().length > 0);
|
||||
jQuery('#btn-clear-bq-search').toggleClass('d-none', !hasText);
|
||||
applyBouquetFilter();
|
||||
});
|
||||
|
||||
jQuery('#btn-clear-bq-search').on('click', function() {
|
||||
jQuery('#filter-bq-input').val('');
|
||||
jQuery(this).addClass('d-none');
|
||||
applyBouquetFilter();
|
||||
jQuery('#filter-bq-input').focus();
|
||||
});
|
||||
|
||||
updateBouquetCounts();
|
||||
|
||||
// Auto-check bouquets matching selected package
|
||||
jQuery('#package_id').on('change', function() {
|
||||
const pkgId = this.value;
|
||||
if (packagePrices[pkgId] && packagePrices[pkgId].bouquets) {
|
||||
const allowed = new Set(packagePrices[pkgId].bouquets.map(Number));
|
||||
jQuery('.bq-checkbox').each(function() {
|
||||
this.checked = allowed.has(Number(this.value));
|
||||
});
|
||||
updateBouquetCounts();
|
||||
}
|
||||
});
|
||||
|
||||
// Submit Form via AJAX
|
||||
jQuery('#active-code-form').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const btn = jQuery('#btn-generate-submit');
|
||||
const origText = btn.html();
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Generating...');
|
||||
|
||||
const formData = jQuery(this).serializeArray();
|
||||
const postData = {
|
||||
action: 'generate_active_codes'
|
||||
};
|
||||
formData.forEach(item => {
|
||||
if (item.name.endsWith('[]')) {
|
||||
const key = item.name.slice(0, -2);
|
||||
if (!postData[key]) postData[key] = [];
|
||||
postData[key].push(item.value);
|
||||
} else {
|
||||
postData[item.name] = item.value;
|
||||
}
|
||||
});
|
||||
|
||||
jQuery.post('./api', postData, function(res) {
|
||||
btn.prop('disabled', false).html(origText);
|
||||
let data = res;
|
||||
if (typeof res === 'string') {
|
||||
try {
|
||||
data = JSON.parse(res);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (!data.result) {
|
||||
alert(data.message || 'Generation failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
currentGeneratedCodes = data.codes || [];
|
||||
jQuery('#res-batch-name').text(data.batch_name);
|
||||
jQuery('#res-qty').text(data.qty);
|
||||
jQuery('#res-batch-link').attr('href', 'active_codes_batch');
|
||||
|
||||
let html = '';
|
||||
currentGeneratedCodes.forEach((c, idx) => {
|
||||
html += `
|
||||
<tr>
|
||||
<td>${idx + 1}</td>
|
||||
<td>
|
||||
<code class="fw-bold font-monospace text-primary fs-6">${c.code}</code>
|
||||
<button type="button" class="btn btn-sm btn-icon btn-text-secondary btn-copy-one" data-code="${c.code}"><i class="ti tabler-copy"></i></button>
|
||||
</td>
|
||||
<td class="font-monospace">${c.username}</td>
|
||||
<td class="font-monospace">${c.password}</td>
|
||||
<td><span class="badge bg-label-success badge-pulse">Ready (Stock)</span></td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
jQuery('#res-codes-table').html(html);
|
||||
jQuery('#generation-results-card').removeClass('d-none');
|
||||
// Smooth scroll to results
|
||||
document.getElementById('generation-results-card').scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}).fail(function() {
|
||||
btn.prop('disabled', false).html(origText);
|
||||
alert('Request failed. Please check your network connection.');
|
||||
});
|
||||
});
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = String(text);
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
textarea.setAttribute('readonly', '');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
success ? resolve() : reject();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Copy single code
|
||||
jQuery(document).on('click', '.btn-copy-one', function() {
|
||||
const code = jQuery(this).data('code');
|
||||
const btn = jQuery(this);
|
||||
const orig = btn.html();
|
||||
copyToClipboard(code).then(() => {
|
||||
btn.html('<i class="ti tabler-check text-success"></i>');
|
||||
setTimeout(() => btn.html(orig), 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy code:', code);
|
||||
});
|
||||
});
|
||||
|
||||
// Copy all generated codes
|
||||
jQuery('#btn-copy-all-res').on('click', function() {
|
||||
if (!currentGeneratedCodes.length) return;
|
||||
const text = currentGeneratedCodes.map(c => c.code).join("\n");
|
||||
const btn = jQuery(this);
|
||||
const orig = btn.html();
|
||||
copyToClipboard(text).then(() => {
|
||||
btn.html('<i class="ti tabler-check me-1"></i>Copied!');
|
||||
setTimeout(() => btn.html(orig), 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy codes:', text);
|
||||
});
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,633 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Reseller Active Codes (Bootstrap 5)
|
||||
*
|
||||
* Full-parity management table for Smart Activation Codes with delayed countdown
|
||||
* (Stock Mode), instant clipboard copy, live modal details, and floating mass actions bar.
|
||||
*/
|
||||
|
||||
?>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<h5 class="card-title mb-1"><i class="ti tabler-key text-primary me-2"></i><?= $language::get('active_codes') ?: 'Active Codes'; ?></h5>
|
||||
<p class="text-muted small mb-0">Pre-generated stock vouchers. Subscriptions count down only upon client's first activation.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="active_codes_batch" class="btn btn-label-secondary">
|
||||
<i class="ti tabler-folders me-1"></i><?= $language::get('batch_manager') ?: 'Batch Manager'; ?>
|
||||
</a>
|
||||
<a href="active_code" class="btn btn-primary">
|
||||
<i class="ti tabler-plus me-1"></i><?= $language::get('generate_codes') ?: 'Generate Codes'; ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="card-body border-bottom bg-light-subtle">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label small text-uppercase fw-semibold" for="filter-search"><?= $language::get('search') ?: 'Search'; ?></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="ti tabler-search"></i></span>
|
||||
<input type="text" id="filter-search" class="form-control" placeholder="Search by code, batch, subscriber, MAC...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-uppercase fw-semibold" for="filter-status">Status</label>
|
||||
<select id="filter-status" class="form-select">
|
||||
<option value="0">All Statuses</option>
|
||||
<option value="1">Ready (Stock / Unused)</option>
|
||||
<option value="2">Active (Streaming)</option>
|
||||
<option value="3">Expired</option>
|
||||
<option value="4">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-uppercase fw-semibold" for="filter-batch">Batch</label>
|
||||
<select id="filter-batch" class="form-select">
|
||||
<option value="">All Batches</option>
|
||||
<?php foreach ($batches as $b): ?>
|
||||
<option value="<?= htmlspecialchars((string)$b['batch_name'], ENT_QUOTES); ?>">
|
||||
<?= htmlspecialchars((string)$b['batch_name'], ENT_QUOTES); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label class="form-label small text-uppercase fw-semibold" for="filter-package">Package</label>
|
||||
<select id="filter-package" class="form-select">
|
||||
<option value="0">All Packages</option>
|
||||
<?php foreach ($rPackages as $pkg): ?>
|
||||
<option value="<?= (int)$pkg['id']; ?>">
|
||||
<?= htmlspecialchars((string)$pkg['package_name'], ENT_QUOTES); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DataTable Container -->
|
||||
<div class="card-datatable table-responsive">
|
||||
<table id="active-codes-table" class="table table-hover border-top">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 35px;">
|
||||
<input type="checkbox" id="select-all" class="form-check-input">
|
||||
</th>
|
||||
<th>Code</th>
|
||||
<th>Batch</th>
|
||||
<th>Package</th>
|
||||
<th>Status</th>
|
||||
<th>Expiration</th>
|
||||
<th>Subscriber</th>
|
||||
<th>Device Lock</th>
|
||||
<th>Created</th>
|
||||
<th class="text-center" style="width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Mass Actions Bar -->
|
||||
<div id="mass-action-bar" class="position-fixed bottom-0 start-50 translate-middle-x p-3 bg-dark text-white rounded-4 shadow-lg d-none align-items-center gap-3" style="z-index: 1080; min-width: 480px; max-width: 90%;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-primary fs-6 px-2 py-1" id="selected-count">0</span>
|
||||
<span class="small fw-semibold">Codes Selected</span>
|
||||
</div>
|
||||
<div class="vr bg-secondary opacity-50 my-1"></div>
|
||||
<div class="d-flex flex-wrap gap-2 ms-auto">
|
||||
<button type="button" class="btn btn-sm btn-success" id="btn-mass-enable">
|
||||
<i class="ti tabler-check me-1"></i>Enable
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-warning" id="btn-mass-disable">
|
||||
<i class="ti tabler-ban me-1"></i>Disable
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-info" id="btn-mass-extend">
|
||||
<i class="ti tabler-calendar-plus me-1"></i>Extend
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="btn-mass-reset">
|
||||
<i class="ti tabler-device-desktop-off me-1"></i>Reset Device
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" id="btn-mass-delete">
|
||||
<i class="ti tabler-trash me-1"></i>Delete
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light" id="btn-mass-cancel">
|
||||
<i class="ti tabler-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code Details Modal -->
|
||||
<div class="modal fade" id="codeDetailsModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-bottom">
|
||||
<h5 class="modal-title d-flex align-items-center gap-2">
|
||||
<i class="ti tabler-key text-primary"></i>
|
||||
<span>Activation Code Details</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4" id="modal-details-body">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-top">
|
||||
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extend Days Modal -->
|
||||
<div class="modal fade" id="extendModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Extend Active Codes</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label" for="extend-days">Number of Days</label>
|
||||
<input type="number" id="extend-days" class="form-control" value="30" min="1" max="365">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-confirm-extend">Extend Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-bottom">
|
||||
<h5 class="modal-title text-danger"><i class="ti tabler-alert-triangle me-2"></i>Confirm Deletion</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
<p class="mb-3">Are you sure you want to delete the selected activation codes? This will also remove their associated subscriber lines.</p>
|
||||
<div class="form-check p-3 bg-light rounded-3 border">
|
||||
<input class="form-check-input" type="checkbox" id="delete-refund" checked>
|
||||
<label class="form-check-label fw-semibold" for="delete-refund">
|
||||
Refund credits for unactivated stock codes
|
||||
</label>
|
||||
<div class="form-text small text-muted">Any codes that have not yet been activated by a client will have their purchase credits refunded to your balance.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-top">
|
||||
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-confirm-delete">Delete Codes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.badge-pulse {
|
||||
animation: pulse-green 2s infinite;
|
||||
}
|
||||
@keyframes pulse-green {
|
||||
0% { box-shadow: 0 0 0 0 rgba(40, 199, 111, 0.7); }
|
||||
70% { box-shadow: 0 0 0 8px rgba(40, 199, 111, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(40, 199, 111, 0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<?php
|
||||
require_once __DIR__ . '/../layouts/footer.php';
|
||||
renderUnifiedLayoutFooter('reseller');
|
||||
?>
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
$(function() {
|
||||
const tableEl = $('#active-codes-table');
|
||||
let selectedIds = new Set();
|
||||
|
||||
// Escape helpers + cell renderers (server now returns a clean keyed payload;
|
||||
// all badges / actions are built client-side).
|
||||
function escHtml(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s).replace(/[&<>"']/g, function(c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function renderCheckbox(d, type, row) {
|
||||
const code = escHtml(row.code);
|
||||
return '<input type="checkbox" class="form-check-input row-select" value="' + row.id + '" data-code="' + code + '">';
|
||||
}
|
||||
function renderCode(d, type, row) {
|
||||
const code = escHtml(row.code);
|
||||
return '<div class="d-flex align-items-center gap-2">' +
|
||||
'<code class="fw-bold font-monospace text-primary fs-6 user-select-all">' + code + '</code>' +
|
||||
'<button type="button" class="btn btn-sm btn-icon btn-outline-secondary btn-copy-code" data-code="' + code + '" title="Copy Code"><i class="ti tabler-copy"></i></button>' +
|
||||
'</div>';
|
||||
}
|
||||
function renderBatch(d) {
|
||||
return '<span class="badge bg-label-secondary font-monospace">' + escHtml(d) + '</span>';
|
||||
}
|
||||
function renderPackage(d, type, row) {
|
||||
let html = '<span class="badge bg-label-info">' + escHtml(row.package_name) + '</span>';
|
||||
if (row.is_trial) html += ' <span class="badge bg-label-warning ms-1">Trial</span>';
|
||||
return html;
|
||||
}
|
||||
function renderStatus(d, type, row) {
|
||||
if (row.status === 0) return '<span class="badge bg-label-danger"><i class="ti tabler-ban me-1"></i>Disabled</span>';
|
||||
if (row.status === 1) return '<span class="badge bg-label-success badge-pulse"><i class="ti tabler-sparkles me-1"></i>Ready (Stock)</span>';
|
||||
if (row.exp_expired) return '<span class="badge bg-label-secondary"><i class="ti tabler-clock-off me-1"></i>Expired</span>';
|
||||
return '<span class="badge bg-label-primary"><i class="ti tabler-player-play me-1"></i>Active</span>';
|
||||
}
|
||||
function renderExpiry(d, type, row) {
|
||||
if (row.status === 0) return '<span class="text-muted fst-italic">Suspended</span>';
|
||||
if (row.status === 1) return '<span class="text-muted"><i class="ti tabler-snowflake me-1"></i>Frozen</span>';
|
||||
if (row.exp_expired) return '<span class="text-danger fw-semibold">' + escHtml(row.exp_str) + '</span>';
|
||||
return '<span class="text-primary">' + (row.exp_unix ? escHtml(row.exp_str) : 'Never') + '</span> <small class="text-muted">(' + row.remaining_days + 'd)</small>';
|
||||
}
|
||||
function renderSubscriber(d) {
|
||||
return d ? '<span class="fw-semibold">' + escHtml(d) + '</span>' : '<span class="text-muted fst-italic">Auto-assigned</span>';
|
||||
}
|
||||
function renderMac(d) {
|
||||
return d ? '<span class="badge bg-label-dark font-monospace">' + escHtml(d) + '</span>' : '<span class="text-muted">-</span>';
|
||||
}
|
||||
function renderActions(d, type, row) {
|
||||
const id = row.id, status = row.status;
|
||||
return '<div class="d-inline-block text-nowrap">' +
|
||||
'<button class="btn btn-sm btn-icon btn-text-secondary rounded-pill btn-view-code" data-id="' + id + '" title="View Details"><i class="ti tabler-eye"></i></button>' +
|
||||
'<button class="btn btn-sm btn-icon btn-text-secondary rounded-pill btn-toggle-code" data-id="' + id + '" data-status="' + status + '" title="' + (status == 0 ? 'Enable' : 'Disable') + '"><i class="ti ' + (status == 0 ? 'tabler-check text-success' : 'tabler-ban text-warning') + '"></i></button>' +
|
||||
'<button class="btn btn-sm btn-icon btn-text-secondary rounded-pill btn-reset-code-device" data-id="' + id + '" title="Reset Device Lock"><i class="ti tabler-device-desktop-off"></i></button>' +
|
||||
'<button class="btn btn-sm btn-icon btn-text-secondary rounded-pill btn-delete-code" data-id="' + id + '" title="Delete Code"><i class="ti tabler-trash text-danger"></i></button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
const dt = tableEl.DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
responsive: true,
|
||||
order: [[8, 'desc']],
|
||||
pageLength: 25,
|
||||
lengthMenu: [10, 25, 50, 100],
|
||||
ajax: {
|
||||
url: './table',
|
||||
type: 'POST',
|
||||
data: function(d) {
|
||||
d.id = 'active_codes';
|
||||
d.filter = jQuery('#filter-status').val();
|
||||
d.batch = jQuery('#filter-batch').val();
|
||||
d.package = jQuery('#filter-package').val();
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{ data: null, orderable: false, searchable: false, render: renderCheckbox },
|
||||
{ data: 'code', render: renderCode },
|
||||
{ data: 'batch', render: renderBatch },
|
||||
{ data: null, render: renderPackage },
|
||||
{ data: null, render: renderStatus },
|
||||
{ data: null, render: renderExpiry },
|
||||
{ data: 'sub_username', render: renderSubscriber },
|
||||
{ data: 'mac', render: renderMac },
|
||||
{ data: 'created_str', render: escHtml },
|
||||
{ data: null, orderable: false, searchable: false, render: renderActions }
|
||||
],
|
||||
drawCallback: function() {
|
||||
updateFloatingBar();
|
||||
// Restore checkbox state
|
||||
tableEl.find('.row-select').each(function() {
|
||||
if (selectedIds.has(this.value)) {
|
||||
this.checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filters event listeners
|
||||
jQuery('#filter-search').on('keyup', function() {
|
||||
dt.search(this.value).draw();
|
||||
});
|
||||
jQuery('#filter-status, #filter-batch, #filter-package').on('change', function() {
|
||||
dt.ajax.reload();
|
||||
});
|
||||
|
||||
// Checkbox selections
|
||||
tableEl.on('change', '.row-select', function() {
|
||||
if (this.checked) {
|
||||
selectedIds.add(this.value);
|
||||
} else {
|
||||
selectedIds.delete(this.value);
|
||||
}
|
||||
updateFloatingBar();
|
||||
});
|
||||
|
||||
jQuery('#select-all').on('change', function() {
|
||||
const checked = this.checked;
|
||||
tableEl.find('.row-select').each(function() {
|
||||
this.checked = checked;
|
||||
if (checked) {
|
||||
selectedIds.add(this.value);
|
||||
} else {
|
||||
selectedIds.delete(this.value);
|
||||
}
|
||||
});
|
||||
updateFloatingBar();
|
||||
});
|
||||
|
||||
function updateFloatingBar() {
|
||||
const bar = jQuery('#mass-action-bar');
|
||||
const count = selectedIds.size;
|
||||
jQuery('#selected-count').text(count);
|
||||
if (count > 0) {
|
||||
bar.removeClass('d-none').addClass('d-flex');
|
||||
} else {
|
||||
bar.removeClass('d-flex').addClass('d-none');
|
||||
jQuery('#select-all').prop('checked', false);
|
||||
}
|
||||
}
|
||||
|
||||
jQuery('#btn-mass-cancel').on('click', function() {
|
||||
selectedIds.clear();
|
||||
tableEl.find('.row-select').prop('checked', false);
|
||||
updateFloatingBar();
|
||||
});
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = String(text);
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
textarea.setAttribute('readonly', '');
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
success ? resolve() : reject();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 1-Click Copy Code
|
||||
jQuery(document).on('click', '.btn-copy-code', function(e) {
|
||||
e.preventDefault();
|
||||
const code = jQuery(this).data('code');
|
||||
const btn = jQuery(this);
|
||||
const orig = btn.html();
|
||||
copyToClipboard(code).then(() => {
|
||||
btn.html('<i class="ti tabler-check text-success"></i>');
|
||||
setTimeout(() => btn.html(orig), 1500);
|
||||
}).catch(() => {
|
||||
prompt('Copy to clipboard:', code);
|
||||
});
|
||||
});
|
||||
|
||||
// View Details Modal
|
||||
const detailsModal = new bootstrap.Modal(document.getElementById('codeDetailsModal'));
|
||||
jQuery(document).on('click', '.btn-view-code', function() {
|
||||
const id = jQuery(this).data('id');
|
||||
jQuery('#modal-details-body').html(`
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<div class="mt-2 text-muted">Loading voucher details...</div>
|
||||
</div>
|
||||
`);
|
||||
detailsModal.show();
|
||||
|
||||
jQuery.getJSON('./api', { action: 'active_code_details', id: id }, function(res) {
|
||||
if (!res.result) {
|
||||
jQuery('#modal-details-body').html(`<div class="alert alert-danger">${res.message || 'Error loading details'}</div>`);
|
||||
return;
|
||||
}
|
||||
const d = res.data;
|
||||
const html = `
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border">
|
||||
<label class="small text-muted text-uppercase fw-semibold d-block">Activation Code</label>
|
||||
<div class="d-flex align-items-center justify-content-between mt-1">
|
||||
<span class="fs-4 fw-bold font-monospace text-primary">${d.code}</span>
|
||||
<button class="btn btn-sm btn-outline-primary btn-copy-code" data-code="${d.code}"><i class="ti tabler-copy me-1"></i>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="p-3 bg-light-subtle rounded-3 border">
|
||||
<label class="small text-muted text-uppercase fw-semibold d-block">Current Status</label>
|
||||
<div class="mt-1">
|
||||
<span class="badge ${d.status === 1 ? 'bg-success' : (d.status === 2 ? 'bg-primary' : 'bg-danger')} fs-6 px-3 py-2">
|
||||
${d.status_text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Package:</span>
|
||||
<span class="fw-semibold">${d.package_name} ${d.is_trial ? '<span class="badge bg-warning ms-1">Trial</span>' : ''}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Batch:</span>
|
||||
<span class="font-monospace">${d.batch_name}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Max Connections:</span>
|
||||
<span class="fw-semibold">${d.max_connections}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Device / MAC Lock:</span>
|
||||
<span class="font-monospace">${d.mac}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Countdown Expiry:</span>
|
||||
<span class="fw-semibold ${d.status === 1 ? 'text-info' : 'text-primary'}">${d.exp_date}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">First Activated:</span>
|
||||
<span>${d.activated_at}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between px-0">
|
||||
<span class="text-muted">Created:</span>
|
||||
<span>${d.created_at}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card bg-dark text-white border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-white d-flex align-items-center gap-2 mb-3">
|
||||
<i class="ti tabler-device-tv text-warning"></i>Xtream Codes & Streaming Credentials
|
||||
</h6>
|
||||
<div class="row g-2 text-start">
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-secondary d-block">Username</small>
|
||||
<div class="font-monospace fw-semibold">${d.username}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-secondary d-block">Password</small>
|
||||
<div class="font-monospace fw-semibold">${d.password}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<small class="text-secondary d-block">Server URL</small>
|
||||
<div class="font-monospace small">${d.portal_url}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<small class="text-secondary d-block">Port</small>
|
||||
<div class="font-monospace">${d.port}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="border-secondary my-3">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="${d.m3u_hls}" class="btn btn-sm btn-outline-light" target="_blank">
|
||||
<i class="ti tabler-download me-1"></i>Download M3U (HLS)
|
||||
</a>
|
||||
<a href="${d.m3u_ts}" class="btn btn-sm btn-outline-light" target="_blank">
|
||||
<i class="ti tabler-download me-1"></i>Download M3U (TS)
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-info btn-copy-code" data-code="${d.m3u_hls}">
|
||||
<i class="ti tabler-copy me-1"></i>Copy M3U
|
||||
</button>
|
||||
${d.direct_activate_url ? `
|
||||
<button class="btn btn-sm btn-primary btn-copy-code" data-code="${d.direct_activate_url}">
|
||||
<i class="ti tabler-link me-1"></i>Copy Activation Portal Link
|
||||
</button>
|
||||
<a href="${d.direct_activate_url}" class="btn btn-sm btn-outline-light" target="_blank" title="Test Subscriber Activation Portal">
|
||||
<i class="ti tabler-external-link me-1"></i>Open Portal
|
||||
</a>
|
||||
` : ''}
|
||||
${d.web_player_url ? `
|
||||
<a href="${d.web_player_url}" class="btn btn-sm btn-warning text-dark fw-bold" target="_blank">
|
||||
<i class="ti tabler-player-play me-1"></i>Launch Web Player
|
||||
</a>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
jQuery('#modal-details-body').html(html);
|
||||
});
|
||||
});
|
||||
|
||||
// Mass Enable
|
||||
jQuery('#btn-mass-enable').on('click', function() {
|
||||
execMassAction('mass_enable');
|
||||
});
|
||||
|
||||
// Mass Disable
|
||||
jQuery('#btn-mass-disable').on('click', function() {
|
||||
execMassAction('mass_disable');
|
||||
});
|
||||
|
||||
// Mass Reset Device
|
||||
jQuery('#btn-mass-reset').on('click', function() {
|
||||
execMassAction('mass_reset_device');
|
||||
});
|
||||
|
||||
// Single Toggle Code
|
||||
tableEl.on('click', '.btn-toggle-code', function() {
|
||||
const id = jQuery(this).data('id');
|
||||
const currentStatus = jQuery(this).data('status');
|
||||
const newAction = (currentStatus == 0) ? 'mass_enable' : 'mass_disable';
|
||||
execSingleAction(newAction, [id]);
|
||||
});
|
||||
|
||||
// Single Reset Device
|
||||
tableEl.on('click', '.btn-reset-code-device', function() {
|
||||
const id = jQuery(this).data('id');
|
||||
execSingleAction('mass_reset_device', [id]);
|
||||
});
|
||||
|
||||
// Single Delete Code
|
||||
tableEl.on('click', '.btn-delete-code', function() {
|
||||
const id = jQuery(this).data('id');
|
||||
selectedIds.clear();
|
||||
selectedIds.add(id);
|
||||
deleteModal.show();
|
||||
});
|
||||
|
||||
// Mass Extend Modal
|
||||
const extendModal = new bootstrap.Modal(document.getElementById('extendModal'));
|
||||
jQuery('#btn-mass-extend').on('click', function() {
|
||||
extendModal.show();
|
||||
});
|
||||
jQuery('#btn-confirm-extend').on('click', function() {
|
||||
const days = jQuery('#extend-days').val();
|
||||
execMassAction('mass_extend', { days: days });
|
||||
extendModal.hide();
|
||||
});
|
||||
|
||||
// Mass Delete Modal
|
||||
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
|
||||
jQuery('#btn-mass-delete').on('click', function() {
|
||||
deleteModal.show();
|
||||
});
|
||||
jQuery('#btn-confirm-delete').on('click', function() {
|
||||
const refund = jQuery('#delete-refund').is(':checked') ? 1 : 0;
|
||||
execMassAction('mass_delete', { refund_credits: refund });
|
||||
deleteModal.hide();
|
||||
});
|
||||
|
||||
function execMassAction(subAction, extra = {}) {
|
||||
if (selectedIds.size === 0) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
executeApiAction(subAction, ids, extra);
|
||||
}
|
||||
|
||||
function execSingleAction(subAction, ids, extra = {}) {
|
||||
executeApiAction(subAction, ids, extra);
|
||||
}
|
||||
|
||||
function executeApiAction(subAction, ids, extra = {}) {
|
||||
const postData = Object.assign({
|
||||
action: 'active_codes_mass',
|
||||
sub_action: subAction,
|
||||
ids: JSON.stringify(ids)
|
||||
}, extra);
|
||||
|
||||
jQuery.post('./api', postData, function(res) {
|
||||
let data = res;
|
||||
if (typeof res === 'string') {
|
||||
try { data = JSON.parse(res); } catch(e) {}
|
||||
}
|
||||
if (data.result) {
|
||||
selectedIds.clear();
|
||||
updateFloatingBar();
|
||||
dt.ajax.reload(null, false);
|
||||
} else {
|
||||
alert(data.message || 'Action failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Batch Manager for Active Codes (Bootstrap 5, Reseller)
|
||||
*
|
||||
* Manage voucher batches, export scratch card formatted .txt files,
|
||||
* copy full batches, and batch toggle/delete with refund calculation.
|
||||
*/
|
||||
|
||||
$batches = $batches ?? [];
|
||||
|
||||
$totalBatches = count($batches);
|
||||
$totalCodes = array_sum(array_column($batches, 'total_codes'));
|
||||
$totalStock = array_sum(array_column($batches, 'stock_count'));
|
||||
$totalActive = array_sum(array_column($batches, 'active_count'));
|
||||
|
||||
?>
|
||||
|
||||
<!-- Metrics Summary -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted fw-normal small text-uppercase mb-1">Total Batches</h6>
|
||||
<h3 class="mb-0 fw-bold"><?= $totalBatches; ?></h3>
|
||||
</div>
|
||||
<div class="avatar avatar-lg bg-label-primary rounded p-2">
|
||||
<i class="ti tabler-folders fs-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted fw-normal small text-uppercase mb-1">Total Generated</h6>
|
||||
<h3 class="mb-0 fw-bold text-info"><?= $totalCodes; ?></h3>
|
||||
</div>
|
||||
<div class="avatar avatar-lg bg-label-info rounded p-2">
|
||||
<i class="ti tabler-key fs-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted fw-normal small text-uppercase mb-1">Stock / Unused</h6>
|
||||
<h3 class="mb-0 fw-bold text-success"><?= $totalStock; ?></h3>
|
||||
</div>
|
||||
<div class="avatar avatar-lg bg-label-success rounded p-2">
|
||||
<i class="ti tabler-snowflake fs-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-xl-3">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted fw-normal small text-uppercase mb-1">Active Subscriptions</h6>
|
||||
<h3 class="mb-0 fw-bold text-primary"><?= $totalActive; ?></h3>
|
||||
</div>
|
||||
<div class="avatar avatar-lg bg-label-warning rounded p-2">
|
||||
<i class="ti tabler-player-play fs-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batches List -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header border-bottom d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<h5 class="card-title mb-1"><i class="ti tabler-folders text-primary me-2"></i>Batch Manager</h5>
|
||||
<p class="text-muted small mb-0">Grouped voucher management, scratch card voucher exporting, and batch operations.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="active_code" class="btn btn-primary">
|
||||
<i class="ti tabler-plus me-1"></i>New Batch
|
||||
</a>
|
||||
<a href="active_codes" class="btn btn-label-secondary">
|
||||
<i class="ti tabler-list me-1"></i>All Codes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<?php if (empty($batches)): ?>
|
||||
<div class="text-center py-5">
|
||||
<div class="avatar avatar-xl bg-label-secondary rounded-circle mx-auto mb-3 p-3">
|
||||
<i class="ti tabler-folders-off fs-1"></i>
|
||||
</div>
|
||||
<h5>No batches created yet</h5>
|
||||
<p class="text-muted">Generate your first batch of active codes to begin managing vouchers.</p>
|
||||
<a href="active_code" class="btn btn-primary mt-2">Generate Codes</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Batch Name</th>
|
||||
<th>Package</th>
|
||||
<th style="min-width: 220px;">Vouchers Breakdown</th>
|
||||
<th>Created Date</th>
|
||||
<th class="text-end" style="width: 200px;">Batch Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($batches as $b):
|
||||
$tot = max(1, (int)$b['total_codes']);
|
||||
$stockPct = round(((int)$b['stock_count'] / $tot) * 100);
|
||||
$activePct = round(((int)$b['active_count'] / $tot) * 100);
|
||||
$bName = (string)$b['batch_name'];
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="avatar avatar-sm bg-label-primary rounded p-1">
|
||||
<i class="ti tabler-folder"></i>
|
||||
</div>
|
||||
<div>
|
||||
<span class="fw-bold font-monospace fs-6"><?= htmlspecialchars($bName, ENT_QUOTES); ?></span>
|
||||
<a href="active_codes?batch=<?= urlencode($bName); ?>" class="d-block small text-muted text-decoration-none">
|
||||
View individual codes <i class="ti tabler-arrow-right small"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-label-info"><?= htmlspecialchars((string)$b['package_name'], ENT_QUOTES); ?></span>
|
||||
<?php if (!empty($b['is_trial'])): ?>
|
||||
<span class="badge bg-label-warning ms-1">Trial</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-success fw-semibold"><i class="ti tabler-snowflake"></i> <?= (int)$b['stock_count']; ?> Stock</span>
|
||||
<span class="text-primary fw-semibold"><i class="ti tabler-player-play"></i> <?= (int)$b['active_count']; ?> Active</span>
|
||||
<span class="text-muted"><?= (int)$b['total_codes']; ?> Total</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: <?= $stockPct; ?>%" title="Stock: <?= $stockPct; ?>%"></div>
|
||||
<div class="progress-bar bg-primary" role="progressbar" style="width: <?= $activePct; ?>%" title="Active: <?= $activePct; ?>%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small"><?= $b['created_at'] ? date('Y-m-d H:i', (int)$b['created_at']) : '-'; ?></span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group">
|
||||
<a href="./api?action=active_codes_export_txt&batch_name=<?= urlencode($bName); ?>" class="btn btn-sm btn-outline-primary" title="Download Printable Vouchers .txt">
|
||||
<i class="ti tabler-printer me-1"></i>Print / Export
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm">
|
||||
<li>
|
||||
<a class="dropdown-item btn-batch-enable" href="#" data-batch="<?= htmlspecialchars($bName, ENT_QUOTES); ?>">
|
||||
<i class="ti tabler-check text-success me-2"></i>Enable Batch
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item btn-batch-disable" href="#" data-batch="<?= htmlspecialchars($bName, ENT_QUOTES); ?>">
|
||||
<i class="ti tabler-ban text-warning me-2"></i>Disable Batch
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item text-danger btn-batch-delete" href="#" data-batch="<?= htmlspecialchars($bName, ENT_QUOTES); ?>" data-stock="<?= (int)$b['stock_count']; ?>">
|
||||
<i class="ti tabler-trash me-2"></i>Delete Batch
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="batchDeleteModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-bottom">
|
||||
<h5 class="modal-title text-danger"><i class="ti tabler-alert-triangle me-2"></i>Delete Voucher Batch</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
<p>Are you sure you want to permanently delete batch <strong id="modal-batch-name" class="font-monospace"></strong> and all its codes?</p>
|
||||
<div class="form-check p-3 bg-light rounded-3 border mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="batch-refund" checked>
|
||||
<label class="form-check-label fw-semibold" for="batch-refund">
|
||||
Refund credits for unactivated stock codes (<span id="modal-stock-count">0</span> codes)
|
||||
</label>
|
||||
<div class="form-text small text-muted">Refunds purchase credits directly back to your balance for any code that has not been redeemed.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-top">
|
||||
<button type="button" class="btn btn-label-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-confirm-batch-delete">Delete Batch</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
require_once __DIR__ . '/../layouts/footer.php';
|
||||
renderUnifiedLayoutFooter('reseller');
|
||||
?>
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
$(function() {
|
||||
let targetBatch = '';
|
||||
|
||||
// Batch Enable
|
||||
jQuery('.btn-batch-enable').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const batch = jQuery(this).data('batch');
|
||||
execBatchAction(batch, 'mass_enable');
|
||||
});
|
||||
|
||||
// Batch Disable
|
||||
jQuery('.btn-batch-disable').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const batch = jQuery(this).data('batch');
|
||||
execBatchAction(batch, 'mass_disable');
|
||||
});
|
||||
|
||||
// Batch Delete
|
||||
const batchDeleteModal = new bootstrap.Modal(document.getElementById('batchDeleteModal'));
|
||||
jQuery('.btn-batch-delete').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
targetBatch = jQuery(this).data('batch');
|
||||
const stockCount = jQuery(this).data('stock');
|
||||
jQuery('#modal-batch-name').text(targetBatch);
|
||||
jQuery('#modal-stock-count').text(stockCount);
|
||||
batchDeleteModal.show();
|
||||
});
|
||||
|
||||
jQuery('#btn-confirm-batch-delete').on('click', function() {
|
||||
if (!targetBatch) return;
|
||||
const refund = jQuery('#batch-refund').is(':checked') ? 1 : 0;
|
||||
execBatchAction(targetBatch, 'mass_delete', { refund_credits: refund });
|
||||
batchDeleteModal.hide();
|
||||
});
|
||||
|
||||
function execBatchAction(batchName, subAction, extra = {}) {
|
||||
const postData = Object.assign({
|
||||
action: 'active_codes_batch_action',
|
||||
batch_name: batchName,
|
||||
sub_action: subAction
|
||||
}, extra);
|
||||
|
||||
jQuery.post('./api', postData, function(res) {
|
||||
let data = res;
|
||||
if (typeof res === 'string') {
|
||||
try { data = JSON.parse(res); } catch(e) {}
|
||||
}
|
||||
if (data.result) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || 'Action failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
+18
-8
@@ -5,6 +5,7 @@ use XcVm\Core\Module\ModuleLoader;
|
||||
use XcVm\Infrastructure\Bootstrap\ScopeBootstrapFactory;
|
||||
use XcVm\Infrastructure\Bootstrap\StreamingRequestBootstrap;
|
||||
use XcVm\Infrastructure\Bootstrap\WebApiBootstrap;
|
||||
use XcVm\Public\Controllers\Api\ActiveCodeApiController;
|
||||
use XcVm\Public\Controllers\Api\AdminApiController;
|
||||
use XcVm\Public\Controllers\Api\Enigma2ApiController;
|
||||
use XcVm\Public\Controllers\Api\EpgApiController;
|
||||
@@ -13,6 +14,7 @@ use XcVm\Public\Controllers\Api\PlayerApiController;
|
||||
use XcVm\Public\Controllers\Api\PlaylistApiController;
|
||||
use XcVm\Public\Controllers\Api\ResellerRestApiController;
|
||||
use XcVm\Public\Controllers\Api\XPluginApiController;
|
||||
use XcVm\Public\Controllers\Player\PortalController;
|
||||
|
||||
/**
|
||||
* Front Controller — единая точка входа для admin/reseller/player.
|
||||
@@ -62,6 +64,7 @@ if (!empty($_SERVER['XC_SCOPE'])) {
|
||||
'includes/api/admin' => 'admin',
|
||||
'includes/api/reseller' => 'reseller',
|
||||
'player' => 'player',
|
||||
'portal' => 'portal',
|
||||
];
|
||||
|
||||
$scope = $scopeMap[$rawScope] ?? 'admin';
|
||||
@@ -73,8 +76,8 @@ if (!empty($_SERVER['XC_SCOPE'])) {
|
||||
$parts = explode('/', $pageName, 2);
|
||||
$pageName = $parts[1] ?? '';
|
||||
}
|
||||
} elseif (preg_match('#^/(admin|reseller)(?:/(.*))?$#', $urlPath, $m)) {
|
||||
// Режим B: прямой URL /admin/... или /reseller/...
|
||||
} elseif (preg_match('#^/(admin|reseller|portal)(?:/(.*))?$#', $urlPath, $m)) {
|
||||
// Режим B: прямой URL /admin/... или /reseller/... или /portal/...
|
||||
$scope = $m[1];
|
||||
$pageName = isset($m[2]) ? trim($m[2], '/') : '';
|
||||
} else {
|
||||
@@ -127,11 +130,9 @@ if (
|
||||
exit;
|
||||
}
|
||||
|
||||
// 4b. Player: /CODE (без завершающего слэша) → /CODE/ — страницы плеера ссылаются
|
||||
// на статику относительно ("css/main.css"), без слэша браузер резолвит её от
|
||||
// корня и весь CSS/JS уходит в 404.
|
||||
// 4b. Player / Portal: /CODE (без завершающего слэша) → /CODE/
|
||||
if (
|
||||
$accessCode && $scope === 'player'
|
||||
$accessCode && in_array($scope, ['player', 'portal'], true)
|
||||
&& ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET'
|
||||
&& rtrim(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '', '/') === '/' . $accessCode
|
||||
&& substr(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: '', -1) !== '/'
|
||||
@@ -162,8 +163,9 @@ if (isset($rawScope) && $rawScope === 'api' && !empty($_SERVER['XC_API'])) {
|
||||
'enigma2' => [Enigma2ApiController::class, 'web'],
|
||||
'xplugin' => [XPluginApiController::class, 'web'],
|
||||
'epg' => [EpgApiController::class, 'web'],
|
||||
'playlist' => [PlaylistApiController::class, 'web'],
|
||||
'internal' => [InternalApiController::class, 'web'],
|
||||
'playlist' => [PlaylistApiController::class, 'web'],
|
||||
'internal' => [InternalApiController::class, 'web'],
|
||||
'active_code' => [ActiveCodeApiController::class, 'web'],
|
||||
];
|
||||
|
||||
if (!isset($rApiEndpoints[$rApiName])) {
|
||||
@@ -202,6 +204,14 @@ if ($scope === 'ministra') {
|
||||
}
|
||||
}
|
||||
|
||||
// 6c. Subscriber Activation Portal (public access, no admin/reseller session required)
|
||||
if ($scope === 'portal') {
|
||||
WebApiBootstrap::init('portal');
|
||||
$portalController = new PortalController();
|
||||
$portalController->index();
|
||||
exit;
|
||||
}
|
||||
|
||||
// 7. Scope bootstrap — working directory + session/functions files
|
||||
$adminDir = ($scope === 'admin') ? MAIN_HOME . 'Public/Views/admin/' : MAIN_HOME . $scope . '/';
|
||||
@chdir(is_dir($adminDir) ? $adminDir : MAIN_HOME);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<?php
|
||||
|
||||
use XcVm\Public\Controllers\Admin\ActiveCodeController;
|
||||
use XcVm\Public\Controllers\Admin\ActiveCodesBatchController;
|
||||
use XcVm\Public\Controllers\Admin\ActiveCodesController;
|
||||
use XcVm\Public\Controllers\Admin\ActiveCodesMassController;
|
||||
use XcVm\Public\Controllers\Admin\AdminLogoutController;
|
||||
use XcVm\Public\Controllers\Admin\AdminResizeController;
|
||||
use XcVm\Public\Controllers\Admin\Ajax\ActiveCodeAjaxController;
|
||||
use XcVm\Public\Controllers\Admin\Ajax\BackupAjaxController;
|
||||
use XcVm\Public\Controllers\Admin\Ajax\BlocklistAjaxController;
|
||||
use XcVm\Public\Controllers\Admin\Ajax\CacheAjaxController;
|
||||
@@ -158,6 +163,10 @@ $router->get('isps', [IspController::class, 'index']);
|
||||
$router->get('hmacs', [HmacController::class, 'index']);
|
||||
$router->get('groups', [GroupController::class, 'index']);
|
||||
$router->get('codes', [CodeController::class, 'index']);
|
||||
$router->get('active_codes', [ActiveCodesController::class, 'index']);
|
||||
$router->get('active_code', [ActiveCodeController::class, 'index']);
|
||||
$router->get('active_codes_batch', [ActiveCodesBatchController::class, 'index']);
|
||||
$router->get('active_codes_mass', [ActiveCodesMassController::class, 'index']);
|
||||
$router->get('packages', [PackageController::class, 'index']);
|
||||
$router->get('rtmp_ips', [RtmpIpController::class, 'index']);
|
||||
$router->get('profiles', [ProfileController::class, 'index']);
|
||||
@@ -374,6 +383,12 @@ $router->api('category', [PackageAjaxController::class, 'category']);
|
||||
$router->api('get_package', [PackageAjaxController::class, 'getPackage']);
|
||||
$router->api('get_package_trial', [PackageAjaxController::class, 'getPackageTrial']);
|
||||
|
||||
// ─── Active Codes ──────────────────────────────────
|
||||
$router->api('active_code_details', [ActiveCodeAjaxController::class, 'details']);
|
||||
$router->api('generate_active_codes', [ActiveCodeAjaxController::class, 'generate']);
|
||||
$router->api('active_codes_batch_action', [ActiveCodeAjaxController::class, 'batchAction']);
|
||||
$router->api('active_codes_export_txt', [ActiveCodeAjaxController::class, 'exportTxt']);
|
||||
|
||||
// ─── Stats & Graphs ────────────────────────────────
|
||||
$router->api('graph_stats', [StatsAjaxController::class, 'graphStats']);
|
||||
$router->api('stats', [StatsAjaxController::class, 'stats']);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
|
||||
use XcVm\Public\Controllers\Reseller\ResellerActiveCodeController;
|
||||
use XcVm\Public\Controllers\Reseller\ResellerActiveCodesBatchController;
|
||||
use XcVm\Public\Controllers\Reseller\ResellerActiveCodesController;
|
||||
use XcVm\Public\Controllers\Reseller\ResellerApiController;
|
||||
use XcVm\Public\Controllers\Reseller\ResellerCreatedChannelsController;
|
||||
use XcVm\Public\Controllers\Reseller\ResellerDashboardController;
|
||||
@@ -73,6 +76,12 @@ $router->get('line', [ResellerLineController::class, 'index']);
|
||||
$router->get('line_activity', [ResellerLineActivityController::class, 'index']);
|
||||
$router->get('live_connections', [ResellerLiveConnectionsController::class, 'index']);
|
||||
|
||||
// ─── Smart Activation Codes ─────────────────────────
|
||||
|
||||
$router->get('active_codes', [ResellerActiveCodesController::class, 'index']);
|
||||
$router->get('active_code', [ResellerActiveCodeController::class, 'index']);
|
||||
$router->get('active_codes_batch', [ResellerActiveCodesBatchController::class, 'index']);
|
||||
|
||||
// ─── Devices MAG / Enigma ──────────────────────────
|
||||
|
||||
$router->get('mags', [ResellerMagsController::class, 'index']);
|
||||
|
||||
@@ -26,6 +26,39 @@ CREATE TABLE IF NOT EXISTS `access_codes` (
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `activation_codes`
|
||||
--
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `activation_codes` (
|
||||
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`activation_code` varchar(50) COLLATE utf8_unicode_ci NOT NULL,
|
||||
`batch_name` varchar(100) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`subscriber_id` int(11) NOT NULL DEFAULT '0',
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1=Ready/Stock, 2=Active/Bound, 0=Disabled',
|
||||
`created_by` int(11) NOT NULL DEFAULT '0',
|
||||
`package_id` int(11) DEFAULT NULL,
|
||||
`bouquets` mediumtext COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`is_adult` tinyint(1) NOT NULL DEFAULT '0',
|
||||
`is_trial` tinyint(1) NOT NULL DEFAULT '0',
|
||||
`purchase_cost` decimal(10,2) NOT NULL DEFAULT '0.00',
|
||||
`dns_base` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`forced_country` varchar(3) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`max_connections` int(11) NOT NULL DEFAULT '1',
|
||||
`mac` varchar(50) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`device_id` varchar(100) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`activated_at` int(11) DEFAULT NULL,
|
||||
`created_at` int(11) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_activation_code` (`activation_code`),
|
||||
KEY `idx_created_by` (`created_by`),
|
||||
KEY `idx_subscriber_id` (`subscriber_id`),
|
||||
KEY `idx_batch_name` (`batch_name`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `blocked_asns`
|
||||
--
|
||||
@@ -344,6 +377,7 @@ CREATE TABLE IF NOT EXISTS `lines` (
|
||||
`last_activity` int(11) DEFAULT NULL,
|
||||
`last_activity_array` mediumtext COLLATE utf8_unicode_ci,
|
||||
`updated` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`is_activecode` tinyint(1) NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `member_id` (`member_id`),
|
||||
KEY `exp_date` (`exp_date`),
|
||||
@@ -357,6 +391,7 @@ CREATE TABLE IF NOT EXISTS `lines` (
|
||||
KEY `username` (`username`),
|
||||
KEY `password` (`password`),
|
||||
KEY `is_e2` (`is_e2`),
|
||||
KEY `idx_is_activecode` (`is_activecode`),
|
||||
KEY `order_default` (`id`,`is_mag`,`is_e2`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
|
||||
|
||||
|
||||
@@ -109,6 +109,12 @@ http {
|
||||
location = /panel_api.php {
|
||||
rewrite ^ /api/player_api last;
|
||||
}
|
||||
|
||||
location = /active_code.php {
|
||||
rewrite ^ /api/active_code last;
|
||||
}
|
||||
|
||||
rewrite ^/api/v1/active-code/auth$ /api/active_code last;
|
||||
|
||||
location = /streaming/live.php {
|
||||
return 302 /stream/auth?type=live&$args;
|
||||
@@ -277,7 +283,7 @@ http {
|
||||
}
|
||||
|
||||
# ─── Streaming API → Front Controller ───────────────────────
|
||||
location ~ ^/api/(player_api|enigma2|xplugin|epg|playlist)$ {
|
||||
location ~ ^/api/(player_api|enigma2|xplugin|epg|playlist|active_code)$ {
|
||||
limit_req zone=one burst=8;
|
||||
include limit_queue.conf;
|
||||
fastcgi_index index.php;
|
||||
@@ -295,7 +301,7 @@ http {
|
||||
}
|
||||
|
||||
# ─── Streaming API → Front Controller (legacy *.php) ──────
|
||||
location ~ ^/(player_api|enigma2|xplugin|epg|playlist)\.php$ {
|
||||
location ~ ^/(player_api|enigma2|xplugin|epg|playlist|active_code)\.php$ {
|
||||
limit_req zone=one burst=8;
|
||||
include limit_queue.conf;
|
||||
fastcgi_index index.php;
|
||||
@@ -312,6 +318,23 @@ http {
|
||||
fastcgi_param XC_API $1;
|
||||
}
|
||||
|
||||
# ─── Subscriber Activation Portal ───────────────────────────
|
||||
location ^~ /portal {
|
||||
limit_req zone=one burst=15;
|
||||
include limit_queue.conf;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_pass php;
|
||||
include fastcgi_params;
|
||||
fastcgi_buffering on;
|
||||
fastcgi_buffers 128 32k;
|
||||
fastcgi_buffer_size 32k;
|
||||
fastcgi_max_temp_file_size 0;
|
||||
fastcgi_keep_conn on;
|
||||
fastcgi_param SCRIPT_FILENAME /home/xc_vm/Public/index.php;
|
||||
fastcgi_param SCRIPT_NAME /public/index.php;
|
||||
fastcgi_param XC_SCOPE portal;
|
||||
}
|
||||
|
||||
# ─── Internal API → Front Controller (server-to-server) ────
|
||||
# Аутентификация: InternalApiController проверяет password + IP whitelist
|
||||
location = /api {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Smart Activation Codes (core feature): prepaid voucher codes that a
|
||||
-- subscriber redeems to provision a line. Generated/managed from admin and
|
||||
-- reseller, redeemed through the player activation portal.
|
||||
-- status: 1=Ready/Stock, 2=Active/Bound, 0=Disabled
|
||||
-- Kept in sync with bin/install/database.sql (fresh-install baseline).
|
||||
CREATE TABLE IF NOT EXISTS `activation_codes` (
|
||||
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`activation_code` varchar(50) COLLATE utf8_unicode_ci NOT NULL,
|
||||
`batch_name` varchar(100) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`subscriber_id` int(11) NOT NULL DEFAULT 0,
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1=Ready/Stock, 2=Active/Bound, 0=Disabled',
|
||||
`created_by` int(11) NOT NULL DEFAULT 0,
|
||||
`package_id` int(11) DEFAULT NULL,
|
||||
`bouquets` mediumtext COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`is_adult` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`is_trial` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`purchase_cost` decimal(10,2) NOT NULL DEFAULT 0.00,
|
||||
`dns_base` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`forced_country` varchar(3) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`max_connections` int(11) NOT NULL DEFAULT 1,
|
||||
`mac` varchar(50) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`device_id` varchar(100) COLLATE utf8_unicode_ci DEFAULT NULL,
|
||||
`activated_at` int(11) DEFAULT NULL,
|
||||
`created_at` int(11) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_activation_code` (`activation_code`),
|
||||
KEY `idx_created_by` (`created_by`),
|
||||
KEY `idx_subscriber_id` (`subscriber_id`),
|
||||
KEY `idx_batch_name` (`batch_name`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
|
||||
|
||||
-- Flag lines auto-provisioned by an activation code, so the normal Lines
|
||||
-- list/filters (admin + reseller) exclude them.
|
||||
ALTER TABLE `lines`
|
||||
ADD COLUMN IF NOT EXISTS `is_activecode` tinyint(1) NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE `lines`
|
||||
ADD KEY IF NOT EXISTS `idx_is_activecode` (`is_activecode`);
|
||||
Reference in New Issue
Block a user