Files
SHiNE-server/shine-UI/promo-code-generator.html
T

556 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SHiNE Promo Code Generator</title>
<style>
:root {
color-scheme: dark;
--bg: #091019;
--card: rgba(18, 29, 42, 0.95);
--card-2: rgba(11, 18, 28, 0.98);
--line: rgba(116, 146, 189, 0.22);
--text: #edf4ff;
--muted: #95a8c4;
--accent: #61d3a7;
--accent-2: #58a8ff;
--danger: #ff7f97;
--shadow: 0 26px 90px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", "Inter", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(88, 168, 255, 0.22), transparent 28%),
radial-gradient(circle at bottom right, rgba(97, 211, 167, 0.16), transparent 36%),
linear-gradient(180deg, #060b12 0%, var(--bg) 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.panel {
width: min(840px, 100%);
background: linear-gradient(180deg, var(--card) 0%, var(--card-2) 100%);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow);
padding: 28px;
backdrop-filter: blur(14px);
}
h1 {
margin: 0 0 10px;
font-size: clamp(28px, 4vw, 40px);
line-height: 1.06;
letter-spacing: -0.03em;
}
.lead, .hint {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.layout {
display: grid;
gap: 16px;
grid-template-columns: 1fr 1fr;
margin-top: 24px;
}
.block {
background: rgba(8, 14, 21, 0.72);
border: 1px solid rgba(116, 146, 189, 0.16);
border-radius: 18px;
padding: 16px;
}
.full { grid-column: 1 / -1; }
.field-label, .block strong {
display: block;
margin-bottom: 8px;
color: #dce9ff;
font-size: 14px;
}
input, textarea {
width: 100%;
border: 1px solid rgba(116, 146, 189, 0.18);
background: rgba(5, 11, 17, 0.92);
color: var(--text);
border-radius: 12px;
padding: 13px 14px;
font: inherit;
}
textarea {
min-height: 124px;
resize: vertical;
}
.mode-switch {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.mode-switch label {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(116, 146, 189, 0.18);
border-radius: 999px;
padding: 10px 14px;
cursor: pointer;
color: var(--text);
background: rgba(8, 14, 21, 0.82);
}
.mode-switch input { width: auto; }
.actions, .seed-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
button {
border: 0;
border-radius: 999px;
padding: 13px 18px;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: transform 0.16s ease, opacity 0.16s ease;
}
button:hover { transform: translateY(-1px); }
button:disabled {
cursor: default;
opacity: 0.55;
transform: none;
}
.primary {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
color: #041219;
}
.secondary {
background: rgba(76, 108, 149, 0.18);
color: var(--text);
border: 1px solid rgba(116, 146, 189, 0.18);
}
.status {
margin-top: 16px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(116, 146, 189, 0.18);
background: rgba(6, 11, 17, 0.74);
color: var(--muted);
white-space: pre-wrap;
}
.status.error {
color: #ffd7df;
border-color: rgba(255, 127, 151, 0.36);
background: rgba(54, 12, 22, 0.52);
}
.status.success {
color: #defff3;
border-color: rgba(97, 211, 167, 0.36);
background: rgba(8, 38, 29, 0.52);
}
.muted-text {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
margin: 8px 0 0;
word-break: break-word;
}
.hidden { display: none !important; }
code {
padding: 2px 5px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
}
@media (max-width: 760px) {
.panel {
padding: 20px;
border-radius: 20px;
}
.layout {
grid-template-columns: 1fr;
}
.full { grid-column: auto; }
}
</style>
</head>
<body>
<main class="panel">
<h1>Генератор promo-кодов</h1>
<p class="lead">
Полностью автономный HTML-файл. Можно открыть локально без сервера и без внешних зависимостей.
Подписывается строка <code>shine_promo_v1:&lt;login&gt;</code>, а на выходе получается promo-код
формата <code>1seller-signatureBase58</code>.
</p>
<section class="layout">
<div class="block full">
<strong>Режим подписи</strong>
<div class="mode-switch">
<label><input type="radio" name="signMode" value="extension" checked> Wallet extension</label>
<label><input type="radio" name="signMode" value="private"> Приватный ключ Base58</label>
</div>
</div>
<div class="block">
<label class="field-label" for="sellerLogin">Логин продавца</label>
<input id="sellerLogin" type="text" autocomplete="off" placeholder="например promo_master">
</div>
<div class="block">
<label class="field-label" for="targetLogin">Новый логин</label>
<input id="targetLogin" type="text" autocomplete="off" placeholder="например SuperName">
</div>
<div class="block full" id="extensionBlock">
<strong>Подключённый кошелёк</strong>
<div class="muted-text" id="walletAddress">не подключен</div>
<div class="actions" style="margin-top: 14px;">
<button class="secondary" type="button" id="connectBtn">Подключить кошелёк</button>
</div>
</div>
<div class="block full hidden" id="privateKeyBlock">
<label class="field-label" for="privateSeed">Приватный ключ продавца</label>
<input id="privateSeed" type="text" autocomplete="off" placeholder="Base58 seed 32 bytes">
<div class="seed-actions" style="margin-top: 14px;">
<button class="secondary" type="button" id="generateSeedBtn">Сгенерировать новый Base58 seed</button>
<button class="secondary" type="button" id="copySeedBtn">Скопировать seed</button>
</div>
<p class="muted-text" id="privateSeedHint">
Формат: Base58 seed длиной 32 байта. Страница импортирует его в Ed25519 и локально подписывает сообщение через WebCrypto.
</p>
</div>
<div class="block full">
<strong>Подписываемое сообщение</strong>
<div class="muted-text" id="messagePreview"></div>
</div>
<div class="block full">
<div class="actions">
<button class="primary" type="button" id="generateBtn">Сгенерировать promo-код</button>
<button class="secondary" type="button" id="copyCodeBtn" disabled>Скопировать promo-код</button>
</div>
</div>
<div class="block full">
<label class="field-label" for="promoCode">Готовый promo-код</label>
<textarea id="promoCode" readonly placeholder="Здесь появится готовая строка promo-кода"></textarea>
</div>
</section>
<p class="status" id="status">Готово. Можно работать полностью локально.</p>
</main>
<script>
(() => {
const LOGIN_RE = /^[0-9A-Za-z_]{1,20}$/;
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
const PROMO_PREFIX = 'shine_promo_v1:';
const $ = (id) => document.getElementById(id);
const sellerLoginInput = $('sellerLogin');
const targetLoginInput = $('targetLogin');
const walletAddressNode = $('walletAddress');
const privateSeedInput = $('privateSeed');
const privateSeedHint = $('privateSeedHint');
const messagePreviewNode = $('messagePreview');
const promoCodeNode = $('promoCode');
const statusNode = $('status');
const generateBtn = $('generateBtn');
const copyCodeBtn = $('copyCodeBtn');
const connectBtn = $('connectBtn');
const generateSeedBtn = $('generateSeedBtn');
const copySeedBtn = $('copySeedBtn');
const extensionBlock = $('extensionBlock');
const privateKeyBlock = $('privateKeyBlock');
let currentMode = 'extension';
let provider = null;
let walletAddress = '';
function setStatus(text, kind = '') {
statusNode.className = `status ${kind}`.trim();
statusNode.textContent = String(text || '');
}
function normalizeLogin(value) {
return String(value || '').trim().toLowerCase();
}
function validateLogin(label, raw) {
const clean = normalizeLogin(raw);
if (!LOGIN_RE.test(clean)) {
throw new Error(`${label}: разрешены только 0..9, a..z, A..Z, _ и длина 1..20`);
}
return clean;
}
function bytesToBase58(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
if (input.length === 0) return '';
const digits = [0];
for (const byte of input) {
let carry = byte;
for (let i = 0; i < digits.length; i += 1) {
const value = (digits[i] * 256) + carry;
digits[i] = value % 58;
carry = Math.floor(value / 58);
}
while (carry > 0) {
digits.push(carry % 58);
carry = Math.floor(carry / 58);
}
}
let out = '';
for (const byte of input) {
if (byte === 0) out += '1';
else break;
}
for (let i = digits.length - 1; i >= 0; i -= 1) {
out += BASE58_ALPHABET[digits[i]];
}
return out;
}
function base58ToBytes(value) {
const text = String(value || '').trim();
if (!text) return new Uint8Array(0);
const bytes = [0];
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
const digit = BASE58_ALPHABET.indexOf(ch);
if (digit < 0) throw new Error('Недопустимый символ Base58');
let carry = digit;
for (let j = 0; j < bytes.length; j += 1) {
const x = (bytes[j] * 58) + carry;
bytes[j] = x & 0xff;
carry = x >> 8;
}
while (carry > 0) {
bytes.push(carry & 0xff);
carry >>= 8;
}
}
for (let i = 0; i < text.length && text[i] === '1'; i += 1) {
bytes.push(0);
}
bytes.reverse();
return Uint8Array.from(bytes);
}
function pkcs8FromSeed32(seed32) {
const out = new Uint8Array(ED25519_PKCS8_PREFIX.length + seed32.length);
out.set(ED25519_PKCS8_PREFIX, 0);
out.set(seed32, ED25519_PKCS8_PREFIX.length);
return out;
}
async function signWithSeed32(seed32, messageBytes) {
if (!globalThis.crypto?.subtle) {
throw new Error('В браузере нет WebCrypto subtle. Для локальной подписи нужен современный браузер.');
}
const privateKey = await crypto.subtle.importKey(
'pkcs8',
pkcs8FromSeed32(seed32),
{ name: 'Ed25519' },
false,
['sign'],
);
const signature = await crypto.subtle.sign('Ed25519', privateKey, messageBytes);
return new Uint8Array(signature);
}
function buildPromoMessageBytes(login) {
return new TextEncoder().encode(`${PROMO_PREFIX}${login}`);
}
function buildPromoCodeString(sellerLogin, signatureBytes) {
if (!(signatureBytes instanceof Uint8Array) || signatureBytes.length !== 64) {
throw new Error('Подпись промокода должна быть ровно 64 байта');
}
return `1${sellerLogin}-${bytesToBase58(signatureBytes)}`;
}
function detectWalletProvider() {
const injected = [];
if (globalThis.shineSolana) injected.push(globalThis.shineSolana);
if (globalThis.shineWallet?.solana) injected.push(globalThis.shineWallet.solana);
if (globalThis.solana?.providers && Array.isArray(globalThis.solana.providers)) {
injected.push(...globalThis.solana.providers);
}
if (globalThis.solana) injected.push(globalThis.solana);
if (globalThis.phantom?.solana) injected.push(globalThis.phantom.solana);
const seen = new Set();
return injected.find((candidate) => {
if (!candidate || seen.has(candidate)) return false;
seen.add(candidate);
return typeof candidate.connect === 'function' && typeof candidate.signMessage === 'function';
}) || null;
}
function updateMessagePreview() {
const login = normalizeLogin(targetLoginInput.value);
if (!login) {
messagePreviewNode.textContent = '—';
return;
}
messagePreviewNode.textContent = new TextDecoder().decode(buildPromoMessageBytes(login));
}
function updateModeUi() {
extensionBlock.classList.toggle('hidden', currentMode !== 'extension');
privateKeyBlock.classList.toggle('hidden', currentMode !== 'private');
}
async function connectWallet() {
provider = detectWalletProvider();
if (!provider) {
throw new Error('Не найдено wallet extension с connect/signMessage');
}
const result = await provider.connect();
const publicKey = result?.publicKey || provider.publicKey;
if (!publicKey) {
throw new Error('Кошелёк не вернул public key');
}
walletAddress = typeof publicKey.toBase58 === 'function' ? publicKey.toBase58() : String(publicKey || '');
walletAddressNode.textContent = walletAddress || 'подключен, но адрес не распознан';
}
async function getSignatureBytesForCurrentMode(messageBytes) {
if (currentMode === 'extension') {
if (!provider) {
throw new Error('Сначала подключите wallet extension');
}
const signed = await provider.signMessage(messageBytes, 'utf8');
const signature = signed?.signature || signed;
const bytes = signature instanceof Uint8Array ? signature : new Uint8Array(signature || []);
if (bytes.length !== 64) {
throw new Error('Wallet extension вернул подпись некорректной длины');
}
return bytes;
}
const seed32 = base58ToBytes(privateSeedInput.value);
if (seed32.length !== 32) {
throw new Error('Приватный ключ должен быть Base58 seed ровно 32 байта');
}
return signWithSeed32(seed32, messageBytes);
}
async function generatePromoCode() {
const sellerLogin = validateLogin('Логин продавца', sellerLoginInput.value);
const targetLogin = validateLogin('Новый логин', targetLoginInput.value);
const messageBytes = buildPromoMessageBytes(targetLogin);
const signatureBytes = await getSignatureBytesForCurrentMode(messageBytes);
const promoCode = buildPromoCodeString(sellerLogin, signatureBytes);
promoCodeNode.value = promoCode;
copyCodeBtn.disabled = false;
}
function randomSeed32Base58() {
if (!globalThis.crypto?.getRandomValues) {
throw new Error('В браузере нет crypto.getRandomValues');
}
const seed = new Uint8Array(32);
crypto.getRandomValues(seed);
return bytesToBase58(seed);
}
async function copyText(text, errorMessage) {
if (!navigator.clipboard?.writeText) {
throw new Error(errorMessage);
}
await navigator.clipboard.writeText(text);
}
document.querySelectorAll('input[name="signMode"]').forEach((node) => {
node.addEventListener('change', () => {
if (!node.checked) return;
currentMode = node.value;
updateModeUi();
setStatus(
currentMode === 'extension'
? 'Режим wallet extension активен.'
: 'Режим ручного Base58 seed активен. Страница может работать полностью оффлайн.',
);
});
});
connectBtn.addEventListener('click', async () => {
connectBtn.disabled = true;
try {
setStatus('Подключение wallet extension...');
await connectWallet();
setStatus(`Кошелёк подключён: ${walletAddress}`, 'success');
} catch (error) {
setStatus(`Ошибка подключения: ${error?.message || 'unknown'}`, 'error');
} finally {
connectBtn.disabled = false;
}
});
generateSeedBtn.addEventListener('click', () => {
try {
const seed = randomSeed32Base58();
privateSeedInput.value = seed;
privateSeedHint.textContent = 'Новый Base58 seed сгенерирован локально в браузере. Храните его как приватный ключ.';
setStatus('Сгенерирован новый Base58 seed 32 bytes.', 'success');
} catch (error) {
setStatus(`Ошибка генерации seed: ${error?.message || 'unknown'}`, 'error');
}
});
copySeedBtn.addEventListener('click', async () => {
try {
const text = String(privateSeedInput.value || '').trim();
if (!text) throw new Error('Сначала сгенерируйте или вставьте seed');
await copyText(text, 'Clipboard API недоступен');
setStatus('Base58 seed скопирован.', 'success');
} catch (error) {
setStatus(`Ошибка копирования seed: ${error?.message || 'unknown'}`, 'error');
}
});
copyCodeBtn.addEventListener('click', async () => {
try {
const text = String(promoCodeNode.value || '').trim();
if (!text) throw new Error('Сначала сгенерируйте promo-код');
await copyText(text, 'Clipboard API недоступен');
setStatus('Promo-код скопирован.', 'success');
} catch (error) {
setStatus(`Ошибка копирования promo-кода: ${error?.message || 'unknown'}`, 'error');
}
});
generateBtn.addEventListener('click', async () => {
generateBtn.disabled = true;
try {
updateMessagePreview();
setStatus('Генерация promo-кода...');
await generatePromoCode();
const modeTitle = currentMode === 'extension' ? 'wallet extension' : 'Base58 seed';
setStatus(`Promo-код готов. Режим: ${modeTitle}.`, 'success');
} catch (error) {
setStatus(`Ошибка генерации: ${error?.message || 'unknown'}`, 'error');
} finally {
generateBtn.disabled = false;
}
});
sellerLoginInput.addEventListener('input', updateMessagePreview);
targetLoginInput.addEventListener('input', updateMessagePreview);
updateModeUi();
updateMessagePreview();
})();
</script>
</body>
</html>