From 0a1aa8aed38b548b23341c34e95bd79a61ddefb7 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 14:13:39 +0300 Subject: [PATCH] Add subscription validation and first-run onboarding reveal --- src/server/index.js | 6 ++ src/web/App.jsx | 7 +- src/web/api.js | 5 + src/web/components/ClientOverviewPage.jsx | 113 +++++++++++++++++++--- src/web/styles.css | 103 +++++++++++++++++--- 5 files changed, 205 insertions(+), 29 deletions(-) diff --git a/src/server/index.js b/src/server/index.js index b5b03f1..917e241 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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()); } diff --git a/src/web/App.jsx b/src/web/App.jsx index fa7b383..0878343 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -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() {
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 }), diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 6afd5c9..e37e597 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -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 (
- - Harbor {product} +
+ + Harbor {product} +
); } @@ -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 ( -
+
- {hasSubscription && + {normalizedSubscriptionUrl && ( + )}
- {hasSubscription && hasUsage && ( + {hasSubscription && subscriptionContentReady && hasUsage && (
Использовано @@ -582,7 +665,7 @@ export function ClientOverviewPage({
)} - {hasSubscription && ( + {hasSubscription && subscriptionContentReady && (
{!showPower && Выберите сервер}
- {hasSubscription &&