Add subscription validation and first-run onboarding reveal
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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' : ''}`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user