Add subscription validation and first-run onboarding reveal
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-07-11 14:13:39 +03:00
parent 84efbe7450
commit 0a1aa8aed3
5 changed files with 205 additions and 29 deletions

View File

@@ -309,6 +309,12 @@ async function handleApi(req, res) {
return sendJson(res, 200, { success: true, ...parsed });
}
if (req.method === 'POST' && req.url === '/api/subscription/validate') {
const { url = '' } = await readBody(req);
const parsed = await fetchSubscription(String(url).trim());
return sendJson(res, 200, { success: true, servers: parsed.servers.length });
}
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
return sendJson(res, 200, await refreshSavedSubscription());
}

View File

@@ -5,6 +5,7 @@ import { api } from './api.js';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [state, setState] = useState(null);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [servers, setServers] = useState([]);
@@ -86,13 +87,13 @@ function App() {
<div className="app-body client-mode">
<main className="app-main">
<ClientOverviewPage
state={state}
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
busy={busy}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={servers}
pendingTag={pendingTag}
servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : servers}
pendingTag={previewReady ? 'Amsterdam' : pendingTag}
setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}

View File

@@ -16,6 +16,11 @@ async function request(url, options = {}) {
export const api = {
state: () => request('/api/state'),
subscription: {
validate: (url, signal) => request('/api/subscription/validate', {
method: 'POST',
body: JSON.stringify({ url }),
signal,
}),
fetch: (url) => request('/api/subscription/fetch', {
method: 'POST',
body: JSON.stringify({ url }),

View File

@@ -13,6 +13,8 @@ import {
import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
function InstructionStep({ step }) {
if (typeof step === 'string') return step;
return (
@@ -76,12 +78,16 @@ function HarborBrand({ isGateway }) {
return (
<div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
<div className="harbor-brand-content">
<svg viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="6" r="3" />
<path d="M16 9v15M10 14h12M6 20c1.5 5 5 8 10 8s8.5-3 10-8M6 20l5 1M26 20l-5 1" />
<path d="M16 9v15M10 14h12" />
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
</svg>
<span><strong>Harbor</strong> <em>{product}</em></span>
</div>
</div>
);
}
@@ -110,6 +116,9 @@ export function ClientOverviewPage({
const [now, setNow] = useState(Date.now());
const [durationMode, setDurationMode] = useState('digital');
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
const [showIntro, setShowIntro] = useState(!hasSubscription);
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [pings, setPings] = useState({});
const [accessTab, setAccessTab] = useState('gateway');
const [copyFeedback, setCopyFeedback] = useState(null);
@@ -124,6 +133,7 @@ export function ClientOverviewPage({
const copyTimerRef = useRef(null);
const instructionsPanelRef = useRef(null);
const instructionsToggleRef = useRef(null);
const previousHasSubscriptionRef = useRef(hasSubscription);
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
@@ -143,6 +153,11 @@ export function ClientOverviewPage({
const orderedInstructionGuides = openInstruction
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
: instructionGuides;
const normalizedSubscriptionUrl = subscriptionUrl.trim();
const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation.status
: normalizedSubscriptionUrl ? 'checking' : 'idle';
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
useEffect(() => {
setNow(Date.now());
@@ -181,6 +196,57 @@ export function ClientOverviewPage({
if (editingSubscription) subscriptionInputRef.current?.focus();
}, [editingSubscription]);
useEffect(() => {
if (!showIntro) return undefined;
const timer = setTimeout(() => setShowIntro(false), 1200);
return () => clearTimeout(timer);
}, [showIntro]);
useEffect(() => {
const previouslyHadSubscription = previousHasSubscriptionRef.current;
previousHasSubscriptionRef.current = hasSubscription;
if (!hasSubscription) {
setSubscriptionContentReady(false);
return undefined;
}
if (previouslyHadSubscription) {
setSubscriptionContentReady(true);
return undefined;
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setSubscriptionContentReady(true);
return undefined;
}
const timer = setTimeout(() => setSubscriptionContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
return () => clearTimeout(timer);
}, [hasSubscription]);
useEffect(() => {
if (!normalizedSubscriptionUrl) {
setSubscriptionValidation({ url: '', status: 'idle' });
return undefined;
}
const controller = new AbortController();
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
const timer = setTimeout(() => {
api.subscription.validate(normalizedSubscriptionUrl, controller.signal)
.then(() => setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid' }))
.catch(() => {
if (!controller.signal.aborted) {
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'invalid' });
}
});
}, 350);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [normalizedSubscriptionUrl]);
useEffect(() => {
if (!hasSubscription) {
setEditingSubscription(true);
@@ -263,7 +329,7 @@ export function ClientOverviewPage({
async function submitSubscription(event) {
event.preventDefault();
if (!subscriptionUrl.trim()) return;
if (subscriptionValidationStatus !== 'valid') return;
await onFetchSubscription();
setSubscriptionUrl('');
setEditingSubscription(false);
@@ -319,9 +385,9 @@ export function ClientOverviewPage({
}
return (
<div className="client-shell">
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
<HarborBrand isGateway={isGateway} />
{hasSubscription && <button
{hasSubscription && subscriptionContentReady && <button
ref={instructionsToggleRef}
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
type="button"
@@ -468,7 +534,11 @@ export function ClientOverviewPage({
{error && <p className="client-error" role="alert">{error}</p>}
<div className="client-form">
<div
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
aria-hidden={subscriptionWaiting}
inert={subscriptionWaiting ? true : undefined}
>
<div
ref={subscriptionRef}
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
@@ -516,7 +586,7 @@ export function ClientOverviewPage({
</div>
<form
className="client-subscription-edit"
className={`client-subscription-edit is-${subscriptionValidationStatus}`}
autoComplete="off"
aria-hidden={!editingSubscription}
inert={!editingSubscription ? true : undefined}
@@ -546,13 +616,26 @@ export function ClientOverviewPage({
{subscriptionDomain(subscriptionUrl)}
</span>
)}
{subscriptionUrl.trim() && (
<button type="submit" aria-label="Сохранить подписку" disabled={busy}></button>
{normalizedSubscriptionUrl && (
<button
className="client-subscription-submit"
type="submit"
aria-label={subscriptionValidationStatus === 'valid'
? 'Сохранить подписку'
: subscriptionValidationStatus === 'invalid'
? 'Ссылка подписки не распознана'
: 'Проверяем ссылку подписки'}
disabled={busy || subscriptionValidationStatus !== 'valid'}
>
{subscriptionValidationStatus === 'valid'
? '✓'
: subscriptionValidationStatus === 'invalid' ? '×' : '…'}
</button>
)}
</form>
</div>
{hasSubscription && hasUsage && (
{hasSubscription && subscriptionContentReady && hasUsage && (
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
<span>Использовано</span>
<strong>
@@ -582,7 +665,7 @@ export function ClientOverviewPage({
</section>
)}
{hasSubscription && (
{hasSubscription && subscriptionContentReady && (
<section className="client-servers" aria-label="Выберите сервер">
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
<div
@@ -620,7 +703,7 @@ export function ClientOverviewPage({
</div>
</main>
{hasSubscription && <aside
{hasSubscription && subscriptionContentReady && <aside
ref={instructionsPanelRef}
id="client-instructions"
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}

View File

@@ -92,15 +92,43 @@ p {
.harbor-brand {
position: fixed;
top: 24px;
left: 28px;
display: flex;
align-items: center;
gap: 9px;
top: 50%;
left: 50%;
z-index: 2;
color: var(--harbor-word);
font-size: 13px;
letter-spacing: -0.035em;
transform: translate(-50%, calc(-50% - 25vh)) scale(3);
transform-origin: center;
transition: transform 1800ms cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
pointer-events: none;
}
.client-shell.is-first-run .harbor-brand {
transform: translate(-50%, calc(-50% - 72px)) scale(1.35);
}
.harbor-brand-content {
display: flex;
align-items: center;
gap: 9px;
}
.client-shell.is-intro .harbor-brand-content {
animation: harbor-first-run 1200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-shell.is-intro .client-form {
animation: harbor-first-run-form 650ms 720ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes harbor-first-run {
from { transform: translateY(53px) scale(1.59); }
}
@keyframes harbor-first-run-form {
from { opacity: 0; filter: blur(8px); }
}
.harbor-brand svg {
@@ -113,6 +141,14 @@ p {
stroke-linejoin: round;
}
.harbor-brand .harbor-anchor-left {
stroke: var(--harbor-connect);
}
.harbor-brand .harbor-anchor-right {
stroke: var(--harbor-gateway);
}
.harbor-brand strong {
font-weight: 800;
}
@@ -726,7 +762,13 @@ p {
display: grid;
gap: 36px;
transform: translateX(0);
transition: left 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 850ms cubic-bezier(0.16, 1, 0.3, 1), width 850ms cubic-bezier(0.16, 1, 0.3, 1);
transition: left 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 850ms cubic-bezier(0.16, 1, 0.3, 1), width 850ms cubic-bezier(0.16, 1, 0.3, 1), opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-form.is-waiting {
opacity: 0;
filter: blur(10px);
pointer-events: none;
}
.client-subscription {
@@ -842,6 +884,12 @@ p {
opacity: 0.72;
filter: blur(0.5px);
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
transition: background 500ms ease, box-shadow 500ms ease;
}
.client-subscription-edit.is-invalid::after {
background: oklch(0.68 0.15 28);
box-shadow: 0 0 8px oklch(0.68 0.15 28 / 0.72), 0 0 18px oklch(0.68 0.15 28 / 0.24);
}
.client-subscription.is-timing-out .client-subscription-edit::after {
@@ -911,7 +959,7 @@ p {
pointer-events: none;
}
.client-subscription-edit button {
.client-subscription-submit {
position: absolute;
top: 4px;
right: 0;
@@ -923,6 +971,32 @@ p {
color: var(--client-accent);
font-size: 19px;
font-weight: 700;
cursor: pointer;
transition: color 250ms ease, filter 500ms ease, opacity 250ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-submit:hover:not(:disabled),
.client-subscription-submit:focus-visible {
filter: drop-shadow(0 0 5px var(--client-accent)) drop-shadow(0 0 14px var(--client-accent));
transform: scale(1.14);
}
.client-subscription-submit:disabled {
cursor: default;
}
.client-subscription-edit.is-checking .client-subscription-submit {
color: var(--client-muted);
animation: client-validation-pulse 900ms ease-in-out infinite alternate;
}
.client-subscription-edit.is-invalid .client-subscription-submit {
color: oklch(0.68 0.15 28);
opacity: 0.9;
}
@keyframes client-validation-pulse {
to { opacity: 0.35; }
}
.client-subscription-heading {
@@ -1374,10 +1448,7 @@ p {
}
.harbor-brand {
position: absolute;
top: -4px;
left: 50%;
transform: translateX(-50%);
transform: translate(-50%, calc(-50% - 28vh)) scale(2.15);
}
.client-instructions-toggle {
@@ -1437,12 +1508,22 @@ p {
.client-instruction-summary > i::after,
.client-subscription-edit,
.client-subscription-edit::after,
.client-subscription-submit,
.client-subscription-summary,
.client-subscription-summary strong {
transition: none;
animation: none;
}
.harbor-brand {
transition: none;
animation: none;
}
.harbor-brand-content {
animation: none;
}
.client-state-copy h2,
.client-state-detail > * {
animation: none;