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

@@ -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,11 +78,15 @@ function HarborBrand({ isGateway }) {
return (
<div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
<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" />
</svg>
<span><strong>Harbor</strong> <em>{product}</em></span>
<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 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' : ''}`}