Merge pull request #135 from GameServerPanel/copilot/fix-add-to-cart-sql-error

Fix add_to_cart fatal SQL error, Browse Servers routing, canonical game deduplication, OS-aware location selection, and XML editor improvements
This commit is contained in:
Frank Harris 2026-05-06 18:51:57 -05:00 committed by GitHub
commit 3a2ed00778
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 499 additions and 352 deletions

View file

@ -204,7 +204,7 @@ $sql = "INSERT INTO {$table_prefix}billing_invoices (
billing_status, invoice_date, due_date, description, invoice_duration, rate_type, rate_per_player, billing_status, invoice_date, due_date, description, invoice_duration, rate_type, rate_per_player,
players, period_start, period_end, subtotal, total_due, payment_status, qty, coupon_id players, period_start, period_end, subtotal, total_due, payment_status, qty, coupon_id
) VALUES ( ) VALUES (
0, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, 0.00, 'USD', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0 0, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, 0.00, 'USD', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0
)"; )";
$stmt = $db->prepare($sql); $stmt = $db->prepare($sql);

View file

@ -541,7 +541,7 @@ $siteBase = $protocol . $host;
<div class="cart-empty"> <div class="cart-empty">
<h2>Your cart is empty</h2> <h2>Your cart is empty</h2>
<p>Browse our game servers and add them to your cart to get started!</p> <p>Browse our game servers and add them to your cart to get started!</p>
<a href="/order.php" class="btn">Browse Servers</a> <a href="/serverlist.php" class="btn">Browse Servers</a>
</div> </div>
<?php else: ?> <?php else: ?>
<table class="cart-table"> <table class="cart-table">
@ -640,7 +640,7 @@ $siteBase = $protocol . $host;
</button> </button>
</form> </form>
<div class="action-buttons" style="margin-top:15px;"> <div class="action-buttons" style="margin-top:15px;">
<a href="/order.php" class="btn btn-secondary">Continue Shopping</a> <a href="/serverlist.php" class="btn btn-secondary">Continue Shopping</a>
<a href="/my_account.php" class="btn btn-secondary">My Account</a> <a href="/my_account.php" class="btn btn-secondary">My Account</a>
</div> </div>
</div> </div>
@ -672,7 +672,7 @@ $siteBase = $protocol . $host;
<div id="status-message" class="status-message"></div> <div id="status-message" class="status-message"></div>
<?php endif; ?> <?php endif; ?>
<div class="action-buttons"> <div class="action-buttons">
<a href="/order.php" class="btn btn-secondary">Continue Shopping</a> <a href="/serverlist.php" class="btn btn-secondary">Continue Shopping</a>
<a href="/my_account.php" class="btn btn-secondary">My Account</a> <a href="/my_account.php" class="btn btn-secondary">My Account</a>
</div> </div>
</div> </div>

View file

@ -24,8 +24,8 @@
// Module general information // Module general information
$module_title = "billing"; $module_title = "billing";
$module_version = "3.4"; $module_version = "3.5";
$db_version = 5; $db_version = 6;
$module_required = FALSE; $module_required = FALSE;
// Module description // Module description
$module_description = "Billing storefront / provisioning integration. Public ordering runs as a standalone site; panel pages provide provisioning and admin order management."; $module_description = "Billing storefront / provisioning integration. Public ordering runs as a standalone site; panel pages provide provisioning and admin order management.";
@ -386,4 +386,17 @@ $install_queries[5] = array(
} }
); );
?> // -----------------------------------------------------------------------
// db_version 6 — Add server_os column to remote_servers for OS-aware
// game/service selection in the billing storefront.
// Default 'linux' preserves existing behaviour for all current installs.
// -----------------------------------------------------------------------
$install_queries[6] = array(
function($db) {
$r = $db->resultQuery("SELECT COUNT(*) AS cnt FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'OGP_DB_PREFIXremote_servers' AND COLUMN_NAME = 'server_os'");
if ($r && isset($r[0]['cnt']) && (int)$r[0]['cnt'] > 0) return true;
return (bool)$db->query("ALTER TABLE `OGP_DB_PREFIXremote_servers` ADD `server_os` ENUM('linux','windows','any') NOT NULL DEFAULT 'linux' AFTER `display_public_ip`");
}
);
?>

View file

