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:
Divarion_D
2026-09-11 19:08:20 +03:00
parent ddbc58c358
commit b8325fc19e
37 changed files with 6439 additions and 71 deletions
+35 -3
View File
@@ -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) {
+5 -1
View File
@@ -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);
}
+17
View File
@@ -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('#')
+657
View File
@@ -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;
}
}
+26
View File
@@ -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.
*
+243 -19
View File
@@ -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();
}
}
+139 -2
View File
@@ -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;
}
}
+149 -20
View File
@@ -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),
]);
}
}
+656
View File
@@ -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>
+625
View File
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[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>
+105 -1
View File
@@ -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) {
+64 -4
View File
@@ -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',
+846
View File
@@ -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">
&copy; <?= 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>
+724
View File
@@ -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>
+633
View File
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[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
View File
@@ -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);
+15
View File
@@ -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']);
+9
View File
@@ -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']);
+35
View File
@@ -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;
+25 -2
View File
@@ -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`);