Added ability to manage custom registrar pricing from panel; fixed #163

This commit is contained in:
Pinga 2025-04-01 11:59:10 +03:00
parent acc7d74b32
commit de89a9e3b4
5 changed files with 314 additions and 97 deletions

View file

@ -1245,19 +1245,11 @@ class RegistrarsController extends Controller
}
}
public function updateCustomPricing(Request $request, Response $response, $args)
public function customPricingView(Request $request, Response $response, $args)
{
if ($_SESSION["auth_roles"] != 0) {
return $response->withHeader('Location', '/dashboard')->withStatus(302);
}
//TODO
if ($request->getMethod() === 'POST') {
// Retrieve POST data
$data = $request->getParsedBody();
$db = $this->container->get('db');
var_dump ($data);die();
}
$db = $this->container->get('db');
// Get the current URI
@ -1300,6 +1292,108 @@ class RegistrarsController extends Controller
}
}
public function updateCustomPricing(Request $request, Response $response, $args)
{
if ($_SESSION["auth_roles"] != 0) {
return $response->withHeader('Location', '/dashboard')->withStatus(302);
}
if (!$args) {
return $response->withHeader('Location', '/registrars')->withStatus(302);
}
$method = $request->getMethod();
$body = $request->getBody()->__toString();
$data = json_decode($body, true);
$db = $this->container->get('db');
$clid = getClid($db, $args);
$tld = $data['tld'] ?? null;
$action = $data['action'] ?? null;
if (!$tld || !$action || !in_array($action, ['create', 'renew', 'transfer', 'restore'])) {
$response->getBody()->write(json_encode(['error' => 'Invalid input']));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
$tldId = $db->selectValue('SELECT id FROM domain_tld WHERE tld = ?', [$tld]);
if (!$tldId) {
$response->getBody()->write(json_encode(['error' => 'TLD not found']));
return $response->withHeader('Content-Type', 'application/json')->withStatus(404);
}
if ($method === 'POST') {
$prices = $data['prices'] ?? [];
if ($action === 'restore') {
$price = $prices['restore'] ?? null;
if ($price === null) {
$response->getBody()->write(json_encode(['error' => 'Missing restore price']));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
$db->exec('
INSERT INTO domain_restore_price (tldid, registrar_id, price)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE price = VALUES(price)
', [$tldId, $clid, $price]);
} else {
$columns = [];
foreach ($prices as $key => $val) {
if (preg_match('/^y(\d{1,2})$/', $key, $matches)) {
$year = (int)$matches[1];
$months = $year * 12;
$col = 'm' . $months;
$columns[$col] = $val;
}
}
if (!empty($columns)) {
$columns['tldid'] = $tldId;
$columns['registrar_id'] = $clid;
$columns['command'] = $action;
$colNames = array_keys($columns);
$placeholders = array_fill(0, count($columns), '?');
$values = array_values($columns);
$updateClause = implode(', ', array_map(function ($col) {
return "$col = VALUES($col)";
}, $colNames));
$sql = 'INSERT INTO domain_price (' . implode(', ', $colNames) . ')
VALUES (' . implode(', ', $placeholders) . ')
ON DUPLICATE KEY UPDATE ' . $updateClause;
$db->exec($sql, $values);
}
}
$response->getBody()->write(json_encode(['success' => true]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
}
elseif ($method === 'DELETE') {
if ($action === 'restore') {
$db->delete('domain_restore_price', [
'tldid' => $tldId,
'registrar_id' => $clid
]);
} else {
$db->exec('DELETE FROM domain_price WHERE tldid = ? AND registrar_id = ? AND command = ?', [
$tldId, $clid, $action
]);
}
$response->getBody()->write(json_encode(['success' => true]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
}
$response->getBody()->write(json_encode(['error' => 'Method not allowed']));
return $response->withHeader('Content-Type', 'application/json')->withStatus(405);
}
public function transferRegistrar(Request $request, Response $response)
{
if ($_SESSION["auth_roles"] != 0) {

View file

@ -304,6 +304,9 @@ $csrfMiddleware = function ($request, $handler) use ($container) {
if ($path && $path === '/clear-cache') {
return $handler->handle($request);
}
if (str_starts_with($path, '/registrar/updatepricing/')) {
return $handler->handle($request);
}
if ($path && $path === '/token-well') {
$csrf->generateToken();
return $handler->handle($request);

View file

@ -1155,3 +1155,8 @@ function sign($ts, $method, $path, $body, $secret_key) {
$stringToSign = $ts . strtoupper($method) . $path . $body;
return hash_hmac('sha256', $stringToSign, $secret_key);
}
function getClid($db, string $clid): ?int {
$result = $db->selectValue('SELECT id FROM registrar WHERE clid = ? LIMIT 1', [$clid]);
return $result !== false ? (int)$result : null;
}

View file

@ -25,27 +25,16 @@
<div class="container-xl">
<div class="col-12">
{% include 'partials/flash.twig' %}
<form action="/registrar/pricing/{{ clid }}" method="post">
{{ csrf.field | raw }}
<div class="card mb-3">
<div class="card-header">
<h3 class="card-title">{{ __('Registrar') }} {{ name }}</h3>
<div class="card-actions">
<!--<button type="submit" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1" /><path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z" /><path d="M16 5l3 3" /></svg>
{{ __('Update Prices') }}
</button>-->
</div>
</div>
<div class="card-body">
<div class="alert alert-info" role="alert">
{{ __('Custom registrar pricing can currently be viewed in this panel but must be managed directly via the database.') }}
</div>
<div class="table-responsive">
<table class="table table-vcenter table-bordered">
<table class="table table-vcenter table-bordered" id="pricing-table">
<thead>
<tr>
<th>{{ __('TLD') }} / {{ __('Command') }}</th>
<th>{{ __('TLD') }}</th>
<th>{{ __('Create') }}</th>
<th>{{ __('Renew') }}</th>
<th>{{ __('Transfer') }}</th>
@ -54,67 +43,67 @@
</thead>
<tbody>
{% for tld in tlds %}
<tr>
<tr data-tld="{{ tld.tld }}">
<td>{{ tld.tld }}</td>
{% if tld.createPrices or tld.renewPrices or tld.transferPrices or tld.tld_restore %}
<td>
{% if tld.createPrices %}
{% for year in [12, 24, 36, 48, 60, 72, 84, 96, 108, 120] %}
<div class="row mb-2">
<div class="col-auto">
<label class="form-label">{{ year/12 }} Year{{ year > 12 ? 's' : '' }}</label>
</div>
<div class="col">
<input type="text" class="form-control" name="create_{{ tld.tld }}_{{ year }}" value="{{ attribute(tld.createPrices, 'm' ~ year) | default('N/A') }}">
</div>
</div>
{% for action in ['create', 'renew', 'transfer'] %}
<td data-action="{{ action }}"
data-prices='{
{% set prices = attribute(tld, action ~ "Prices") %}
{% for year in 1..10 %}
{% set key = "m" ~ (year * 12) %}
{% if attribute(prices, key) is defined %}
"y{{ year }}": "{{ attribute(prices, key) }}"{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
}'>
{% set prices = attribute(tld, action ~ 'Prices') %}
{% if prices %}
<div>
<div class="fw-bold">
{% for year in 1..10 %}
{% set key = 'm' ~ (year * 12) %}
{% if attribute(prices, key) is defined %}
{{ year }}y: ${{ attribute(prices, key) }}{% if not loop.last %}, {% endif %}
{% endif %}
{% endfor %}
</div>
<div class="d-flex gap-2 justify-content-center mt-2">
<button class="edit-btn btn btn-sm btn-warning">{{ __('Edit') }}</button>
<button class="delete-btn btn btn-sm btn-danger"
data-action="{{ action }}"
data-tld="{{ tld.tld }}">
{{ __('Delete') }}
</button>
</div>
</div>
{% else %}
N/A
<span class="text-muted">{{ __('No custom price') }}</span><br>
<button class="edit-btn btn btn-sm btn-primary">{{ __('Set') }}</button>
{% endif %}
</td>
<td>
{% if tld.renewPrices %}
{% for year in [12, 24, 36, 48, 60, 72, 84, 96, 108, 120] %}
<div class="row mb-2">
<div class="col-auto">
<label class="form-label">{{ year/12 }} Year{{ year > 12 ? 's' : '' }}</label>
</div>
<div class="col">
<input type="text" class="form-control" name="renew_{{ tld.tld }}_{{ year }}" value="{{ attribute(tld.renewPrices, 'm' ~ year) | default('N/A') }}">
</div>
</div>
{% endfor %}
{% else %}
N/A
{% endif %}
</td>
<td>
{% if tld.transferPrices %}
{% for year in [12, 24, 36, 48, 60, 72, 84, 96, 108, 120] %}
<div class="row mb-2">
<div class="col-auto">
<label class="form-label">{{ year/12 }} Year{{ year > 12 ? 's' : '' }}</label>
</div>
<div class="col">
<input type="text" class="form-control" name="transfer_{{ tld.tld }}_{{ year }}" value="{{ attribute(tld.transferPrices, 'm' ~ year) | default('N/A') }}">
</div>
</div>
{% endfor %}
{% else %}
N/A
{% endif %}
</td>
<td>
<td data-action="restore"
{% if tld.tld_restore %}
<input type="text" class="form-control" name="restore_{{ tld.tld }}" value="{{ tld.tld_restore.price | default('N/A') }}">
data-price="{{ tld.tld_restore.price }}"
{% endif %}>
{% if tld.tld_restore %}
<div class="fw-bold">${{ tld.tld_restore.price }}</div>
<div class="d-flex gap-2 justify-content-center mt-2">
<button class="edit-btn btn btn-sm btn-warning">{{ __('Edit') }}</button>
<button class="delete-btn btn btn-sm btn-danger"
data-action="restore"
data-tld="{{ tld.tld }}">
{{ __('Delete') }}
</button>
</div>
{% else %}
N/A
<span class="text-muted">{{ __('No custom price') }}</span><br>
<button class="edit-btn btn btn-sm btn-primary">{{ __('Set') }}</button>
{% endif %}
</td>
{% else %}
<td colspan="4">{{ __('Registrar does not have custom prices for') }} {{ tld.tld }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
@ -122,10 +111,135 @@
</div>
</div>
</div>
</form>
</div>
</div>
</div>
{% include 'partials/footer.twig' %}
</div>
<script>
document.getElementById("pricing-table").addEventListener("click", async function (e) {
const btn = e.target;
// DELETE BUTTON HANDLER
if (btn.classList.contains("delete-btn")) {
const confirmed = confirm("Are you sure you want to delete custom prices for this TLD?");
if (!confirmed) return;
const tld = btn.dataset.tld;
const action = btn.dataset.action;
const res = await fetch(`/registrar/updatepricing/{{ clid }}`, {
method: "DELETE",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tld, action })
});
if (res.ok) {
location.reload();
} else {
alert("Error deleting price");
}
return;
}
// EDIT BUTTON HANDLER
if (!btn.classList.contains("edit-btn")) return;
const td = btn.closest("td");
const tr = td.closest("tr");
const tld = tr.dataset.tld;
const action = td.dataset.action;
td.setAttribute("data-original", td.innerHTML);
let inputHTML = "";
if (action === "restore") {
inputHTML = `<label class="form-label">Restore Price
<input class="form-control form-control-sm" type="number" step="0.01" name="restore" placeholder="$">
</label>`;
} else {
inputHTML = Array.from({ length: 10 }, (_, i) => {
const year = i + 1;
return `<label class="form-label">${year}y
<input class="form-control form-control-sm" type="number" step="0.01" name="y${year}" placeholder="$" data-year="${year}">
</label>`;
}).join('');
}
const form = document.createElement("form");
form.classList.add("price-form");
form.innerHTML = `
<div class="year-inputs" style="display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 6px;">
${inputHTML}
</div>
<button class="save-btn btn btn-sm btn-success" type="submit">Save</button>
<button class="cancel-btn btn btn-sm btn-secondary" type="button">Cancel</button>
`;
td.innerHTML = "";
td.appendChild(form);
const priceData = td.dataset.prices ? JSON.parse(td.dataset.prices) : {};
const restorePrice = td.dataset.price;
// Prefill values if present
if (action !== "restore") {
for (let y = 1; y <= 10; y++) {
const input = form.querySelector(`input[name="y${y}"]`);
if (input && priceData[`y${y}`]) {
input.value = priceData[`y${y}`];
}
}
} else {
const input = form.querySelector(`input[name="restore"]`);
if (input && restorePrice) {
input.value = restorePrice;
}
}
// Cancel button
form.querySelector(".cancel-btn").addEventListener("click", () => {
td.innerHTML = td.getAttribute("data-original");
});
// Auto-fill other years when typing in y1
if (action !== "restore") {
form.querySelector('input[name="y1"]').addEventListener("input", (ev) => {
const base = parseFloat(ev.target.value);
if (isNaN(base)) return;
for (let y = 2; y <= 10; y++) {
const input = form.querySelector(`input[name="y${y}"]`);
if (input) input.value = (base * y).toFixed(2);
}
});
}
// Submit save
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const data = new FormData(form);
const payload = {};
for (const [key, value] of data.entries()) {
if (value.trim() !== "") {
if (action === "restore") {
payload.restore = parseFloat(value);
} else {
payload[key] = parseFloat(value);
}
}
}
const res = await fetch(`/registrar/updatepricing/{{ clid }}`, {
method: "POST",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tld, action, prices: payload })
});
if (res.ok) {
location.reload();
} else {
alert("Error saving price");
}
});
});
</script>
{% endblock %}

View file

@ -94,7 +94,8 @@ $app->group('', function ($route) {
$route->map(['GET', 'POST'], '/registrar/create', RegistrarsController::class . ':create')->setName('registrarcreate');
$route->get('/registrar/view/{registrar}', RegistrarsController::class . ':viewRegistrar')->setName('viewRegistrar');
$route->get('/registrar/update/{registrar}', RegistrarsController::class . ':updateRegistrar')->setName('updateRegistrar');
$route->map(['GET', 'POST'], '/registrar/pricing/{registrar}', RegistrarsController::class . ':updateCustomPricing')->setName('updateCustomPricing');
$route->get('/registrar/pricing/{registrar}', RegistrarsController::class . ':customPricingView')->setName('customPricingView');
$route->map(['POST', 'DELETE'], '/registrar/updatepricing/{registrar}', RegistrarsController::class . ':updateCustomPricing')->setName('updateCustomPricing');
$route->post('/registrar/update', RegistrarsController::class . ':updateRegistrarProcess')->setName('updateRegistrarProcess');
$route->get('/registrar', RegistrarsController::class .':registrar')->setName('registrar');
$route->map(['GET', 'POST'], '/registrar/edit', RegistrarsController::class .':editRegistrar')->setName('editRegistrar');