@ -9,15 +9,13 @@
<?php <?php
/* /*
This is the actual "order gameserver" page. There is a page that displays all the possible game servers we can rent. This page displays the options This is the "order gameserver" page. It displays the options for a single specific game server and
for a single specific game server and has the "Add to Cart" button. has the "Add to Cart" button. The gameserver selected is passed from the serverlist page by a GET
The gameserver selected is passed from the gameserverss page by a Post of the ServiceID of the service_id. When the user clicks "Add to Cart", the next page is add_to_cart.php.
When the user clicks the "Add to Cart" button, the next page to load is "add_to_cart.php" which creates all the DB entries.
All the configuration info is passed to the add_to_cart.php in hidden fields OS-aware selection: if both a Linux and a Windows variant of the same game exist as separate
billing_services entries, the system automatically detects the selected location's OS (from
In our website, we are setting "post" pages with a "Tag". The first tag in our post should be the service ID from the services table remote_servers.server_os) and routes the cart add to the correct service variant.
There are other methods that might be better to get the info. But all we need is the "service_ID" in the "{$table_prefix}billing_services" table
This method means we can use one code block in every game page and fill in the data dynamically.
*/ */
// Require login for ordering // Require login for ordering
@ -43,278 +41,363 @@ if (!$db) {
include(__DIR__ . '/includes/top.php'); include(__DIR__ . '/includes/top.php');
include(__DIR__ . '/includes/menu.php'); include(__DIR__ . '/includes/menu.php');
if (isset($_POST['save']) && !empty($_POST['description'])) {
if (isset($_POST['save']) AND !empty($_POST['description'])) $new_description = str_replace("\\r\\n", "<br>", $_POST['description']);
{ $service = intval($_POST['service_id']);
$new_description = str_replace("\\r\\n", "<br>", $_POST['description']); $stmt = $db->prepare("UPDATE {$table_prefix}billing_services SET description = ? WHERE service_id = ?");
$service = $_POST['service_id']; if ($stmt) {
$stmt->bind_param("si", $new_description, $service);
$change_description = "UPDATE {$table_prefix}billing_services $stmt->execute();
SET description ='".$new_description."' $stmt->close();
WHERE service_id=".$service; }
$save = $db->query($change_description); }
}
?>
/**
* Derive OS ('linux'|'windows'|'any') from a game_key string.
* Checks for _win / _windows substrings; then _linux; else 'any'.
*/
function order_game_key_os(string $gameKey): string
{
$lk = strtolower($gameKey);
if (str_contains($lk, '_win')) {
return 'windows';
}
if (str_contains($lk, '_linux')) {
return 'linux';
}
return 'any';
}
// --- Fetch the requested service with config_homes join for canonical game info ---
<!-- ------------------------------------------------------------------------------ $req_service_id = intval($_REQUEST['service_id'] ?? 0);
THIS IS WHAT WE DISPLAY ON THE SHOP PAGE AT THE TOP if ($req_service_id !== 0) {
--> $where_service_id = " WHERE bs.enabled = 1 AND bs.service_id=" . $req_service_id;
<?php
// Shop Form
$req_service_id = intval($_REQUEST['service_id'] ?? 0);
if ($req_service_id !== 0) {
$where_service_id = " WHERE enabled = 1 AND service_id=" . $req_service_id;
} else {
$where_service_id = " WHERE enabled = 1";
}
$qry_services = "SELECT * FROM {$table_prefix}billing_services " . $where_service_id . " ORDER BY service_name";
$services_result = $db->query($qry_services);
if ($services_result === false) {
echo "<p class='error'>Unable to load service information. Please try again or contact support.</p>";
error_log("billing order.php: query failed - " . $db->error . " | SQL: " . $qry_services);
billing_maybe_close_db($db);
include(__DIR__ . '/includes/footer.php');
echo '</body></html>';
exit;
}
// Fetch all rows into an array so foreach works correctly
$serviceRows = [];
while ($row = $services_result->fetch_assoc()) {
$serviceRows[] = $row;
}
$services_result->free();
if ($req_service_id !== 0 && empty($serviceRows)) {
error_log("billing order.php: service_id={$req_service_id} not found or not enabled");
echo "<p class='error'>The requested service could not be found or is no longer available.</p>";
echo "<p><a href='serverlist.php'>Back to server list</a></p>";
billing_maybe_close_db($db);
include(__DIR__ . '/includes/footer.php');
echo '</body></html>';
exit;
}
?>
<div class="clearfix">
<?php
foreach ($serviceRows as $row)
{
if(!isset($_REQUEST['service_id']))
{
?>
<div class="float-left p-30-20">
<img src="<?php echo htmlspecialchars(billing_image_url((string)($row['img_url'] ?? '')), ENT_QUOTES, 'UTF-8');?>" width="460" height="225" >
<br>
<?php echo htmlspecialchars((string)$row['service_name'], ENT_QUOTES, 'UTF-8');?>
<br>
<?php
if ($row['price_monthly'] == 0.0) {
echo "FREE";
} else { } else {
echo "$" . number_format(floatval($row['price_monthly']),2). " Monthly"; $where_service_id = " WHERE bs.enabled = 1";
}
$qry_services = "SELECT bs.*, ch.game_name AS cfg_game_name, ch.game_key AS cfg_game_key
FROM {$table_prefix}billing_services bs
LEFT JOIN {$table_prefix}config_homes ch ON ch.home_cfg_id = bs.home_cfg_id
{$where_service_id}
ORDER BY bs.service_name";
$services_result = $db->query($qry_services);
if ($services_result === false) {
// Fallback: query without join if config_homes doesn't exist in this context
$where_service_id_simple = str_replace('bs.', '', $where_service_id);
$qry_services = "SELECT *, NULL AS cfg_game_name, NULL AS cfg_game_key
FROM {$table_prefix}billing_services
{$where_service_id_simple}
ORDER BY service_name";
$services_result = $db->query($qry_services);
}
if ($services_result === false) {
echo "<p class='error'>Unable to load service information. Please try again or contact support.</p>";
error_log("billing order.php: query failed - " . $db->error);
billing_maybe_close_db($db);
include(__DIR__ . '/includes/footer.php');
echo '</body></html>';
exit;
}
$serviceRows = [];
while ($row = $services_result->fetch_assoc()) {
$serviceRows[] = $row;
}
$services_result->free();
if ($req_service_id !== 0 && empty($serviceRows)) {
error_log("billing order.php: service_id={$req_service_id} not found or not enabled");
echo "<p class='error'>The requested service could not be found or is no longer available.</p>";
echo "<p><a href='serverlist.php'>Back to server list</a></p>";
billing_maybe_close_db($db);
include(__DIR__ . '/includes/footer.php');
echo '</body></html>';
exit;
}
// Check whether remote_servers has a server_os column (added by db_version 6 migration).
// We gracefully degrade: if the column is absent, all servers are treated as compatible.
$hasServerOsColumn = false;
$osColCheck = $db->query("SHOW COLUMNS FROM {$table_prefix}remote_servers LIKE 'server_os'");
if ($osColCheck && $osColCheck->num_rows > 0) {
$hasServerOsColumn = true;
$osColCheck->free();
}
?>
<div class="clearfix">
<?php
foreach ($serviceRows as $row)
{
if (!isset($_REQUEST['service_id']))
{
?>
<div class="float-left p-30-20">
<?php
$imgSrc = billing_image_url((string)($row['img_url'] ?? ''));
if ($imgSrc === '') { $imgSrc = '/images/games/default_server.png'; }
?>
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" width="460" height="225"
onerror="this.src='/images/games/default_server.png'; this.onerror=null;">
<br>
<?php echo htmlspecialchars((string)($row['cfg_game_name'] ?? $row['service_name']), ENT_QUOTES, 'UTF-8'); ?>
<br>
<?php
if (floatval($row['price_monthly']) == 0.0) {
echo "FREE";
} else {
echo "$" . number_format(floatval($row['price_monthly']), 2) . " Monthly";
} }
?> ?>
<br> <br>
<form action="<?php echo $row['description'];?>" method="POST"> <a href="order.php?service_id=<?php echo intval($row['service_id']); ?>" class="gsw-btn">Order Now</a>
<input name="service_id" type="hidden" value="<?php echo $row['service_id'];?>" />
<input name="order_server" type="submit" value="Server Info">
</form>
<form action="" method="POST">
<input name="service_id" type="hidden" value="<?php echo $row['service_id'];?>" />
<input name="order_server" type="submit" value="Order Server">
</form>
</div>
</div> </div>
<?php
</> }else
// THIS IS THE SERVER WE WANT TO ORDER
{
// Determine canonical game name and OS for this service
<?php $svcGameKey = (string)($row['cfg_game_key'] ?? '');
} else $svcGameOs = order_game_key_os($svcGameKey);
//THIS IS THE SERVER WE WANT TO ORDER $canonicalGameName = (string)($row['cfg_game_name'] ?? $row['service_name']);
{
?>
<div class="float-left decorative-bottom">
<img src="<?php echo htmlspecialchars(billing_image_url((string)($row['img_url'] ?? '')), ENT_QUOTES, 'UTF-8');?>" width="230" height="112">
<center><b> <?php echo htmlspecialchars((string)$row['service_name'], ENT_QUOTES, 'UTF-8');?></b></center>
<?php
//$isAdmin = if( current_user_can('administrator')){
//$isAdmin = true;
//$isAdmin = $db->isAdmin($_SESSION['user_id'] );
$isAdmin = false;
if($isAdmin)
{
if(!isset($_POST['edit']))
{
echo "<p style='color:gray;width:230px;' >$row[description]<p>";
echo "<form action='' method='post'>".
"<input type='hidden' name='service_id' value='$row[service_id]' />".
"<input type='submit' name='edit' value='Edit' />".
"</form>";
}
else
{
echo "<form action='' method='post'>".
"<textarea style='resize:none;width:230px;height:132px;' name='description' >".str_replace("<br>", "\r\n", $row['description'])."</textarea><br>".
"<input type='hidden' name='service_id' value='$row[service_id]' />".
"<input type='submit' name='save' value='Save' />".
"</form>";
}
}
else
echo "<p style='color:gray;width:280px;' >$row[description]<p>";
?>
</div>
<table class="float-left">
<form method="post" action="add_to_cart.php">
<input type="hidden" name="service_id" size="15" value="<?php echo intval($_REQUEST['service_id'] ?? $row['service_id'] ?? 0); ?>">
<input type="hidden" name="remote_control_password" size="15" value="">
<input type="hidden" name="ftp_password" size="15" value="">
<tr>
<td align="right"><b>Game Server Name</b> </td>
<td align="left">
<input type="text" name="home_name" size="40" value="<?php echo $row['service_name'];?>">
</td>
<tr>
<td align="right"><b>Location</b></td>
<td align="left">
<?php
// Fetch servers available for this game from billing_services.remote_server_id
// (a comma-separated list of numeric remote server IDs, e.g. "1,3,7").
$available_server = false;
$remoteIdsCsv = (string)($row['remote_server_id'] ?? '');
$allowedIds = [];
foreach (explode(',', $remoteIdsCsv) as $part) {
$part = trim($part);
if ($part !== '' && ctype_digit($part)) {
$allowedIds[] = (int)$part;
}
}
if (!empty($allowedIds)) {
$inList = implode(',', $allowedIds);
$rsQuery = "SELECT remote_server_id, remote_server_name
FROM {$table_prefix}remote_servers
WHERE remote_server_id IN ({$inList})
ORDER BY remote_server_name";
$rsResult = $db->query($rsQuery);
if ($rsResult) {
$firstServer = true;
while ($rs = $rsResult->fetch_assoc()) {
$rsID = (int)$rs['remote_server_id'];
$rsNAME = htmlspecialchars((string)$rs['remote_server_name'], ENT_QUOTES, 'UTF-8');
$checked = $firstServer ? ' checked' : '';
$available_server = true;
$firstServer = false;
echo "<div>\n"
. " <input type='radio' name='ip_id' id='rs_{$rsID}' value='{$rsID}' required{$checked}>\n"
. " <label for='rs_{$rsID}'>{$rsNAME}</label>\n"
. "</div>\n";
}
}
}
?>
// Build map of OS variant service IDs for JS-based automatic selection.
// Look for sibling services that share the same cfg_game_name (canonical) but differ in OS.
// e.g. if current service is arma3_linux64, find the arma3_win64 service too.
$osServiceMap = []; // ['linux' => service_id, 'windows' => service_id]
if ($svcGameOs !== 'any' && !empty($canonicalGameName)) {
$escapedName = $db->real_escape_string($canonicalGameName);
$siblingQuery = "SELECT bs.service_id, ch.game_key AS cfg_game_key
FROM {$table_prefix}billing_services bs
LEFT JOIN {$table_prefix}config_homes ch ON ch.home_cfg_id = bs.home_cfg_id
WHERE bs.enabled = 1 AND ch.game_name = '{$escapedName}'";
$siblingResult = $db->query($siblingQuery);
if ($siblingResult) {
while ($sib = $siblingResult->fetch_assoc()) {
$sibOs = order_game_key_os((string)($sib['cfg_game_key'] ?? ''));
$osServiceMap[$sibOs] = (int)$sib['service_id'];
}
$siblingResult->free();
}
}
// Always include the current service as a fallback
if (!isset($osServiceMap[$svcGameOs]) || $svcGameOs === 'any') {
$osServiceMap[$svcGameOs] = (int)$row['service_id'];
}
$osServiceMapJson = json_encode($osServiceMap, JSON_THROW_ON_ERROR);
?>
<div class="float-left decorative-bottom">
<?php
$imgSrc = billing_image_url((string)($row['img_url'] ?? ''));
if ($imgSrc === '') { $imgSrc = '/images/games/default_server.png'; }
?>
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" width="230" height="112"
onerror="this.src='/images/games/default_server.png'; this.onerror=null;">
<center><b><?php echo htmlspecialchars($canonicalGameName, ENT_QUOTES, 'UTF-8'); ?></b></center>
<?php
$isAdmin = false;
if ($isAdmin) {
if (!isset($_POST['edit'])) {
echo "<p style='color:gray;width:230px;'>" . htmlspecialchars((string)($row['description'] ?? ''), ENT_QUOTES, 'UTF-8') . "</p>";
echo "<form action='' method='post'>"
. "<input type='hidden' name='service_id' value='" . intval($row['service_id']) . "' />"
. "<input type='submit' name='edit' value='Edit' />"
. "</form>";
} else {
$descEditable = htmlspecialchars(str_replace("<br>", "\r\n", (string)($row['description'] ?? '')), ENT_QUOTES, 'UTF-8');
echo "<form action='' method='post'>"
. "<textarea style='resize:none;width:230px;height:132px;' name='description'>{$descEditable}</textarea><br>"
. "<input type='hidden' name='service_id' value='" . intval($row['service_id']) . "' />"
. "<input type='submit' name='save' value='Save' />"
. "</form>";
}
} else {
echo "<p style='color:gray;width:280px;'>" . htmlspecialchars((string)($row['description'] ?? ''), ENT_QUOTES, 'UTF-8') . "</p>";
}
?>
</div>
<table class="float-left">
<form method="post" action="add_to_cart.php">
<!-- service_id is updated by JS when the location OS changes -->
<input type="hidden" id="order_service_id" name="service_id" value="<?php echo intval($row['service_id']); ?>">
<input type="hidden" name="remote_control_password" value="">
<input type="hidden" name="ftp_password" value="">
<tr>
<td align="right"><b>Game Server Name</b> </td>
<td align="left">
<input type="text" name="home_name" size="40" value="<?php echo htmlspecialchars((string)($row['service_name'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>">
</td>
<tr>
<td align="right"><b>Location</b></td>
<td align="left">
<?php
// Fetch servers available for this game from billing_services.remote_server_id
// (a comma-separated list of numeric remote server IDs, e.g. "1,3,7").
// When OS-aware: also collect sibling service's allowed IDs to show all compatible locations.
$available_server = false;
$remoteIdsCsv = (string)($row['remote_server_id'] ?? '');
</td> // Also gather allowed IDs from sibling OS-variant services
</tr> $allAllowedIds = [];
<tr> foreach (explode(',', $remoteIdsCsv) as $part) {
<td align="right"><b>Configure</b></td> $part = trim($part);
<td align="left"> if ($part !== '' && ctype_digit($part)) {
<div class="slidecontainer"> $allAllowedIds[] = (int)$part;
<center><b>Player Slots</b> </center> }
<input type="range" name="max_players" min="<?php echo $row['slot_min_qty']?>" max="<?php echo $row['slot_max_qty']?>" value="<?php echo $row['slot_min_qty']?>" class="slider" id="playerRange"> }
<center><b>Months</b></center> // Add IDs from sibling service variants so locations appear regardless of which
<input type="range" name="qty" min="1" max="24" value="1" class="slider" id="invoiceRange"> // service variant is the "primary" one shown to the user.
if (count($osServiceMap) > 1) {
foreach ($osServiceMap as $_os => $sibSvcId) {
if ($sibSvcId === (int)$row['service_id']) continue;
$sibRow = $db->query("SELECT remote_server_id FROM {$table_prefix}billing_services WHERE service_id = " . intval($sibSvcId) . " LIMIT 1");
if ($sibRow && ($sibData = $sibRow->fetch_assoc())) {
foreach (explode(',', (string)($sibData['remote_server_id'] ?? '')) as $part) {
$part = trim($part);
if ($part !== '' && ctype_digit($part)) {
$allAllowedIds[] = (int)$part;
}
}
$sibRow->free();
}
}
}
$allAllowedIds = array_unique($allAllowedIds);
<p>Player Slots: <span id="playerSlots"></span><br> if (!empty($allAllowedIds)) {
<span>Price: $<?php echo number_format(floatval($row['price_monthly']),2 );?> USD</span><br> $inList = implode(',', $allAllowedIds);
<span id="invoiceDuration"></span><br> // Select server_os if the column exists (added by db_version 6 migration)
<span id="totalPrice"></span></p> $osSel = $hasServerOsColumn ? ', server_os' : ", 'any' AS server_os";
</div> $rsQuery = "SELECT remote_server_id, remote_server_name{$osSel}
FROM {$table_prefix}remote_servers
WHERE remote_server_id IN ({$inList})
<script> ORDER BY remote_server_name";
var slider = document.getElementById("playerRange"); $rsResult = $db->query($rsQuery);
var invoiceslider = document.getElementById("invoiceRange"); if ($rsResult) {
$firstServer = true;
while ($rs = $rsResult->fetch_assoc()) {
$rsID = (int)$rs['remote_server_id'];
$rsNAME = htmlspecialchars((string)$rs['remote_server_name'], ENT_QUOTES, 'UTF-8');
$rsOs = (string)($rs['server_os'] ?? 'any');
$checked = $firstServer ? ' checked' : '';
// Skip this location if we know the service is OS-specific and the
// node OS is incompatible AND no sibling service covers this OS.
if ($svcGameOs !== 'any' && $rsOs !== 'any' && $rsOs !== $svcGameOs && !isset($osServiceMap[$rsOs])) {
continue; // Incompatible OS variant with no fallback service
}
$available_server = true;
$firstServer = false;
$safeOs = htmlspecialchars($rsOs, ENT_QUOTES, 'UTF-8');
echo "<div>\n"
. " <input type='radio' name='ip_id' id='rs_{$rsID}' value='{$rsID}' data-os='{$safeOs}' required{$checked} onchange='gspUpdateServiceId(this)'>\n"
. " <label for='rs_{$rsID}'>{$rsNAME}</label>\n"
. "</div>\n";
}
$rsResult->free();
}
}
?>
</td>
</tr>
<tr>
<td align="right"><b>Configure</b></td>
<td align="left">
<div class="slidecontainer">
<center><b>Player Slots</b> </center>
<input type="range" name="max_players" min="<?php echo intval($row['slot_min_qty']); ?>" max="<?php echo intval($row['slot_max_qty']); ?>" value="<?php echo intval($row['slot_min_qty']); ?>" class="slider" id="playerRange">
<center><b>Months</b></center>
<input type="range" name="qty" min="1" max="24" value="1" class="slider" id="invoiceRange">
var output = document.getElementById("playerSlots"); <p>Player Slots: <span id="playerSlots"></span><br>
var price = document.getElementById("totalPrice"); <span>Price: $<?php echo number_format(floatval($row['price_monthly']), 2); ?> USD</span><br>
var invoiceDuration = document.getElementById("invoiceDuration"); <span id="invoiceDuration"></span><br>
var totalvalue = 0; <span id="totalPrice"></span></p>
</div>
<script>
output.innerHTML = slider.value; (function() {
invoiceDuration.innerHTML = "Duration: "+invoiceslider.value+" months"; var slider = document.getElementById("playerRange");
totalvalue = slider.value * invoiceslider.value * <?php echo number_format($row['price_monthly'],2);?>; var invoiceslider = document.getElementById("invoiceRange");
price.innerHTML = "Total Price: $"+totalvalue.toFixed(2) ; var output = document.getElementById("playerSlots");
var price = document.getElementById("totalPrice");
var invoiceDuration = document.getElementById("invoiceDuration");
var pricePerSlot = <?php echo number_format(floatval($row['price_monthly']), 2, '.', ''); ?>;
slider.oninput = function() { // OS-aware service variant map: {os: service_id}
output.innerHTML = this.value; var osServiceMap = <?php echo $osServiceMapJson; ?>;
invoiceDuration.innerHTML = "Duration: "+invoiceslider.value+" months";
totalvalue = slider.value * invoiceslider.value * <?php echo number_format($row['price_monthly'],2);?>; function recalc() {
price.innerHTML = "Total Price: $"+totalvalue.toFixed(2) ; var slots = parseInt(slider.value, 10);
} var months = parseInt(invoiceslider.value, 10);
invoiceslider.oninput = function() { output.innerHTML = slots;
invoiceDuration.innerHTML = "Duration: "+invoiceslider.value+" months"; invoiceDuration.innerHTML = "Duration: " + months + " month" + (months !== 1 ? "s" : "");
totalvalue = slider.value * invoiceslider.value * <?php echo number_format($row['price_monthly'],2);?>; price.innerHTML = "Total Price: $" + (slots * months * pricePerSlot).toFixed(2);
price.innerHTML = "Total Price: $"+totalvalue.toFixed(2) ; }
} recalc();
</script> slider.oninput = recalc;
invoiceslider.oninput = recalc;
<input type="hidden" name="invoice_duration" value="month" /> // Update the hidden service_id based on the selected location's OS.
</td> window.gspUpdateServiceId = function(radio) {
</tr> var os = radio.getAttribute('data-os') || 'any';
var svcInput = document.getElementById('order_service_id');
<tr> if (!svcInput) return;
<td align="left" colspan="2"> // Pick the service for this OS, fall back to 'any', then first available
<input name="service_id" type="hidden" value="<?php echo $row['service_id'];?>"/> if (osServiceMap[os] !== undefined) {
<?php svcInput.value = osServiceMap[os];
// Only show Add to Cart when logged in } else if (osServiceMap['any'] !== undefined) {
$is_logged_in = (isset($_SESSION['website_user_id']) && !empty($_SESSION['website_user_id'])) || (isset($_SESSION['website_username']) && !empty($_SESSION['website_username'])); svcInput.value = osServiceMap['any'];
?> }
<?php if ($available_server && $is_logged_in): ?> // else keep the current value
<button type="submit" name="add_to_cart" class="gsw-btn">Add to Cart</button> };
<?php else: ?>
<div class="login-placeholder">Please <a href="login.php">login</a> to order</div> // Trigger on page load for the pre-checked radio
<?php endif; ?> var checked = document.querySelector('input[name="ip_id"]:checked');
</form> if (checked) { window.gspUpdateServiceId(checked); }
</td> })();
</tr> </script>
<tr>
<td align="left" colspan="2"> <input type="hidden" name="invoice_duration" value="month" />
<form action ="serverlist.php" method="POST"> </td>
<button class="gsw-btn-secondary">Back to List</button> </tr>
</form>
</td> <tr>
</tr> <td align="left" colspan="2">
</table> <?php
<?php // Only show Add to Cart when logged in
} $is_logged_in = (isset($_SESSION['website_user_id']) && !empty($_SESSION['website_user_id'])) || (isset($_SESSION['website_username']) && !empty($_SESSION['website_username']));
} ?>
?> <?php if ($available_server && $is_logged_in): ?>
</div> <button type="submit" name="add_to_cart" class="gsw-btn">Add to Cart</button>
<?php elseif (!$is_logged_in): ?>
<div class="login-placeholder">Please <a href="login.php">login</a> to order</div>
<?php else: ?>
<p class="error">No available server locations for this game.</p>
<?php endif; ?>
</form>
</td>
</tr>
<tr>
<td align="left" colspan="2">
<form action="serverlist.php" method="GET">
<button class="gsw-btn-secondary">Back to List</button>
</form>
</td>
</tr>
</table>
<?php
}
}
?>
</div>
<?php <?php
// Close database connection // Close database connection
billing_maybe_close_db($db); billing_maybe_close_db($db);
?> ?>
</body> </body>
<?php include(__DIR__ . '/includes/footer.php'); ?> <?php include(__DIR__ . '/includes/footer.php'); ?>

View file

@ -7,10 +7,6 @@
</head> </head>
<body> <body>
<?php <?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Include database configuration // Include database configuration
require_once(__DIR__ . '/bootstrap.php'); require_once(__DIR__ . '/bootstrap.php');
@ -37,23 +33,62 @@ if (isset($_POST['save']) && !empty($_POST['description'])) {
$stmt->close(); $stmt->close();
} }
// Fetch services // Fetch services, joining config_homes to get canonical game_name and game_key for OS detection.
// LEFT JOIN so services without a linked config_homes entry still appear.
$service_id = isset($_REQUEST['service_id']) ? intval($_REQUEST['service_id']) : 0; $service_id = isset($_REQUEST['service_id']) ? intval($_REQUEST['service_id']) : 0;
$where_service_id = $service_id !== 0 if ($service_id !== 0) {
? "WHERE enabled = 1 AND service_id = $service_id AND remote_server_id != '' AND remote_server_id IS NOT NULL" $where_clause = "WHERE bs.enabled = 1 AND bs.service_id = {$service_id} AND bs.remote_server_id != '' AND bs.remote_server_id IS NOT NULL";
: "WHERE enabled = 1 AND remote_server_id != '' AND remote_server_id IS NOT NULL"; } else {
$qry_services = "SELECT * FROM {$table_prefix}billing_services $where_service_id ORDER BY service_name"; $where_clause = "WHERE bs.enabled = 1 AND bs.remote_server_id != '' AND bs.remote_server_id IS NOT NULL";
}
$qry_services = "SELECT bs.*, ch.game_name AS cfg_game_name, ch.game_key AS cfg_game_key
FROM {$table_prefix}billing_services bs
LEFT JOIN {$table_prefix}config_homes ch ON ch.home_cfg_id = bs.home_cfg_id
{$where_clause}
ORDER BY bs.service_name";
$result_services = $db->query($qry_services); $result_services = $db->query($qry_services);
if (!$result_services) {
// config_homes join may not exist on all installs; fall back to services-only query
$where_clause_fallback = str_replace('bs.', '', $where_clause);
$qry_services_fallback = "SELECT service_id, home_cfg_id, enabled, service_name, description,
img_url, price_monthly, slot_min_qty, slot_max_qty,
remote_server_id,
NULL AS cfg_game_name, NULL AS cfg_game_key
FROM {$table_prefix}billing_services
{$where_clause_fallback}
ORDER BY service_name";
$result_services = $db->query($qry_services_fallback);
}
if (!$result_services) { if (!$result_services) {
echo "<meta http-equiv='refresh' content='1'>"; echo "<meta http-equiv='refresh' content='1'>";
billing_maybe_close_db($db); billing_maybe_close_db($db);
return; return;
} }
// Fetch all service rows into an array so the template foreach works correctly // Fetch all service rows and deduplicate by canonical game name so that
// arma3_linux64 and arma3_win64 (both named "Arma 3") appear only once.
// When a specific service_id is requested we skip deduplication.
$serviceRows = []; $serviceRows = [];
$seenCanonical = [];
while ($row = $result_services->fetch_assoc()) { while ($row = $result_services->fetch_assoc()) {
if ($service_id !== 0) {
// Single-service detail view: always include without deduplication
$serviceRows[] = $row;
continue;
}
// Derive canonical display name: prefer config_homes game_name (consistent across OS
// variants), fall back to service_name.
$canonicalName = !empty($row['cfg_game_name'])
? $row['cfg_game_name']
: $row['service_name'];
if (isset($seenCanonical[$canonicalName])) {
// Already have this game — skip the duplicate OS variant
continue;
}
$seenCanonical[$canonicalName] = true;
$serviceRows[] = $row; $serviceRows[] = $row;
} }
$result_services->free(); $result_services->free();
@ -69,13 +104,18 @@ include(__DIR__ . '/includes/menu.php');
<?php if (!isset($_REQUEST['service_id'])): ?> <?php if (!isset($_REQUEST['service_id'])): ?>
<!-- Service listing (all) --> <!-- Service listing (all) -->
<div class="float-left p-30-20"> <div class="float-left p-30-20">
<?php $imgSrc = billing_image_url((string)($row['img_url'] ?? '')); ?>
<?php if ($imgSrc !== ''): ?>
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" width="460" height="225"><br>
<?php endif; ?>
<strong><?php echo htmlspecialchars((string)$row['service_name'], ENT_QUOTES, 'UTF-8'); ?></strong><br>
<?php <?php
echo ($row['price_monthly'] == 0.0) ? "FREE" : "$" . number_format(floatval($row['price_monthly']), 2) . " Monthly"; $imgSrc = billing_image_url((string)($row['img_url'] ?? ''));
// Use a generic fallback image when the service has no image configured
if ($imgSrc === '') {
$imgSrc = '/images/games/default_server.png';
}
?>
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" width="460" height="225"
onerror="this.src='/images/games/default_server.png'; this.onerror=null;"><br>
<strong><?php echo htmlspecialchars((string)($row['cfg_game_name'] ?? $row['service_name']), ENT_QUOTES, 'UTF-8'); ?></strong><br>
<?php
echo (floatval($row['price_monthly']) == 0.0) ? "FREE" : "$" . number_format(floatval($row['price_monthly']), 2) . " Monthly";
?> ?>
<br> <br>
@ -84,44 +124,48 @@ include(__DIR__ . '/includes/menu.php');
<?php else: ?> <?php else: ?>
<!-- Single service detail view --> <!-- Single service detail view -->
<div class="float-left decorative-bottom"> <div class="float-left decorative-bottom">
<?php $imgSrc = billing_image_url((string)($row['img_url'] ?? '')); ?> <?php
<?php if ($imgSrc !== ''): ?> $imgSrc = billing_image_url((string)($row['img_url'] ?? ''));
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" width="230" height="112"><br> if ($imgSrc === '') {
<?php endif; ?> $imgSrc = '/images/games/default_server.png';
<center><b><?php echo htmlspecialchars((string)$row['service_name'], ENT_QUOTES, 'UTF-8'); ?></b></center> }
?>
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" width="230" height="112"
onerror="this.src='/images/games/default_server.png'; this.onerror=null;"><br>
<center><b><?php echo htmlspecialchars((string)($row['cfg_game_name'] ?? $row['service_name']), ENT_QUOTES, 'UTF-8'); ?></b></center>
<?php <?php
$isAdmin = false; // change to actual check, e.g. current_user_can('administrator') $isAdmin = false;
if ($isAdmin) { if ($isAdmin) {
if (!isset($_POST['edit'])) { if (!isset($_POST['edit'])) {
echo "<p style='color:gray;width:230px;'>{$row['description']}</p>"; echo "<p style='color:gray;width:230px;'>" . htmlspecialchars((string)($row['description'] ?? ''), ENT_QUOTES, 'UTF-8') . "</p>";
echo "<form method='post'> echo "<form method='post'>
<input type='hidden' name='service_id' value='{$row['servioce_id']}'> <input type='hidden' name='service_id' value='" . intval($row['service_id']) . "'>
<input type='submit' name='edit' value='Edit'> <input type='submit' name='edit' value='Edit'>
</form>"; </form>";
} else { } else {
$desc = str_replace("<br>", "\r\n", $row['description']); $desc = htmlspecialchars(str_replace("<br>", "\r\n", (string)($row['description'] ?? '')), ENT_QUOTES, 'UTF-8');
echo "<form method='post'> echo "<form method='post'>
<textarea style='resize:none;width:230px;height:132px;' name='description'>$desc</textarea><br> <textarea style='resize:none;width:230px;height:132px;' name='description'>{$desc}</textarea><br>
<input type='hidden' name='service_id' value='{$row['service_id']}'> <input type='hidden' name='service_id' value='" . intval($row['service_id']) . "'>
<input type='submit' name='save' value='Save'> <input type='submit' name='save' value='Save'>
</form>"; </form>";
} }
} else { } else {
echo "<p style='color:gray;width:280px;'>{$row['description']}</p>"; echo "<p style='color:gray;width:280px;'>" . htmlspecialchars((string)($row['description'] ?? ''), ENT_QUOTES, 'UTF-8') . "</p>";
} }
?> ?>
</div> </div>
<!-- Order Form --> <!-- Order Form -->
<form method="post" action="order_server.php"> <form method="post" action="order_server.php">
<input type="hidden" name="service_id" value="<?php echo $row['service_id']; ?>"> <input type="hidden" name="service_id" value="<?php echo intval($row['service_id']); ?>">
<input type="hidden" name="remote_control_password" value=""> <input type="hidden" name="remote_control_password" value="">
<input type="hidden" name="ftp_password" value=""> <input type="hidden" name="ftp_password" value="">
<table class="float-left"> <table class="float-left">
<tr> <tr>
<td align="right"><b>Game Server Name</b></td> <td align="right"><b>Game Server Name</b></td>
<td><input type="text" name="home_name" size="40" value="<?php echo $row['service_name']; ?>"></td> <td><input type="text" name="home_name" size="40" value="<?php echo htmlspecialchars((string)($row['service_name'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>"></td>
</tr> </tr>
<!-- Add other form fields as needed --> <!-- Add other form fields as needed -->
<tr> <tr>

