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,
players, period_start, period_end, subtotal, total_due, payment_status, qty, coupon_id
) VALUES (
0, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, 0.00, 'USD', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0
0, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, 0.00, 'USD', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0
)";
$stmt = $db->prepare($sql);

View file

@ -541,7 +541,7 @@ $siteBase = $protocol . $host;
<div class="cart-empty">
<h2>Your cart is empty</h2>
<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>
<?php else: ?>
<table class="cart-table">
@ -640,7 +640,7 @@ $siteBase = $protocol . $host;
</button>
</form>
<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>
</div>
</div>
@ -672,7 +672,7 @@ $siteBase = $protocol . $host;
<div id="status-message" class="status-message"></div>
<?php endif; ?>
<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>
</div>
</div>

View file

@ -24,8 +24,8 @@
// Module general information
$module_title = "billing";
$module_version = "3.4";
$db_version = 5;
$module_version = "3.5";
$db_version = 6;
$module_required = FALSE;
// Module description
$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
/*
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
for a single specific game server and has the "Add to Cart" button.
The gameserver selected is passed from the gameserverss page by a Post of the ServiceID
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
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
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.
This is the "order gameserver" page. It displays the options for a single specific game server and
has the "Add to Cart" button. The gameserver selected is passed from the serverlist page by a GET
of the service_id. When the user clicks "Add to Cart", the next page is add_to_cart.php.
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
remote_servers.server_os) and routes the cart add to the correct service variant.
*/
// Require login for ordering
@ -43,278 +41,363 @@ if (!$db) {
include(__DIR__ . '/includes/top.php');
include(__DIR__ . '/includes/menu.php');
if (isset($_POST['save']) AND !empty($_POST['description']))
{
$new_description = str_replace("\\r\\n", "<br>", $_POST['description']);
$service = $_POST['service_id'];
$change_description = "UPDATE {$table_prefix}billing_services
SET description ='".$new_description."'
WHERE service_id=".$service;
$save = $db->query($change_description);
}
?>
if (isset($_POST['save']) && !empty($_POST['description'])) {
$new_description = str_replace("\\r\\n", "<br>", $_POST['description']);
$service = intval($_POST['service_id']);
$stmt = $db->prepare("UPDATE {$table_prefix}billing_services SET description = ? WHERE service_id = ?");
if ($stmt) {
$stmt->bind_param("si", $new_description, $service);
$stmt->execute();
$stmt->close();
}
}
/**
* 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';
}
<!-- ------------------------------------------------------------------------------
THIS IS WHAT WE DISPLAY ON THE SHOP PAGE AT THE TOP
-->
<?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";
// --- Fetch the requested service with config_homes join for canonical game info ---
$req_service_id = intval($_REQUEST['service_id'] ?? 0);
if ($req_service_id !== 0) {
$where_service_id = " WHERE bs.enabled = 1 AND bs.service_id=" . $req_service_id;
} 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>
<form action="<?php echo $row['description'];?>" method="POST">
<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>
<a href="order.php?service_id=<?php echo intval($row['service_id']); ?>" class="gsw-btn">Order Now</a>
</div>
</>
<?php
} else
//THIS IS THE SERVER WE WANT TO ORDER
{
?>
<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";
}
}
}
?>
<?php
}else
// THIS IS THE SERVER WE WANT TO ORDER
{
// Determine canonical game name and OS for this service
$svcGameKey = (string)($row['cfg_game_key'] ?? '');
$svcGameOs = order_game_key_os($svcGameKey);
$canonicalGameName = (string)($row['cfg_game_name'] ?? $row['service_name']);
// 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>
</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 $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>
<input type="range" name="qty" min="1" max="24" value="1" class="slider" id="invoiceRange">
// Also gather allowed IDs from sibling OS-variant services
$allAllowedIds = [];
foreach (explode(',', $remoteIdsCsv) as $part) {
$part = trim($part);
if ($part !== '' && ctype_digit($part)) {
$allAllowedIds[] = (int)$part;
}
}
// Add IDs from sibling service variants so locations appear regardless of which
// 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>
<span>Price: $<?php echo number_format(floatval($row['price_monthly']),2 );?> USD</span><br>
<span id="invoiceDuration"></span><br>
<span id="totalPrice"></span></p>
</div>
<script>
var slider = document.getElementById("playerRange");
var invoiceslider = document.getElementById("invoiceRange");
if (!empty($allAllowedIds)) {
$inList = implode(',', $allAllowedIds);
// Select server_os if the column exists (added by db_version 6 migration)
$osSel = $hasServerOsColumn ? ', server_os' : ", 'any' AS server_os";
$rsQuery = "SELECT remote_server_id, remote_server_name{$osSel}
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');
$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");
var price = document.getElementById("totalPrice");
var invoiceDuration = document.getElementById("invoiceDuration");
var totalvalue = 0;
<p>Player Slots: <span id="playerSlots"></span><br>
<span>Price: $<?php echo number_format(floatval($row['price_monthly']), 2); ?> USD</span><br>
<span id="invoiceDuration"></span><br>
<span id="totalPrice"></span></p>
</div>
output.innerHTML = slider.value;
invoiceDuration.innerHTML = "Duration: "+invoiceslider.value+" months";
totalvalue = slider.value * invoiceslider.value * <?php echo number_format($row['price_monthly'],2);?>;
price.innerHTML = "Total Price: $"+totalvalue.toFixed(2) ;
<script>
(function() {
var slider = document.getElementById("playerRange");
var invoiceslider = document.getElementById("invoiceRange");
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() {
output.innerHTML = this.value;
invoiceDuration.innerHTML = "Duration: "+invoiceslider.value+" months";
totalvalue = slider.value * invoiceslider.value * <?php echo number_format($row['price_monthly'],2);?>;
price.innerHTML = "Total Price: $"+totalvalue.toFixed(2) ;
}
invoiceslider.oninput = function() {
invoiceDuration.innerHTML = "Duration: "+invoiceslider.value+" months";
totalvalue = slider.value * invoiceslider.value * <?php echo number_format($row['price_monthly'],2);?>;
price.innerHTML = "Total Price: $"+totalvalue.toFixed(2) ;
}
</script>
<input type="hidden" name="invoice_duration" value="month" />
</td>
</tr>
<tr>
<td align="left" colspan="2">
<input name="service_id" type="hidden" value="<?php echo $row['service_id'];?>"/>
<?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): ?>
<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>
<?php endif; ?>
</form>
</td>
</tr>
<tr>
<td align="left" colspan="2">
<form action ="serverlist.php" method="POST">
<button class="gsw-btn-secondary">Back to List</button>
</form>
</td>
</tr>
</table>
<?php
}
}
?>
</div>
// OS-aware service variant map: {os: service_id}
var osServiceMap = <?php echo $osServiceMapJson; ?>;
function recalc() {
var slots = parseInt(slider.value, 10);
var months = parseInt(invoiceslider.value, 10);
output.innerHTML = slots;
invoiceDuration.innerHTML = "Duration: " + months + " month" + (months !== 1 ? "s" : "");
price.innerHTML = "Total Price: $" + (slots * months * pricePerSlot).toFixed(2);
}
recalc();
slider.oninput = recalc;
invoiceslider.oninput = recalc;
// Update the hidden service_id based on the selected location's OS.
window.gspUpdateServiceId = function(radio) {
var os = radio.getAttribute('data-os') || 'any';
var svcInput = document.getElementById('order_service_id');
if (!svcInput) return;
// Pick the service for this OS, fall back to 'any', then first available
if (osServiceMap[os] !== undefined) {
svcInput.value = osServiceMap[os];
} else if (osServiceMap['any'] !== undefined) {
svcInput.value = osServiceMap['any'];
}
// else keep the current value
};
// Trigger on page load for the pre-checked radio
var checked = document.querySelector('input[name="ip_id"]:checked');
if (checked) { window.gspUpdateServiceId(checked); }
})();
</script>
<input type="hidden" name="invoice_duration" value="month" />
</td>
</tr>
<tr>
<td align="left" colspan="2">
<?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): ?>
<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
// Close database connection
billing_maybe_close_db($db);
billing_maybe_close_db($db);
?>
</body>
<?php include(__DIR__ . '/includes/footer.php'); ?>

View file

@ -7,10 +7,6 @@
</head>
<body>
<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Include database configuration
require_once(__DIR__ . '/bootstrap.php');
@ -37,23 +33,62 @@ if (isset($_POST['save']) && !empty($_POST['description'])) {
$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;
$where_service_id = $service_id !== 0
? "WHERE enabled = 1 AND service_id = $service_id AND remote_server_id != '' AND remote_server_id IS NOT NULL"
: "WHERE enabled = 1 AND remote_server_id != '' AND remote_server_id IS NOT NULL";
$qry_services = "SELECT * FROM {$table_prefix}billing_services $where_service_id ORDER BY service_name";
if ($service_id !== 0) {
$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";
} else {
$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);
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) {
echo "<meta http-equiv='refresh' content='1'>";
billing_maybe_close_db($db);
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 = [];
$seenCanonical = [];
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;
}
$result_services->free();
@ -69,13 +104,18 @@ include(__DIR__ . '/includes/menu.php');
<?php if (!isset($_REQUEST['service_id'])): ?>
<!-- Service listing (all) -->
<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
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>
@ -84,44 +124,48 @@ include(__DIR__ . '/includes/menu.php');
<?php else: ?>
<!-- Single service detail view -->
<div class="float-left decorative-bottom">
<?php $imgSrc = billing_image_url((string)($row['img_url'] ?? '')); ?>
<?php if ($imgSrc !== ''): ?>
<img src="<?php echo htmlspecialchars($imgSrc, ENT_QUOTES, 'UTF-8'); ?>" width="230" height="112"><br>
<?php endif; ?>
<center><b><?php echo htmlspecialchars((string)$row['service_name'], ENT_QUOTES, 'UTF-8'); ?></b></center>
<?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;"><br>
<center><b><?php echo htmlspecialchars((string)($row['cfg_game_name'] ?? $row['service_name']), ENT_QUOTES, 'UTF-8'); ?></b></center>
<?php
$isAdmin = false; // change to actual check, e.g. current_user_can('administrator')
$isAdmin = false;
if ($isAdmin) {
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'>
<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'>
</form>";
} 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'>
<textarea style='resize:none;width:230px;height:132px;' name='description'>$desc</textarea><br>
<input type='hidden' name='service_id' value='{$row['service_id']}'>
<textarea style='resize:none;width:230px;height:132px;' name='description'>{$desc}</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;'>{$row['description']}</p>";
echo "<p style='color:gray;width:280px;'>" . htmlspecialchars((string)($row['description'] ?? ''), ENT_QUOTES, 'UTF-8') . "</p>";
}
?>
</div>
<!-- Order Form -->
<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="ftp_password" value="">
<table class="float-left">
<tr>
<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>
<!-- Add other form fields as needed -->
<tr>

View file

@ -233,61 +233,61 @@ function config_games_print_editor_css()
$printed = true;
echo <<<CSS
<style>
.xml-editor-wrapper{margin:20px 0;padding:12px;background:#111;border:1px solid #222;border-radius:8px}
.xml-node{border:1px solid #333;border-radius:6px;padding:12px;margin-bottom:10px;background:#181818}
.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:14px;margin-bottom:12px;background:#181818}
.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__title{font-weight:600;color:#f5f5f5}
.xml-node__title--required::after{content:" *";color:#e06c75;font-size:0.8rem}
.xml-node__path{font-size:0.85rem;color:#989898}
.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__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;font-size:1rem}
.xml-node__title--required::after{content:" *";color:#e06c75;font-size:0.85rem}
.xml-node__path{font-size:0.88rem;color:#b0b0b0}
.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--optional{background:#2a2a2a;color:#888}
.xml-node__body label{font-size:0.85rem;color:#bbb;display:block;margin-bottom:4px}
.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 textarea{min-height:120px}
.xml-node__badge--optional{background:#2a2a2a;color:#aaa}
.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 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:130px;line-height:1.4}
.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 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-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-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--top{float:right;margin:0 18px 12px 0}
.xml-hint{font-size:0.85rem;color:#999;margin-top:4px}
.xml-validation-errors{background:#2d0f0f;border:1px solid #8b1c1c;border-radius:6px;padding:12px 16px;margin-bottom:14px;color:#f88}
.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:#ffaaaa;font-size:0.93rem}
.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 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-warning{background:#2d2200;border:1px solid #7a5a00;border-radius:4px;padding:8px 12px;color:#f0c050;font-size:0.85rem;margin-bottom:6px}
.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-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__options{margin:4px 0 4px 12px;padding:0;list-style:disc inside}
.xml-node__options li{margin-bottom:2px}
.xml-node__options code{color:#7eb3f0;background:rgba(30,100,200,0.12);padding:1px 4px;border-radius:3px}
.xml-node__example{display:block;margin-top:4px;color:#888}
.xml-node__example code{color:#a0d0a0;background:rgba(30,150,50,0.1);padding:1px 4px;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-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:10px 14px;color:#f0c050;font-size:0.9rem;margin-bottom:8px}
.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.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:6px 0 4px 14px;padding:0;list-style:disc inside}
.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 5px;border-radius:3px}
.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 5px;border-radius:3px}
.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-section-grid{display:flex;flex-direction:column;gap:14px;margin-bottom:18px}
.xml-section-block{border:1px solid #303030;border-radius:6px;background:#141414;padding:12px}
.xml-section-block__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px}
.xml-section-block__title{font-size:1.02rem;color:#f0f0f0;font-weight:600}
.xml-section-block__meta{font-size:0.8rem;color:#9f9f9f}
.xml-section-block__desc{font-size:0.86rem;color:#b0b0b0;margin:0 0 10px}
.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-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.xml-btn{border:1px solid #3f3f3f;background:#222;color:#fff;padding:6px 10px;border-radius:4px;cursor:pointer}
.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:14px}
.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.05rem;color:#f0f0f0;font-weight:600}
.xml-section-block__meta{font-size:0.83rem;color:#b0b0b0}
.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: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:12px}
.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--primary{background:#1c6dd0;border-color:#114b99}
.xml-btn--primary{background:#1c6dd0;border-color:#114b99;color:#fff}
.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-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}
</style>
CSS;
@ -730,6 +730,23 @@ function config_games_render_top_level_editor($home_cfg_id, $configFile)
echo "</div>";
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>";
}
@ -1134,22 +1151,12 @@ function exec_ogp_module() {
echo "<div id='xml-editor-section'>";
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
echo "<hr style='margin:24px 0;border-color:#333'>";
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 "<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 "<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."'>";