Добавить установщик XLX системы и веб-панель
Этот коммит содержится в:
@@ -0,0 +1,90 @@
|
||||
const tokenInput = document.querySelector('#adminToken');
|
||||
const form = document.querySelector('#settingsForm');
|
||||
const envPreview = document.querySelector('#envPreview');
|
||||
const adminResult = document.querySelector('#adminResult');
|
||||
const loadButton = document.querySelector('#loadSettings');
|
||||
const saveButton = document.querySelector('#saveSettings');
|
||||
const applyButton = document.querySelector('#applySettings');
|
||||
|
||||
function token() {
|
||||
return tokenInput.value.trim();
|
||||
}
|
||||
|
||||
function setResult(text) {
|
||||
adminResult.textContent = text;
|
||||
}
|
||||
|
||||
function formData() {
|
||||
return Object.fromEntries(new FormData(form).entries());
|
||||
}
|
||||
|
||||
function fillForm(settings) {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const input = form.elements.namedItem(key);
|
||||
if (input) {
|
||||
input.value = value ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
setResult('Загружаем...');
|
||||
const response = await fetch('/api/admin/xlxd/settings', {
|
||||
headers: { 'X-Admin-Token': token() },
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
setResult(payload.error || 'Ошибка загрузки');
|
||||
return;
|
||||
}
|
||||
|
||||
fillForm(payload.data.settings);
|
||||
envPreview.textContent = payload.data.env;
|
||||
setResult('Настройки загружены.');
|
||||
}
|
||||
|
||||
async function saveSettings(apply) {
|
||||
setResult(apply ? 'Сохраняем и применяем...' : 'Сохраняем...');
|
||||
const response = await fetch('/api/admin/xlxd/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Token': token(),
|
||||
},
|
||||
body: JSON.stringify({ settings: formData(), apply }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
setResult(payload.error || 'Ошибка сохранения');
|
||||
return;
|
||||
}
|
||||
|
||||
envPreview.textContent = payload.data.env;
|
||||
setResult(payload.data.message);
|
||||
}
|
||||
|
||||
loadButton?.addEventListener('click', loadSettings);
|
||||
saveButton?.addEventListener('click', () => saveSettings(false));
|
||||
applyButton?.addEventListener('click', () => saveSettings(true));
|
||||
|
||||
form?.addEventListener('input', () => {
|
||||
const data = formData();
|
||||
const lines = {
|
||||
XLX_REFLECTOR_NAME: data.reflector_name,
|
||||
XLX_SERVER_HOST: data.server_host,
|
||||
XLX_SYSOP_CALLSIGN: data.sysop_callsign,
|
||||
XLX_SYSOP_EMAIL: data.sysop_email,
|
||||
XLX_COUNTRY: data.country,
|
||||
XLX_DASHBOARD_PORT: data.dashboard_port,
|
||||
XLX_DMR_PORT: data.dmr_port,
|
||||
XLX_YSF_PORT: data.ysf_port,
|
||||
XLX_DEFAULT_MODULE: data.default_module,
|
||||
XLX_MODULES: data.modules,
|
||||
XLX_INSTALL_PATH: data.install_path,
|
||||
XLX_SOURCE_PATH: data.source_path,
|
||||
XLX_LOG_PATH: data.log_path,
|
||||
XLX_SERVICE_NAME: data.service_name,
|
||||
XLX_REPO_URL: data.repo_url,
|
||||
};
|
||||
envPreview.textContent = Object.entries(lines).map(([key, value]) => `${key}=${value || ''}`).join('\n');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
const form = document.querySelector('#registerForm');
|
||||
const result = document.querySelector('#registerResult');
|
||||
|
||||
form?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
result.textContent = 'Отправляем заявку...';
|
||||
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
const response = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
if (!payload.ok) {
|
||||
result.textContent = payload.error || 'Ошибка регистрации';
|
||||
return;
|
||||
}
|
||||
|
||||
const payment = payload.data.payment || {};
|
||||
const lines = [
|
||||
`Заявка создана: ${payload.data.callsign}`,
|
||||
`Статус: ${payload.data.status}`,
|
||||
];
|
||||
if (payment.confirmation_url) {
|
||||
lines.push(`YooKassa: ${payment.confirmation_url}`);
|
||||
}
|
||||
if (payment.manual_transfer?.instructions) {
|
||||
lines.push(`Перевод по чеку: ${payment.manual_transfer.instructions}`);
|
||||
lines.push(`ID платежа для чека: ${payment.id}`);
|
||||
}
|
||||
|
||||
result.textContent = lines.join('\n');
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #07080d;
|
||||
--panel: #111522;
|
||||
--panel-2: #171d2d;
|
||||
--text: #f6f7fb;
|
||||
--muted: #aeb7c9;
|
||||
--line: #2a3347;
|
||||
--accent: #5ee0b8;
|
||||
--accent-2: #88a7ff;
|
||||
--danger: #ff7d7d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, #17253c 0, transparent 34rem), var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 36px 0 56px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr);
|
||||
gap: 28px;
|
||||
align-items: stretch;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.hero.compact {
|
||||
min-height: 260px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 18px;
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
line-height: .98;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 900px;
|
||||
font-size: clamp(44px, 7vw, 86px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 720px;
|
||||
margin: 22px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 14px 20px;
|
||||
background: var(--accent);
|
||||
color: #04120f;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button.ghost {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status-panel,
|
||||
.tile,
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
display: grid;
|
||||
align-content: end;
|
||||
gap: 14px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.status-panel div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-panel div:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.status-panel span,
|
||||
.tile p,
|
||||
label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-panel strong {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.num {
|
||||
display: block;
|
||||
margin-bottom: 34px;
|
||||
color: var(--accent-2);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tile p {
|
||||
margin: 16px 0 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.panel.split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(340px, .8fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form,
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.result {
|
||||
min-height: 72px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #06070b;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.env-preview {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.hero,
|
||||
.grid,
|
||||
.panel.split,
|
||||
.settings-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 44px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Xlx\Domain\AccessCredentialService;
|
||||
use Xlx\Domain\AccessExportService;
|
||||
use Xlx\Domain\IdAllocator;
|
||||
use Xlx\Domain\PaymentService;
|
||||
use Xlx\Domain\RegistrationService;
|
||||
use Xlx\Domain\XlxdSettingsService;
|
||||
use Xlx\Domain\YooKassaClient;
|
||||
use Xlx\Support\Database;
|
||||
use Xlx\Support\Input;
|
||||
use Xlx\Support\Response;
|
||||
use Xlx\Support\Security;
|
||||
|
||||
$config = require __DIR__ . '/../bootstrap.php';
|
||||
$pdo = (new Database($config))->pdo();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
|
||||
try {
|
||||
if ($method === 'GET' && ($path === '/' || $path === '/dashboard')) {
|
||||
require __DIR__ . '/views/home.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/admin') {
|
||||
require __DIR__ . '/views/admin.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/api/health') {
|
||||
Response::json(['ok' => true, 'service' => 'xlx-panel']);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/api/callsigns/check') {
|
||||
$paymentService = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
|
||||
$service = new RegistrationService($config, $paymentService);
|
||||
Response::json(['ok' => true, 'data' => $service->checkCallsign($pdo, (string) ($_GET['callsign'] ?? ''))]);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/api/register') {
|
||||
$paymentService = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
|
||||
$service = new RegistrationService($config, $paymentService);
|
||||
Response::json(['ok' => true, 'data' => $service->register($pdo, Input::json())], 201);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/api/payments/receipt') {
|
||||
$input = Input::json();
|
||||
$service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
|
||||
Response::json(['ok' => true, 'data' => $service->submitReceipt(
|
||||
$pdo,
|
||||
(int) ($input['payment_id'] ?? 0),
|
||||
(int) ($input['user_id'] ?? 0),
|
||||
(string) ($input['receipt_reference'] ?? ''),
|
||||
isset($input['comment']) ? (string) $input['comment'] : null,
|
||||
)], 201);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/api/admin/payments/confirm') {
|
||||
Security::requireAdminToken($config);
|
||||
$input = Input::json();
|
||||
$service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
|
||||
Response::json(['ok' => true, 'data' => $service->confirm($pdo, (int) ($input['payment_id'] ?? 0), $input['admin_user_id'] ?? null)]);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/api/admin/receipts/approve') {
|
||||
Security::requireAdminToken($config);
|
||||
$input = Input::json();
|
||||
$service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
|
||||
Response::json(['ok' => true, 'data' => $service->approveReceipt($pdo, (int) ($input['receipt_id'] ?? 0), $input['admin_user_id'] ?? null)]);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/api/webhooks/yookassa') {
|
||||
$token = $_SERVER['HTTP_X_WEBHOOK_TOKEN'] ?? ($_GET['token'] ?? '');
|
||||
if (!hash_equals((string) $config->get('billing.yookassa.webhook_secret'), (string) $token)) {
|
||||
Response::error('Webhook token is missing or invalid.', 401);
|
||||
}
|
||||
|
||||
$input = Input::json();
|
||||
if (($input['event'] ?? '') !== 'payment.succeeded') {
|
||||
Response::json(['ok' => true, 'ignored' => true]);
|
||||
}
|
||||
|
||||
$providerPaymentId = (string) ($input['object']['id'] ?? '');
|
||||
$service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
|
||||
Response::json(['ok' => true, 'data' => $service->confirmProviderPayment($pdo, $providerPaymentId)]);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/api/admin/access/export') {
|
||||
Security::requireAdminToken($config);
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="xlx-active-users.csv"');
|
||||
echo (new AccessExportService())->activeUsersCsv($pdo);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $path === '/api/admin/xlxd/settings') {
|
||||
Security::requireAdminToken($config);
|
||||
$service = new XlxdSettingsService($config);
|
||||
$settings = $service->current($pdo);
|
||||
Response::json([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'settings' => $settings,
|
||||
'env' => $service->renderEnv($settings),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $path === '/api/admin/xlxd/settings') {
|
||||
Security::requireAdminToken($config);
|
||||
$input = Input::json();
|
||||
$service = new XlxdSettingsService($config);
|
||||
Response::json(['ok' => true, 'data' => $service->save($pdo, $input['settings'] ?? [], (bool) ($input['apply'] ?? false))]);
|
||||
}
|
||||
|
||||
Response::error('Route not found.', 404);
|
||||
} catch (Throwable $throwable) {
|
||||
Response::error($throwable->getMessage(), 400);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>XLX Admin</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell admin-shell">
|
||||
<section class="hero compact">
|
||||
<div>
|
||||
<p class="eyebrow">Админка</p>
|
||||
<h1>Настройка XLX без ручного редактирования конфигов</h1>
|
||||
<p class="lead">Меняй имя reflector, домен, порты, модули и пути. Настройки сохраняются в БД и могут сразу применяться в `/etc/xlx/xlxd.env`.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<label>Admin token
|
||||
<input id="adminToken" type="password" placeholder="X-Admin-Token">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button class="button" id="loadSettings">Загрузить настройки</button>
|
||||
<button class="button ghost" id="saveSettings">Сохранить</button>
|
||||
<button class="button ghost" id="applySettings">Сохранить и применить</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel split">
|
||||
<form class="form settings-form" id="settingsForm">
|
||||
<label>Reflector
|
||||
<input name="reflector_name" placeholder="XLX138">
|
||||
</label>
|
||||
<label>Домен
|
||||
<input name="server_host" placeholder="xlx.example.com">
|
||||
</label>
|
||||
<label>Позывной sysop
|
||||
<input name="sysop_callsign" placeholder="R0XXX">
|
||||
</label>
|
||||
<label>Email sysop
|
||||
<input name="sysop_email" placeholder="admin@example.com">
|
||||
</label>
|
||||
<label>Страна
|
||||
<input name="country" placeholder="RU">
|
||||
</label>
|
||||
<label>DMR порт
|
||||
<input name="dmr_port" type="number" min="1" max="65535">
|
||||
</label>
|
||||
<label>YSF порт
|
||||
<input name="ysf_port" type="number" min="1" max="65535">
|
||||
</label>
|
||||
<label>Dashboard порт
|
||||
<input name="dashboard_port" type="number" min="1" max="65535">
|
||||
</label>
|
||||
<label>Модуль по умолчанию
|
||||
<input name="default_module" maxlength="1">
|
||||
</label>
|
||||
<label>Активные модули
|
||||
<input name="modules" placeholder="ABCDEFGHIJKLMNOPQRSTUVWXYZ">
|
||||
</label>
|
||||
<label>Путь установки
|
||||
<input name="install_path" placeholder="/xlxd">
|
||||
</label>
|
||||
<label>Путь исходников
|
||||
<input name="source_path" placeholder="/usr/src/xlxd">
|
||||
</label>
|
||||
<label>Лог
|
||||
<input name="log_path" placeholder="/var/log/xlxd.log">
|
||||
</label>
|
||||
<label>Systemd service
|
||||
<input name="service_name" placeholder="xlxd">
|
||||
</label>
|
||||
<label>Репозиторий xlxd
|
||||
<input name="repo_url" placeholder="https://github.com/LX3JL/xlxd.git">
|
||||
</label>
|
||||
</form>
|
||||
<div>
|
||||
<p class="eyebrow">Предпросмотр env</p>
|
||||
<pre class="result env-preview" id="envPreview"></pre>
|
||||
<pre class="result" id="adminResult"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>XLX Server</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">XLX / DMR / Pi-Star</p>
|
||||
<h1>Личный доступ к DMR-серверу без ручной настройки</h1>
|
||||
<p class="lead">Регистрация по позывному без дублей, оплата через YooKassa или чек перевода, автоматический DMR ID и пароль для Pi-Star.</p>
|
||||
<div class="actions">
|
||||
<a class="button" href="#register">Подключиться</a>
|
||||
<a class="button ghost" href="/admin">Админка</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-panel">
|
||||
<div>
|
||||
<span>DMR порт</span>
|
||||
<strong>62030</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Модуль</span>
|
||||
<strong>A</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Оплата</span>
|
||||
<strong>YooKassa</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="tile">
|
||||
<span class="num">01</span>
|
||||
<h2>Проверка позывного</h2>
|
||||
<p>Система проверяет формат и занятость позывного во внутренней базе.</p>
|
||||
</article>
|
||||
<article class="tile">
|
||||
<span class="num">02</span>
|
||||
<h2>Оплата доступа</h2>
|
||||
<p>Платеж через YooKassa или перевод с отправкой чека на подтверждение.</p>
|
||||
</article>
|
||||
<article class="tile">
|
||||
<span class="num">03</span>
|
||||
<h2>Данные Pi-Star</h2>
|
||||
<p>После оплаты выдаются сервер, порт, логин, пароль, DMR ID и модуль.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="register">
|
||||
<div>
|
||||
<p class="eyebrow">Регистрация</p>
|
||||
<h2>Создать заявку</h2>
|
||||
<p>После отправки появится ссылка YooKassa и инструкция для оплаты переводом.</p>
|
||||
</div>
|
||||
<form class="form" id="registerForm">
|
||||
<label>Позывной
|
||||
<input name="callsign" placeholder="R0XXX" required>
|
||||
</label>
|
||||
<label>Email
|
||||
<input name="email" type="email" placeholder="user@example.com" required>
|
||||
</label>
|
||||
<label>Пароль кабинета
|
||||
<input name="password" type="password" minlength="10" required>
|
||||
</label>
|
||||
<button class="button" type="submit">Отправить</button>
|
||||
<pre class="result" id="registerResult"></pre>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Ссылка в новой задаче
Block a user