Compare commits

...
4 Commits
Author SHA256 Message Date
AidarKC 98140c1f71 UI: создание канала и ошибки Solana 2026-08-07 14:20:18 +04:00
aidar f00de85e38 Merge pull request 'Обновить запуск UI и упростить регистрацию' (#1) from milana-ui-redesign into main
добавлен стартовый экран UI до первого успешного подключения;
упрощена форма регистрации и удалён временный режим промокодов;
обновлены FAQ и документация переноса экранов в Figma;
версия клиента увеличена до 1.5.1.
2026-08-07 09:45:46 +00:00
malvviiina b686b12c6a Обновить запуск UI и упростить регистрацию 2026-08-06 22:53:44 +03:00
AidarKC e4b903b13c вкладки в каналах 2026-08-06 22:45:15 +04:00
10 changed files with 328 additions and 287 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.5.0 client.version=1.5.2
server.version=1.4.5 server.version=1.4.5
-2
View File
@@ -155,8 +155,6 @@
При переносе нужно отдельно учитывать: При переносе нужно отдельно учитывать:
- заголовок и стрелку назад; - заголовок и стрелку назад;
- поля логина и пароля; - поля логина и пароля;
- переключатель режима 12 слов;
- сетку слов;
- строку статуса длины пароля; - строку статуса длины пароля;
- строку статуса проверки логина; - строку статуса проверки логина;
- кнопку проверки логина; - кнопку проверки логина;
+11 -1
View File
@@ -9,7 +9,7 @@
<link rel="apple-touch-icon" href="./img/logo.jpg" /> <link rel="apple-touch-icon" href="./img/logo.jpg" />
<title>СИЯНИЕ</title> <title>СИЯНИЕ</title>
<script> <script>
window.__SHINE_BUILD_HASH__ = '20260715214500'; window.__SHINE_BUILD_HASH__ = '20260806223040';
window.__SHINE_CLIENT_VERSION__ = '1.2.10'; window.__SHINE_CLIENT_VERSION__ = '1.2.10';
</script> </script>
<script> <script>
@@ -26,6 +26,16 @@ window.__SHINE_BUILD_HASH__ = '20260715214500';
</script> </script>
</head> </head>
<body> <body>
<div id="initial-splash" class="initial-splash" aria-label="Сияние">
<div class="initial-splash__logo-wrap">
<img
class="initial-splash__logo"
src="./img/shine-logo-transparent-final.png"
alt="Логотип Сияние"
/>
</div>
<div class="initial-splash__brand">Сияние</div>
</div>
<div class="app-shell"> <div class="app-shell">
<main id="app-screen" class="screen-content"></main> <main id="app-screen" class="screen-content"></main>
<div id="toolbar-slot" class="toolbar-slot"></div> <div id="toolbar-slot" class="toolbar-slot"></div>
+25
View File
@@ -152,6 +152,7 @@ const routes = {
const screenEl = document.getElementById('app-screen'); const screenEl = document.getElementById('app-screen');
const toolbarEl = document.getElementById('toolbar-slot'); const toolbarEl = document.getElementById('toolbar-slot');
const appShellEl = document.querySelector('.app-shell'); const appShellEl = document.querySelector('.app-shell');
const initialSplashEl = document.getElementById('initial-splash');
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000; const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000; const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000;
@@ -177,6 +178,7 @@ let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null; let uiVersionPeriodicIntervalId = null;
let hiddenDmAudioContext = null; let hiddenDmAudioContext = null;
let hiddenDmAudioUnlocked = false; let hiddenDmAudioUnlocked = false;
let initialConnectionCompleted = false;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const GUEST_ALLOWED_PAGES = new Set([ const GUEST_ALLOWED_PAGES = new Set([
'start-view', 'start-view',
@@ -331,6 +333,11 @@ function refreshConnectionUi() {
const bannerEl = ensureConnectionRetryBannerEl(); const bannerEl = ensureConnectionRetryBannerEl();
if (!bannerEl) return; if (!bannerEl) return;
if (!initialConnectionCompleted) {
bannerEl.hidden = true;
return;
}
if (state === 'connected' || state === 'updating') { if (state === 'connected' || state === 'updating') {
bannerEl.hidden = true; bannerEl.hidden = true;
stopConnectionCountdown(); stopConnectionCountdown();
@@ -360,6 +367,21 @@ function refreshConnectionUi() {
bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора'; bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора';
} }
function finishInitialConnectionSplash() {
if (initialConnectionCompleted) return;
initialConnectionCompleted = true;
if (!initialSplashEl) {
refreshConnectionUi();
return;
}
initialSplashEl.classList.add('is-leaving');
const removeSplash = () => initialSplashEl.remove();
initialSplashEl.addEventListener('transitionend', removeSplash, { once: true });
window.setTimeout(removeSplash, 700);
refreshConnectionUi();
}
function startConnectionCountdown() { function startConnectionCountdown() {
if (connectionStatusCountdownId) return; if (connectionStatusCountdownId) return;
connectionStatusCountdownId = window.setInterval(() => { connectionStatusCountdownId = window.setInterval(() => {
@@ -467,6 +489,9 @@ function setConnectionStatus(nextState, text = '') {
if (!state) return; if (!state) return;
connectionState = state; connectionState = state;
connectionStatusText = String(text || '').trim(); connectionStatusText = String(text || '').trim();
if (state === 'connected') {
finishInitialConnectionSplash();
}
if (state === 'disconnected') { if (state === 'disconnected') {
if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) { if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) {
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS; connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
+82 -11
View File
@@ -8,8 +8,9 @@ import {
} from '../services/channel-name-rules.js'; } from '../services/channel-name-rules.js';
import { openArweaveAttachmentManager, markArweaveAttachmentPlaced } from '../components/arweave-attachment-manager.js'; import { openArweaveAttachmentManager, markArweaveAttachmentPlaced } from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js'; import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' }; export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1; const CHANNEL_TYPE_PUBLIC = 1;
@@ -48,13 +49,23 @@ function renderAvatarPreview(slot, avatar, title) {
slot.append(wrap); slot.append(wrap);
} }
function buildAbsoluteChannelUrl({ ownerBlockchainName = '', channelName = '' } = {}) {
const route = makeShineChannelRoute({ ownerBlockchainName, channelName });
if (!route) return '';
try {
return new URL(`/${route}`, window.location.origin).toString();
} catch {
return `${window.location.origin}/${route}`;
}
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add'; screen.className = 'stack channels-screen channels-screen--add';
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Создать канал', title: 'Создание канала',
leftAction: { label: '<', onClick: () => navigate('channels-list') }, leftAction: { label: '<', onClick: () => navigate('channels-list') },
}), }),
); );
@@ -63,23 +74,31 @@ export function render({ navigate }) {
form.className = 'card stack'; form.className = 'card stack';
form.innerHTML = ` form.innerHTML = `
<strong class="channel-head-title">Создание канала</strong> <strong class="channel-head-title">Создание канала</strong>
<p class="channel-head-meta">Разрешены только латинские буквы, цифры, _ и -.</p>
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название не должно состоять только из цифр.</p>
<p class="channel-head-meta">Можно использовать большие и маленькие буквы, но уникальность проверяется без учёта регистра.</p>
<div class="meta-muted">Тип канала фиксирован: публичный (1).</div> <div class="meta-muted">Тип канала фиксирован: публичный (1).</div>
<div class="channel-create-avatar-head">
<div id="channel-avatar-preview"></div>
<div class="channel-create-avatar-side">
<div class="channel-create-avatar-status-row">
<div class="channel-create-avatar-status" id="channel-avatar-status"></div>
<button type="button" class="channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар"></button>
</div>
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
</div>
</div>
<label for="channel-name">Техническое имя канала</label> <label for="channel-name">Техническое имя канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required /> <input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
<div class="meta-muted">Используется в прямой ссылке на канал.</div>
<div class="meta-muted" id="channel-link-preview"></div>
<div class="meta-muted">Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.</div>
<div id="channel-name-error" class="meta-muted inline-error"></div> <div id="channel-name-error" class="meta-muted inline-error"></div>
<label for="channel-title">Человекочитаемое имя</label> <label for="channel-title">Красивое имя (то, как канал будет виден пользователям)</label>
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" /> <input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div> <div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
<div id="channel-title-error" class="meta-muted inline-error"></div> <div id="channel-title-error" class="meta-muted inline-error"></div>
<div id="channel-avatar-preview"></div>
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
<label for="channel-description">Описание канала (необязательно)</label> <label for="channel-description">Описание канала (необязательно)</label>
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea> <textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div> <div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
@@ -96,7 +115,10 @@ export function render({ navigate }) {
const titleEl = form.querySelector('#channel-title'); const titleEl = form.querySelector('#channel-title');
const descriptionEl = form.querySelector('#channel-description'); const descriptionEl = form.querySelector('#channel-description');
const avatarPreviewEl = form.querySelector('#channel-avatar-preview'); const avatarPreviewEl = form.querySelector('#channel-avatar-preview');
const avatarStatusEl = form.querySelector('#channel-avatar-status');
const avatarBtn = form.querySelector('#channel-avatar-btn'); const avatarBtn = form.querySelector('#channel-avatar-btn');
const avatarRemoveBtn = form.querySelector('#channel-avatar-remove');
const linkPreviewEl = form.querySelector('#channel-link-preview');
const nameErrorEl = form.querySelector('#channel-name-error'); const nameErrorEl = form.querySelector('#channel-name-error');
const titleErrorEl = form.querySelector('#channel-title-error'); const titleErrorEl = form.querySelector('#channel-title-error');
const titleCounterEl = form.querySelector('#channel-title-counter'); const titleCounterEl = form.querySelector('#channel-title-counter');
@@ -108,6 +130,36 @@ export function render({ navigate }) {
let submitInFlight = false; let submitInFlight = false;
let selectedAvatar = null; let selectedAvatar = null;
let ownerBlockchainName = '';
const renderAvatarStatus = (nameValue, titleValue) => {
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleValue || nameValue);
avatarRemoveBtn.hidden = !selectedAvatar?.ar;
if (!selectedAvatar?.ar) {
avatarStatusEl.innerHTML = '<p class="meta-muted">Аватар не установлен.</p>';
return;
}
const txId = String(selectedAvatar.ar || '').trim();
const isPending = true;
avatarStatusEl.innerHTML = `
<p class="channel-avatar-status-text">${isPending ? 'Аватар выбран и ещё не сохранён.' : 'Аватар установлен.'}</p>
<p class="meta-muted">Адрес в Arweave:</p>
<p class="channel-avatar-txid">${txId}</p>
`;
};
const renderChannelLinkPreview = (nameValue) => {
const normalizedChannelName = normalizeChannelDisplayName(nameValue);
if (!ownerBlockchainName || !normalizedChannelName) {
linkPreviewEl.textContent = 'Прямая ссылка появится после ввода технического имени канала.';
return;
}
const directUrl = buildAbsoluteChannelUrl({
ownerBlockchainName,
channelName: normalizedChannelName,
});
linkPreviewEl.innerHTML = `Прямая ссылка: <a href="${directUrl}" target="_blank" rel="noopener noreferrer">${directUrl}</a>`;
};
const setBusy = (busy) => { const setBusy = (busy) => {
submitInFlight = !!busy; submitInFlight = !!busy;
@@ -134,7 +186,8 @@ export function render({ navigate }) {
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok; const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
submitEl.disabled = submitInFlight || !ok; submitEl.disabled = submitInFlight || !ok;
renderAvatarPreview(avatarPreviewEl, selectedAvatar, titleCheck.normalized || nameCheck.normalized); renderAvatarStatus(nameCheck.normalized, titleCheck.normalized);
renderChannelLinkPreview(nameCheck.normalized);
return { return {
ok, ok,
@@ -149,19 +202,28 @@ export function render({ navigate }) {
descriptionEl.addEventListener('input', updateValidation); descriptionEl.addEventListener('input', updateValidation);
avatarBtn.addEventListener('click', async () => { avatarBtn.addEventListener('click', async () => {
try { try {
selectedAvatar = await openArweaveAttachmentManager({ const nextAvatar = await openArweaveAttachmentManager({
login: state.session.login, login: state.session.login,
storagePwd: state.session.storagePwdInMemory, storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer, gateway: state.entrySettings.arweaveServer,
mode: 'avatar', mode: 'avatar',
historyPurpose: 'avatar', historyPurpose: 'avatar',
}); });
if (nextAvatar?.ar) selectedAvatar = nextAvatar;
updateValidation(); updateValidation();
} catch (error) { } catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.'); errorEl.textContent = toUserMessage(error, 'Не удалось выбрать аватар.');
} }
}); });
avatarRemoveBtn.addEventListener('click', () => {
if (!selectedAvatar?.ar) return;
const confirmed = window.confirm('Убрать аватар и сделать канал без аватара?');
if (!confirmed) return;
selectedAvatar = null;
updateValidation();
});
form.addEventListener('submit', async (event) => { form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
if (submitInFlight) return; if (submitInFlight) return;
@@ -206,5 +268,14 @@ export function render({ navigate }) {
screen.append(form); screen.append(form);
nameEl.focus(); nameEl.focus();
updateValidation(); updateValidation();
void authService.getUser(String(state.session.login || '').trim())
.then((user) => {
ownerBlockchainName = String(user?.blockchainName || '').trim();
renderChannelLinkPreview(nameEl.value);
})
.catch(() => {
ownerBlockchainName = '';
renderChannelLinkPreview(nameEl.value);
});
return screen; return screen;
} }
+30 -257
View File
@@ -4,34 +4,17 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import { import {
checkLoginExistsOnSolana, checkLoginExistsOnSolana,
formatSolanaErrorDetails, formatSolanaErrorDetails,
isSolanaRpcUnavailableError,
precheckLoginClassOnSolana, precheckLoginClassOnSolana,
} from '../services/solana-register-service.js'; } from '../services/solana-register-service.js';
import { import {
composePasswordFromWords,
emptyPasswordWords, emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH, PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js'; } from '../services/password-words.js';
import { sha256Text } from '../services/crypto-utils.js';
import { openRegistrationFaq } from './registration-faq-view.js'; import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
// ВРЕМЕННАЯ UI-ЗАГЛУШКА: const MIN_REGISTRATION_LOGIN_LENGTH = 8;
// пока полноценная логика продажи/выдачи коротких имён не внедрена on-chain,
// UI пускает логины 5..7 символов только по временному коду.
const TEMP_MIN_LOGIN_WITHOUT_PROMO = 8;
const TEMP_ABSOLUTE_MIN_LOGIN_LEN = 5;
const TEMP_PROMO_HASH_HEX = 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3';
function bytesToHex(bytes) {
return Array.from(bytes || [], (b) => b.toString(16).padStart(2, '0')).join('');
}
async function isTemporaryPromoAccepted(value) {
const digest = await sha256Text(String(value || ''));
return bytesToHex(digest) === TEMP_PROMO_HASH_HEX;
}
function normalizeLoginForTemporaryUiGuard(login) { function normalizeLoginForTemporaryUiGuard(login) {
const source = String(login || '').trim(); const source = String(login || '').trim();
@@ -46,48 +29,6 @@ function normalizeLoginForTemporaryUiGuard(login) {
return normalized; return normalized;
} }
function createWordsLayout({ words, onInput }) {
const section = document.createElement('div');
section.className = 'registration-words-block';
const grid = document.createElement('div');
grid.className = 'registration-words-grid';
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
const row = document.createElement('label');
row.className = 'registration-word-row';
const number = document.createElement('span');
number.className = 'registration-word-number';
number.textContent = `${index + 1}.`;
const input = document.createElement('input');
input.className = 'input registration-word-input';
input.type = 'text';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.maxLength = 32;
input.value = words[index];
input.addEventListener('input', () => onInput(index, input.value));
row.append(number, input);
grid.append(row);
return input;
});
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent =
'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs, preview };
}
function makePasswordToggleIcons() { function makePasswordToggleIcons() {
return { return {
eye: ` eye: `
@@ -115,10 +56,11 @@ export function render({ navigate }) {
const form = document.createElement('div'); const form = document.createElement('div');
form.className = 'card stack registration-form'; form.className = 'card stack registration-form';
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single'; state.registrationDraft.passwordMode = 'single';
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords); state.registrationDraft.passwordWords = emptyPasswordWords();
let passwordWordsLinked = Boolean(state.registrationDraft.passwordWordsLinked); state.registrationDraft.passwordWordsLinked = false;
let usePromoCode = Boolean(state.registrationDraft.usePromoCode); state.registrationDraft.usePromoCode = false;
state.registrationDraft.promoCode = '';
let loginCheckTimer = 0; let loginCheckTimer = 0;
let loginCheckRunId = 0; let loginCheckRunId = 0;
@@ -155,67 +97,6 @@ export function render({ navigate }) {
passwordInputRow.className = 'inline-input-row'; passwordInputRow.className = 'inline-input-row';
passwordInputRow.append(passwordInput, passwordToggleButton); passwordInputRow.append(passwordInput, passwordToggleButton);
const {
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
passwordWords[index] = value;
passwordInput.value = composePasswordFromWords(passwordWords);
passwordWordsLinked = true;
syncDraftState();
updateWordsPreview();
},
});
const passwordModeToggle = document.createElement('label');
passwordModeToggle.className = 'registration-toggle';
const passwordModeCheckbox = document.createElement('input');
passwordModeCheckbox.type = 'checkbox';
passwordModeCheckbox.checked = passwordMode === 'words';
const passwordModeLabel = document.createElement('span');
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const promoToggle = document.createElement('label');
promoToggle.className = 'registration-toggle';
const promoCheckbox = document.createElement('input');
promoCheckbox.type = 'checkbox';
promoCheckbox.checked = usePromoCode;
const promoToggleLabel = document.createElement('span');
promoToggleLabel.textContent = 'У меня есть промокод';
promoToggle.append(promoCheckbox, promoToggleLabel);
const promoField = document.createElement('label');
promoField.className = 'stack';
const promoFieldLabel = document.createElement('span');
promoFieldLabel.className = 'field-label';
promoFieldLabel.textContent = 'Промокод';
const promoInput = document.createElement('input');
promoInput.className = 'input';
promoInput.type = 'text';
promoInput.autocomplete = 'off';
promoInput.autocapitalize = 'off';
promoInput.spellcheck = false;
promoInput.value = String(state.registrationDraft.promoCode || '');
promoInput.placeholder = 'Вставьте промокод';
const promoHint = document.createElement('p');
promoHint.className = 'meta-muted';
promoHint.textContent = 'Временный режим: логины длиной 5-7 символов доступны только по специальному коду. Любое другое значение считается неверным промокодом.';
promoField.append(promoFieldLabel, promoInput, promoHint);
const statusText = document.createElement('p'); const statusText = document.createElement('p');
statusText.className = 'status-line registration-login-status'; statusText.className = 'status-line registration-login-status';
statusText.style.display = 'none'; statusText.style.display = 'none';
@@ -239,7 +120,6 @@ export function render({ navigate }) {
nextButton.type = 'button'; nextButton.type = 'button';
nextButton.textContent = 'Далее'; nextButton.textContent = 'Далее';
let passwordField = null;
const passwordLengthText = document.createElement('p'); const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'password-length-hint'; passwordLengthText.className = 'password-length-hint';
let lastCheckedLogin = ''; let lastCheckedLogin = '';
@@ -251,13 +131,8 @@ export function render({ navigate }) {
return String(passwordInput.value || ''); return String(passwordInput.value || '');
} }
function updateWordsPreview() { function updatePasswordLength() {
const password = passwordMode === 'words' passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`;
? composePasswordFromWords(passwordWords)
: String(passwordInput.value || '');
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
} }
function setStatusMessage(message, kind = '') { function setStatusMessage(message, kind = '') {
@@ -295,32 +170,14 @@ export function render({ navigate }) {
passwordToggleButton.setAttribute('title', reveal ? 'Скрыть пароль' : 'Показать пароль'); passwordToggleButton.setAttribute('title', reveal ? 'Скрыть пароль' : 'Показать пароль');
} }
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = 'grid';
passwordInputRow.style.display = 'grid';
updateWordsPreview();
}
function syncDraftState() { function syncDraftState() {
state.registrationDraft.login = String(loginInput.value.trim()); state.registrationDraft.login = String(loginInput.value.trim());
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
state.registrationDraft.password = getCurrentPassword(); state.registrationDraft.password = getCurrentPassword();
state.registrationDraft.usePromoCode = usePromoCode;
state.registrationDraft.promoCode = String(promoInput.value || '').trim();
}
function updatePromoVisibility() {
promoField.style.display = usePromoCode ? 'grid' : 'none';
} }
async function runAvailabilityCheck({ automatic = false } = {}) { async function runAvailabilityCheck({ automatic = false } = {}) {
const runId = ++loginCheckRunId; const runId = ++loginCheckRunId;
const login = loginInput.value.trim(); const login = loginInput.value.trim();
const promoCode = String(promoInput.value || '').trim();
if (!login) { if (!login) {
setStatusMessage(automatic ? '' : 'Введите логин'); setStatusMessage(automatic ? '' : 'Введите логин');
formError.style.display = 'none'; formError.style.display = 'none';
@@ -334,28 +191,8 @@ export function render({ navigate }) {
return false; return false;
} }
if (normalizedLogin.length < TEMP_ABSOLUTE_MIN_LOGIN_LEN) { if (normalizedLogin.length < MIN_REGISTRATION_LOGIN_LENGTH) {
setStatusMessage(`Логин должен быть не короче ${TEMP_ABSOLUTE_MIN_LOGIN_LEN} символов ❌`, 'is-unavailable'); setStatusMessage(`Логин должен быть не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов ❌`, 'is-unavailable');
formError.style.display = 'none';
return false;
}
const wantsShortLogin = normalizedLogin.length < TEMP_MIN_LOGIN_WITHOUT_PROMO;
let promoAccepted = false;
if (usePromoCode) {
if (!promoCode) {
setStatusMessage('Введите промокод ❌', 'is-unavailable');
formError.style.display = 'none';
return false;
}
promoAccepted = await isTemporaryPromoAccepted(promoCode);
if (!promoAccepted) {
setStatusMessage('Неверный промокод ❌', 'is-unavailable');
formError.style.display = 'none';
return false;
}
} else if (wantsShortLogin) {
setStatusMessage(`Логины короче ${TEMP_MIN_LOGIN_WITHOUT_PROMO} символов временно доступны только по специальному коду ❌`, 'is-unavailable');
formError.style.display = 'none'; formError.style.display = 'none';
return false; return false;
} }
@@ -363,8 +200,6 @@ export function render({ navigate }) {
if (login === lastCheckedLogin) { if (login === lastCheckedLogin) {
if (!lastCheckedFree) { if (!lastCheckedFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable'); setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (lastCheckedClassName === 'free') { } else if (lastCheckedClassName === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available'); setStatusMessage('Логин свободен ✅', 'is-available');
} else if (lastCheckedClassName === 'premium') { } else if (lastCheckedClassName === 'premium') {
@@ -375,7 +210,7 @@ export function render({ navigate }) {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable'); setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
} }
formError.style.display = 'none'; formError.style.display = 'none';
return lastCheckedFree && (lastCheckedClassName === 'free' || lastCheckedClassName === 'promo'); return lastCheckedFree && lastCheckedClassName === 'free';
} }
setStatusMessage('Проверяем логин...'); setStatusMessage('Проверяем логин...');
@@ -388,19 +223,15 @@ export function render({ navigate }) {
const isFree = !check.exists; const isFree = !check.exists;
let className = ''; let className = '';
if (isFree) { if (isFree) {
if (promoAccepted) { try {
className = 'promo'; const precheck = await precheckLoginClassOnSolana({
} else { login,
try { solanaEndpoint: state.entrySettings.solanaServer,
const precheck = await precheckLoginClassOnSolana({ });
login, className = precheck.className;
solanaEndpoint: state.entrySettings.solanaServer, } catch (precheckError) {
}); className = 'free';
className = precheck.className; console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError));
} catch (precheckError) {
className = 'free';
console.warn('Solana login precheck fallback to free', formatSolanaErrorDetails(precheckError));
}
} }
} }
lastCheckedLogin = login; lastCheckedLogin = login;
@@ -408,8 +239,6 @@ export function render({ navigate }) {
lastCheckedClassName = className; lastCheckedClassName = className;
if (!isFree) { if (!isFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable'); setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (className === 'promo') {
setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available');
} else if (className === 'free') { } else if (className === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available'); setStatusMessage('Логин свободен ✅', 'is-available');
} else if (className === 'premium') { } else if (className === 'premium') {
@@ -420,9 +249,13 @@ export function render({ navigate }) {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable'); setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
} }
formError.style.display = 'none'; formError.style.display = 'none';
return isFree && (className === 'free' || className === 'promo'); return isFree && className === 'free';
} catch (error) { } catch (error) {
if (runId !== loginCheckRunId) return false; if (runId !== loginCheckRunId) return false;
if (isSolanaRpcUnavailableError(error)) {
setStatusMessage('Нет связи с сервером Solana. Попробуйте ещё раз позже.', 'is-unavailable');
return false;
}
const base = toUserMessage(error, 'Не удалось проверить логин'); const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error); const details = formatSolanaErrorDetails(error);
setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable'); setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable');
@@ -438,65 +271,12 @@ export function render({ navigate }) {
}); });
passwordInput.addEventListener('input', () => { passwordInput.addEventListener('input', () => {
if (passwordWordsLinked && String(passwordInput.value || '') !== composePasswordFromWords(passwordWords)) {
passwordWords = emptyPasswordWords();
passwordWordsLinked = false;
wordInputs.forEach((input) => {
input.value = '';
});
}
syncDraftState(); syncDraftState();
updateWordsPreview(); updatePasswordLength();
});
passwordModeCheckbox.addEventListener('change', () => {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
if (nextMode === passwordMode) return;
if (nextMode === 'words') {
if (!passwordWordsLinked) {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
} else {
passwordInput.value = composePasswordFromWords(passwordWords);
}
} else if (passwordWordsLinked) {
passwordInput.value = composePasswordFromWords(passwordWords);
}
passwordMode = nextMode;
updatePasswordModeVisibility();
updateWordsPreview();
syncDraftState();
});
promoCheckbox.addEventListener('change', () => {
usePromoCode = promoCheckbox.checked;
resetLoginCheckState();
updatePromoVisibility();
syncDraftState();
if (usePromoCode) {
setStatusMessage('Временный код включён: для логинов 5-7 символов будет локальная проверка', 'is-available');
} else {
setStatusMessage('');
scheduleAvailabilityCheck();
}
});
promoInput.addEventListener('input', () => {
resetLoginCheckState();
syncDraftState();
if (usePromoCode) scheduleAvailabilityCheck();
}); });
nextButton.addEventListener('click', async () => { nextButton.addEventListener('click', async () => {
formError.style.display = 'none'; formError.style.display = 'none';
const promoCode = String(promoInput.value || '').trim();
if (usePromoCode && !promoCode) {
formError.textContent = 'Если включён временный код, поле должно быть заполнено.';
formError.style.display = '';
return;
}
const isFree = await runAvailabilityCheck(); const isFree = await runAvailabilityCheck();
if (!isFree) return; if (!isFree) return;
@@ -518,11 +298,6 @@ export function render({ navigate }) {
state.registrationDraft.login = nextLogin; state.registrationDraft.login = nextLogin;
state.registrationDraft.password = nextPassword; state.registrationDraft.password = nextPassword;
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.passwordWordsLinked = passwordWordsLinked;
state.registrationDraft.usePromoCode = usePromoCode;
state.registrationDraft.promoCode = promoCode;
if (credsChanged) { if (credsChanged) {
state.registrationDraft.preGeneratedKeyBundle = null; state.registrationDraft.preGeneratedKeyBundle = null;
} }
@@ -539,14 +314,12 @@ export function render({ navigate }) {
passwordLabel.className = 'stack registration-password-single'; passwordLabel.className = 'stack registration-password-single';
passwordLabel.innerHTML = '<span class="field-label">Пароль</span>'; passwordLabel.innerHTML = '<span class="field-label">Пароль</span>';
form.append(loginField, statusText, passwordLabel); form.append(loginField, statusText, passwordLabel);
passwordField = passwordLabel;
loginField.append(loginInput); loginField.append(loginInput);
passwordField.append(passwordInputRow); passwordLabel.append(passwordInputRow);
form.append(passwordModeToggle, promoToggle, promoField, passwordLengthText, wordsSection, formError, faqButton); form.append(passwordLengthText, formError, faqButton);
actions.innerHTML = ''; actions.innerHTML = '';
actions.append(nextButton); actions.append(nextButton);
updatePasswordModeVisibility(); updatePasswordLength();
updatePromoVisibility();
syncDraftState(); syncDraftState();
} }
+1 -11
View File
@@ -62,17 +62,7 @@ export const REGISTRATION_FAQ_TOPICS = [
paragraphs: [ paragraphs: [
'Минимально разумный уровень сейчас начинается примерно от 8 символов.', 'Минимально разумный уровень сейчас начинается примерно от 8 символов.',
'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.', 'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.',
'Если вам удобнее думать словами, можно использовать режим из 12 полей ниже: слова просто склеиваются в один длинный пароль, и система не проверяет орфографию.', 'Используйте уникальный пароль, который не применяется в других сервисах.',
],
},
{
id: 'one-or-twelve',
shortTitle: '1 или 12 слов',
title: 'Чем отличается один пароль от режима 12 слов?',
paragraphs: [
'Технически ничем: это один и тот же пароль. Режим 12 слов нужен только для удобства запоминания и ввода.',
'Можно заполнить все 12 полей, можно только первые 6, можно использовать слова от другого кошелька, разные языки и любые нестандартные символы.',
'Главное помнить, что в конце всё равно получается одна строка длиной до 256 символов.',
], ],
}, },
{ {
+20 -4
View File
@@ -18,6 +18,7 @@ import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import { import {
checkLoginExistsOnSolana, checkLoginExistsOnSolana,
formatSolanaErrorDetails, formatSolanaErrorDetails,
isSolanaRpcUnavailableError,
isUserAlreadyExistsSolanaError, isUserAlreadyExistsSolanaError,
registerUserOnSolana, registerUserOnSolana,
} from '../services/solana-register-service.js'; } from '../services/solana-register-service.js';
@@ -311,6 +312,16 @@ export function render({ navigate }) {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle; const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи не найдены. Вернитесь на предыдущий шаг.'); if (!keyBundle) throw new Error('Ключи не найдены. Вернитесь на предыдущий шаг.');
// Перед on-chain регистрацией перепроверяем логин ещё раз:
// между экраном выбора логина и оплатой его мог занять кто-то другой.
const loginStateBeforeRegister = await checkLoginExistsOnSolana({
login: state.registrationDraft.login,
solanaEndpoint: state.entrySettings.solanaServer,
});
if (loginStateBeforeRegister?.exists) {
throw new Error('Этот логин уже зарегистрирован. Войдите в существующий аккаунт или выберите другой логин.');
}
// Регистрация на Solana (смарт контракт) // Регистрация на Solana (смарт контракт)
submitButton.textContent = 'Регистрация в Solana...'; submitButton.textContent = 'Регистрация в Solana...';
let registrationTxId = ''; let registrationTxId = '';
@@ -325,15 +336,20 @@ export function render({ navigate }) {
registrationTxId = String(registrationResult?.signature || '').trim(); registrationTxId = String(registrationResult?.signature || '').trim();
} catch (solanaError) { } catch (solanaError) {
const solanaMsg = formatSolanaErrorDetails(solanaError); const solanaMsg = formatSolanaErrorDetails(solanaError);
// Пользователь уже зарегистрирован в Solana — продолжаем if (solanaMsg.includes('already') || isUserAlreadyExistsSolanaError(solanaError)) {
if (!solanaMsg.includes('already') && !isUserAlreadyExistsSolanaError(solanaError)) { throw new Error('Этот логин уже зарегистрирован. Войдите в существующий аккаунт или выберите другой логин.');
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
} }
if (isSolanaRpcUnavailableError(solanaError)) {
throw new Error('Нет связи с сервером Solana. Попробуйте ещё раз позже.');
}
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
} }
renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId }); renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId });
} catch (error) { } catch (error) {
const message = toUserMessage(error, 'Не удалось завершить регистрацию.'); const message = isSolanaRpcUnavailableError(error)
? 'Нет связи с сервером Solana. Попробуйте ещё раз позже.'
: toUserMessage(error, 'Не удалось завершить регистрацию.');
setAuthError(message); setAuthError(message);
status.className = 'status-line is-unavailable'; status.className = 'status-line is-unavailable';
status.textContent = message; status.textContent = message;
@@ -72,6 +72,19 @@ export function formatSolanaErrorDetails(error) {
return parts.join(' :: '); return parts.join(' :: ');
} }
export function isSolanaRpcUnavailableError(error) {
const details = formatSolanaErrorDetails(error).toLowerCase();
return details.includes('service unavailable')
|| details.includes('503')
|| details.includes('failed to fetch')
|| details.includes('fetch failed')
|| details.includes('networkerror')
|| details.includes('network error')
|| details.includes('rpc unavailable')
|| details.includes('rpc error')
|| details.includes('failed to get info about account');
}
export function isUserAlreadyExistsSolanaError(error) { export function isUserAlreadyExistsSolanaError(error) {
const details = formatSolanaErrorDetails(error); const details = formatSolanaErrorDetails(error);
return details.includes('UserAlreadyExists') return details.includes('UserAlreadyExists')
+145
View File
@@ -438,6 +438,70 @@
text-align: center; text-align: center;
} }
.initial-splash {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
align-content: center;
justify-items: center;
gap: clamp(12px, 2.4vh, 20px);
padding: 24px;
overflow: hidden;
background:
radial-gradient(circle at 50% 18%, rgba(224, 172, 75, 0.08), transparent 26%),
radial-gradient(circle at 50% 67%, rgba(45, 81, 143, 0.06), transparent 38%),
linear-gradient(180deg, #040816 0%, #020611 54%, #01040c 100%);
font-family: "Manrope", "Inter", "SF Pro Display", system-ui, sans-serif;
opacity: 1;
visibility: visible;
transition: opacity 520ms ease, visibility 520ms ease;
}
.initial-splash.is-leaving {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.initial-splash__logo-wrap {
position: relative;
isolation: isolate;
width: clamp(232px, 59vw, 284px);
aspect-ratio: 1;
}
.initial-splash__logo-wrap::before {
content: "";
position: absolute;
inset: 16%;
z-index: -1;
border-radius: 50%;
background: rgba(214, 155, 56, 0.2);
filter: blur(30px);
animation: shine-halo-breathe 4.8s ease-in-out infinite;
}
.initial-splash__logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
filter:
drop-shadow(0 0 8px rgba(255, 211, 121, 0.72))
drop-shadow(0 8px 22px rgba(225, 158, 48, 0.38));
animation: shine-logo-breathe 4.8s ease-in-out infinite;
}
.initial-splash__brand {
margin-top: -4px;
color: #f4ebdd;
font-size: clamp(42px, 9vw, 56px);
font-weight: 500;
line-height: 1;
text-shadow: 0 3px 22px rgba(210, 168, 90, 0.2);
}
.auth-screen--welcome { .auth-screen--welcome {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
@@ -709,6 +773,14 @@
} }
@media (max-height: 760px) { @media (max-height: 760px) {
.initial-splash__logo-wrap {
width: 206px;
}
.initial-splash__brand {
font-size: 40px;
}
.auth-screen--welcome { .auth-screen--welcome {
gap: 10px; gap: 10px;
} }
@@ -732,6 +804,12 @@
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.initial-splash {
transition-duration: 1ms;
}
.initial-splash__logo,
.initial-splash__logo-wrap::before,
.auth-logo, .auth-logo,
.auth-logo-wrap::before, .auth-logo-wrap::before,
.shine-btn { .shine-btn {
@@ -6479,6 +6557,73 @@ html, body { overflow-x: hidden; }
box-shadow: 0 0 22px rgba(212, 175, 55, 0.2), inset 0 0 8px rgba(212, 175, 55, 0.1); box-shadow: 0 0 22px rgba(212, 175, 55, 0.2), inset 0 0 8px rgba(212, 175, 55, 0.1);
} }
.channel-create-avatar-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.channel-create-avatar-side {
min-width: 0;
display: grid;
gap: 10px;
align-content: start;
}
.channel-create-avatar-status-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
}
.channel-create-avatar-status {
min-width: 0;
}
.channel-avatar-status-text {
margin: 0;
color: rgba(255, 236, 194, 0.95);
font-weight: 600;
}
.channel-avatar-txid {
margin: 4px 0 0;
word-break: break-all;
color: rgba(255, 255, 255, 0.86);
font-size: 12px;
}
.channel-avatar-remove-btn {
width: 26px;
min-width: 26px;
height: 26px;
padding: 0;
border: 1px solid rgba(255, 107, 107, 0.75);
border-radius: 8px;
background: rgba(150, 18, 18, 0.3);
color: #ff8b8b;
font-weight: 700;
line-height: 1;
}
.channel-avatar-remove-btn:hover,
.channel-avatar-remove-btn:focus-visible {
background: rgba(180, 22, 22, 0.42);
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.16);
}
.channel-avatar-remove-btn[hidden] {
display: none;
}
@media (max-width: 420px) {
.channel-create-avatar-head {
grid-template-columns: 1fr;
}
}
/* ===== Final microinteractions: breathe cards + static energy buttons ===== */ /* ===== Final microinteractions: breathe cards + static energy buttons ===== */
@keyframes breatheCard { @keyframes breatheCard {
0%, 100% { transform: translateY(0px); } 0%, 100% { transform: translateY(0px); }