View file

@ -233,61 +233,61 @@ function config_games_print_editor_css()
$printed = true; $printed = true;
echo <<<CSS echo <<<CSS
<style> <style>
.xml-editor-wrapper{margin:20px 0;padding:12px;background:#111;border:1px solid #222;border-radius:8px} .xml-editor-wrapper{margin:20px 0;padding:14px;background:#111;border:1px solid #222;border-radius:8px;font-size:1rem}
.xml-node{border:1px solid #333;border-radius:6px;padding:12px;margin-bottom:10px;background:#181818} .xml-node{border:1px solid #333;border-radius:6px;padding:14px;margin-bottom:12px;background:#181818}
.xml-node--required{border-left:3px solid #1c6dd0} .xml-node--required{border-left:3px solid #1c6dd0}
.xml-node__header{display:flex;justify-content:space-between;align-items:center;gap:12px;border-bottom:1px solid #2a2a2a;padding-bottom:6px;margin-bottom:8px} .xml-node__header{display:flex;justify-content:space-between;align-items:center;gap:12px;border-bottom:1px solid #2a2a2a;padding-bottom:8px;margin-bottom:10px}
.xml-node__title{font-weight:600;color:#f5f5f5} .xml-node__title{font-weight:600;color:#f5f5f5;font-size:1rem}
.xml-node__title--required::after{content:" *";color:#e06c75;font-size:0.8rem} .xml-node__title--required::after{content:" *";color:#e06c75;font-size:0.85rem}
.xml-node__path{font-size:0.85rem;color:#989898} .xml-node__path{font-size:0.88rem;color:#b0b0b0}
.xml-node__badge{font-size:0.72rem;padding:2px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:0.05em;margin-left:6px} .xml-node__badge{font-size:0.75rem;padding:2px 7px;border-radius:3px;text-transform:uppercase;letter-spacing:0.05em;margin-left:6px}
.xml-node__badge--required{background:#1c3a6d;color:#7eb3f0} .xml-node__badge--required{background:#1c3a6d;color:#7eb3f0}
.xml-node__badge--optional{background:#2a2a2a;color:#888} .xml-node__badge--optional{background:#2a2a2a;color:#aaa}
.xml-node__body label{font-size:0.85rem;color:#bbb;display:block;margin-bottom:4px} .xml-node__body label{font-size:0.9rem;color:#d0d0d0;display:block;margin-bottom:5px}
.xml-node__body input[type="text"], .xml-node__body textarea, .xml-node__body select{width:100%;padding:8px;border:1px solid #3a3a3a;border-radius:4px;background:#101010;color:#fff;font-family:monospace} .xml-node__body input[type="text"], .xml-node__body textarea, .xml-node__body select{width:100%;padding:8px 10px;border:1px solid #3a3a3a;border-radius:4px;background:#101010;color:#f0f0f0;font-family:monospace;font-size:0.93rem}
.xml-node__body textarea{min-height:120px} .xml-node__body textarea{min-height:130px;line-height:1.4}
.xml-node__attributes{margin-top:8px} .xml-node__attributes{margin-top:8px}
.xml-node__attributes .attr-row{display:flex;gap:8px;align-items:center;margin-bottom:6px} .xml-node__attributes .attr-row{display:flex;gap:8px;align-items:center;margin-bottom:6px}
.xml-node__attributes .attr-row input[type="text"]{flex:1} .xml-node__attributes .attr-row input[type="text"]{flex:1}
.xml-children{margin-top:10px;border-left:2px solid #2a2a2a;padding-left:12px} .xml-children{margin-top:10px;border-left:2px solid #2a2a2a;padding-left:14px}
.xml-actions{display:flex;justify-content:flex-end;margin-top:16px;padding:8px 18px 0} .xml-actions{display:flex;justify-content:flex-end;margin-top:16px;padding:8px 18px 0}
.xml-node__actions{display:flex;gap:8px;align-items:center} .xml-node__actions{display:flex;gap:8px;align-items:center}
.xml-node__apply{background:#1c6dd0;border:1px solid #114b99;color:#fff;padding:6px 12px;border-radius:4px;cursor:pointer} .xml-node__apply{background:#1c6dd0;border:1px solid #114b99;color:#fff;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:0.93rem}
.xml-node__apply:hover{background:#1f7aec} .xml-node__apply:hover{background:#1f7aec}
.xml-global-save{background:#1c6dd0;border:1px solid #114b99;color:#fff;padding:10px 28px;border-radius:4px;font-weight:600;text-transform:uppercase;letter-spacing:0.03em;cursor:pointer;transition:background 0.2s ease,transform 0.2s ease;box-shadow:0 2px 6px rgba(0,0,0,0.35)} .xml-global-save{background:#1c6dd0;border:1px solid #114b99;color:#fff;padding:10px 28px;border-radius:4px;font-weight:600;text-transform:uppercase;letter-spacing:0.03em;cursor:pointer;transition:background 0.2s ease,transform 0.2s ease;box-shadow:0 2px 6px rgba(0,0,0,0.35);font-size:0.95rem}
.xml-global-save:hover{background:#1f7aec;transform:translateY(-1px)} .xml-global-save:hover{background:#1f7aec;transform:translateY(-1px)}
.xml-global-save--top{float:right;margin:0 18px 12px 0} .xml-global-save--top{float:right;margin:0 18px 12px 0}
.xml-hint{font-size:0.85rem;color:#999;margin-top:4px} .xml-hint{font-size:0.88rem;color:#b0b0b0;margin-top:5px}
.xml-validation-errors{background:#2d0f0f;border:1px solid #8b1c1c;border-radius:6px;padding:12px 16px;margin-bottom:14px;color:#f88} .xml-validation-errors{background:#2d0f0f;border:1px solid #8b1c1c;border-radius:6px;padding:12px 16px;margin-bottom:14px;color:#ffaaaa;font-size:0.93rem}
.xml-validation-errors ul{margin:6px 0 0 16px;padding:0} .xml-validation-errors ul{margin:6px 0 0 16px;padding:0}
.xml-raw-toggle{margin:8px 0 4px;color:#7eb3f0;cursor:pointer;font-size:0.9rem;text-decoration:underline;background:none;border:none;padding:0} .xml-raw-toggle{margin:8px 0 4px;color:#7eb3f0;cursor:pointer;font-size:0.95rem;text-decoration:underline;background:none;border:none;padding:0}
.xml-raw-section{margin-top:10px;display:none} .xml-raw-section{margin-top:10px;display:none}
.xml-raw-section textarea{width:100%;min-height:300px;font-family:monospace;font-size:0.85rem;background:#0c0c0c;color:#eee;border:1px solid #3a3a3a;border-radius:4px;padding:8px} .xml-raw-section textarea{width:100%;min-height:320px;font-family:monospace;font-size:0.9rem;background:#0c0c0c;color:#f0f0f0;border:1px solid #3a3a3a;border-radius:4px;padding:10px;line-height:1.4}
.xml-raw-warning{background:#2d2200;border:1px solid #7a5a00;border-radius:4px;padding:8px 12px;color:#f0c050;font-size:0.85rem;margin-bottom:6px} .xml-raw-warning{background:#2d2200;border:1px solid #7a5a00;border-radius:4px;padding:10px 14px;color:#f0c050;font-size:0.9rem;margin-bottom:8px}
.xml-section-header{margin:20px 0 4px;font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:0.1em;border-bottom:1px solid #2a2a2a;padding-bottom:4px} .xml-section-header{margin:22px 0 6px;font-size:0.85rem;color:#aaa;text-transform:uppercase;letter-spacing:0.1em;border-bottom:1px solid #2a2a2a;padding-bottom:5px}
.xml-node__desc{font-size:0.82rem;color:#aaa;background:#0e0e0e;border-left:3px solid #2a4a7a;padding:6px 10px;margin:6px 0 8px;border-radius:0 4px 4px 0} .xml-node__desc{font-size:0.88rem;color:#c8c8c8;background:#0e0e0e;border-left:3px solid #2a4a7a;padding:8px 12px;margin:8px 0 10px;border-radius:0 4px 4px 0;line-height:1.5}
.xml-node__options{margin:4px 0 4px 12px;padding:0;list-style:disc inside} .xml-node__options{margin:6px 0 4px 14px;padding:0;list-style:disc inside}
.xml-node__options li{margin-bottom:2px} .xml-node__options li{margin-bottom:3px;font-size:0.9rem}
.xml-node__options code{color:#7eb3f0;background:rgba(30,100,200,0.12);padding:1px 4px;border-radius:3px} .xml-node__options code{color:#7eb3f0;background:rgba(30,100,200,0.12);padding:1px 5px;border-radius:3px}
.xml-node__example{display:block;margin-top:4px;color:#888} .xml-node__example{display:block;margin-top:6px;color:#aaa;font-size:0.88rem}
.xml-node__example code{color:#a0d0a0;background:rgba(30,150,50,0.1);padding:1px 4px;border-radius:3px} .xml-node__example code{color:#a0d0a0;background:rgba(30,150,50,0.1);padding:1px 5px;border-radius:3px}
.xml-jump-link{display:inline-block;margin-bottom:12px;padding:6px 14px;background:#1c6dd0;color:#fff;border-radius:4px;text-decoration:none;font-size:0.9rem} .xml-jump-link{display:inline-block;margin-bottom:12px;padding:7px 16px;background:#1c6dd0;color:#fff;border-radius:4px;text-decoration:none;font-size:0.93rem}
.xml-jump-link:hover{background:#1f7aec;text-decoration:none} .xml-jump-link:hover{background:#1f7aec;text-decoration:none}
.xml-section-grid{display:flex;flex-direction:column;gap:14px;margin-bottom:18px} .xml-section-grid{display:flex;flex-direction:column;gap:16px;margin-bottom:20px}
.xml-section-block{border:1px solid #303030;border-radius:6px;background:#141414;padding:12px} .xml-section-block{border:1px solid #303030;border-radius:6px;background:#141414;padding:14px}
.xml-section-block__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px} .xml-section-block__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:10px}
.xml-section-block__title{font-size:1.02rem;color:#f0f0f0;font-weight:600} .xml-section-block__title{font-size:1.05rem;color:#f0f0f0;font-weight:600}
.xml-section-block__meta{font-size:0.8rem;color:#9f9f9f} .xml-section-block__meta{font-size:0.83rem;color:#b0b0b0}
.xml-section-block__desc{font-size:0.86rem;color:#b0b0b0;margin:0 0 10px} .xml-section-block__desc{font-size:0.9rem;color:#c8c8c8;margin:0 0 12px;line-height:1.5}
.xml-section-block textarea{width:100%;min-height:170px;background:#0f0f0f;border:1px solid #3c3c3c;border-radius:4px;color:#f7f7f7;padding:8px;font-family:monospace;font-size:0.84rem} .xml-section-block textarea{width:100%;min-height:180px;background:#0f0f0f;border:1px solid #3c3c3c;border-radius:4px;color:#f7f7f7;padding:10px;font-family:monospace;font-size:0.9rem;line-height:1.4}
.xml-section-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px} .xml-section-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.xml-btn{border:1px solid #3f3f3f;background:#222;color:#fff;padding:6px 10px;border-radius:4px;cursor:pointer} .xml-btn{border:1px solid #3f3f3f;background:#222;color:#e8e8e8;padding:7px 12px;border-radius:4px;cursor:pointer;font-size:0.9rem}
.xml-btn:hover{background:#2a2a2a} .xml-btn:hover{background:#2a2a2a}
.xml-btn--primary{background:#1c6dd0;border-color:#114b99} .xml-btn--primary{background:#1c6dd0;border-color:#114b99;color:#fff}
.xml-btn--primary:hover{background:#1f7aec} .xml-btn--primary:hover{background:#1f7aec}
.xml-btn--danger{background:#6b1f1f;border-color:#8d2d2d} .xml-btn--danger{background:#6b1f1f;border-color:#8d2d2d;color:#ffcccc}
.xml-btn--danger:hover{background:#8d2d2d} .xml-btn--danger:hover{background:#8d2d2d}
.xml-add-section{border:1px dashed #3a3a3a;border-radius:6px;padding:10px;margin-bottom:16px} .xml-add-section{border:1px dashed #3a3a3a;border-radius:6px;padding:12px;margin-bottom:18px}
.xml-add-section select{min-width:260px} .xml-add-section select{min-width:260px}
</style> </style>
CSS; CSS;
@ -730,6 +730,23 @@ function config_games_render_top_level_editor($home_cfg_id, $configFile)
echo "</div>"; echo "</div>";
echo "</form>"; echo "</form>";
} }
// Render missing optional schema sections as informational cards with an Add button.
$descriptions = config_games_tag_descriptions();
foreach ($optionalMissing as $missingName) {
$safeMissing = htmlspecialchars($missingName, ENT_QUOTES, 'UTF-8');
$missingDesc = htmlspecialchars($descriptions[$missingName]['desc'] ?? 'Optional configuration section.', ENT_QUOTES, 'UTF-8');
echo "<div class='xml-section-block' style='border-color:#2a3a2a;background:#101810;opacity:0.85'>";
echo "<div class='xml-section-block__head'><div><div class='xml-section-block__title' style='color:#a0c0a0'>{$safeMissing}</div><div class='xml-section-block__meta'>Optional &mdash; not in this file</div></div></div>";
echo "<p class='xml-section-block__desc' style='font-style:italic;color:#9aba9a'>This section is not currently in this XML file. You can add it if needed.</p>";
echo "<p class='xml-section-block__desc'>{$missingDesc}</p>";
echo "<form action='?m=config_games&amp;home_cfg_id=" . (int)$home_cfg_id . "' method='post'>";
echo "<input type='hidden' name='home_cfg_id' value='" . (int)$home_cfg_id . "'>";
echo "<input type='hidden' name='section_name' value='{$safeMissing}'>";
echo "<div class='xml-section-actions'><button class='xml-btn xml-btn--primary' type='submit' name='add_optional_section' value='1'>Add Section</button></div>";
echo "</form>";
echo "</div>";
}
echo "</div>"; echo "</div>";
} }
@ -1134,22 +1151,12 @@ function exec_ogp_module() {
echo "<div id='xml-editor-section'>"; echo "<div id='xml-editor-section'>";
config_games_render_top_level_editor($home_cfg_id, $config_file); config_games_render_top_level_editor($home_cfg_id, $config_file);
echo "<details style='margin:18px 0'><summary style='cursor:pointer;color:#9dc7ff'>Open legacy detailed node editor (previous default editor)</summary>";
echo "<form action='?m=config_games&amp;home_cfg_id=".$home_cfg_id."' method='post'>";
echo "<input type='hidden' name='home_cfg_id' value='".(int)$home_cfg_id."'>";
echo "<button type='submit' name='save_xml' value='1' class='xml-global-save xml-global-save--top'>".get_lang('save')."</button>";
echo "<div style='clear:both'></div>";
echo config_games_render_editor($xml);
echo "<div class='xml-actions'><button type='submit' name='save_xml' value='1' class='xml-global-save'>".get_lang('save')."</button></div>";
echo "<p class='note'>&#x2605; = required field. Use the action dropdown to remove entire sections. Attribute values left blank will be removed. Script sections such as post_install are fully editable. Changes are validated against the schema before saving.</p>";
echo "</form>";
echo "</details>";
// Raw XML editor // Raw XML editor
echo "<hr style='margin:24px 0;border-color:#333'>"; echo "<hr style='margin:24px 0;border-color:#333'>";
echo "<h3 style='margin-bottom:8px'>Full Raw XML Editor</h3>"; echo "<h3 style='margin-bottom:8px'>Full Raw XML Editor</h3>";
echo "<div class='xml-raw-warning'>&#x26A0; <strong>Warning:</strong> Saving raw XML bypasses the guided editor. The file will be validated against the schema before saving. Invalid XML will be rejected.</div>"; echo "<div class='xml-raw-warning'>&#x26A0; <strong>Warning:</strong> Saving raw XML bypasses the guided editor. The file will be validated against the schema before saving. Invalid XML will be rejected.</div>";
echo "<button type='button' class='xml-raw-toggle' onclick=\"var s=document.getElementById('raw_xml_section');s.style.display=s.style.display==='none'?'block':'none'\">Toggle Raw XML Editor</button>"; echo "<script>function gspToggleRawXml(){var s=document.getElementById('raw_xml_section');var b=document.getElementById('raw_xml_toggle_btn');if(s.style.display!=='block'){s.style.display='block';b.textContent='Hide Raw XML Editor';}else{s.style.display='none';b.textContent='Show Raw XML Editor';}}</script>";
echo "<button type='button' id='raw_xml_toggle_btn' class='xml-raw-toggle' onclick='gspToggleRawXml()'>Show Raw XML Editor</button>";
echo "<div id='raw_xml_section' class='xml-raw-section'>"; echo "<div id='raw_xml_section' class='xml-raw-section'>";
echo "<form action='?m=config_games&amp;home_cfg_id=".$home_cfg_id."' method='post'>"; echo "<form action='?m=config_games&amp;home_cfg_id=".$home_cfg_id."' method='post'>";
echo "<input type='hidden' name='home_cfg_id' value='".(int)$home_cfg_id."'>"; echo "<input type='hidden' name='home_cfg_id' value='".(int)$home_cfg_id."'>";