This commit is contained in:
2026-08-10 12:01:03 +07:00
commit 265b1202ad
95 changed files with 27718 additions and 0 deletions

215
src/pages/AccountPage.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, ApiError } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import { useDevices } from '../hooks/useDevices';
import { useToastStore } from '../store/useToast';
import { Shell } from '../components/Shell';
import { Reveal } from '../components/Reveal';
import { Skeleton } from '../components/Skeleton';
import { StatStrip } from '../components/StatStrip';
import { DeviceCard } from '../components/DeviceCard';
import { Modal } from '../components/Modal';
import type { Device, SubscriptionData, UserInfo } from '../types';
export function AccountPage() {
const navigate = useNavigate();
const { user, isAuthenticated, clear } = useAuth();
const showToast = useToastStore((s) => s.show);
const { data: devices, loading: devicesLoading, remove } = useDevices();
const [userInfo, setUserInfo] = useState<UserInfo | null>(user);
const [subscription, setSubscription] = useState<SubscriptionData | null>(null);
const [loadingMeta, setLoadingMeta] = useState(true);
const [pending, setPending] = useState<Device | null>(null);
useEffect(() => {
if (!isAuthenticated) {
navigate('/login', { replace: true, state: { returnTo: '/account' } });
}
}, [isAuthenticated, navigate]);
const loadMeta = useCallback(async () => {
setLoadingMeta(true);
try {
const [me, sub] = await Promise.all([api.getMe(), api.getSubscription()]);
setUserInfo(me);
setSubscription(sub);
} catch (err: unknown) {
const message = err instanceof ApiError ? err.message : 'Не удалось загрузить аккаунт';
showToast(message);
if (err instanceof ApiError && err.status === 401) clear();
} finally {
setLoadingMeta(false);
}
}, [clear, showToast]);
useEffect(() => {
if (!isAuthenticated) return;
void loadMeta();
}, [isAuthenticated, loadMeta]);
const handleDelete = (device: Device) => setPending(device);
const copySubscriptionLink = async () => {
const link = subscription?.subscription_link;
if (!link) return;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(link);
} else {
const input = document.createElement('textarea');
input.value = link;
input.style.position = 'fixed';
input.style.opacity = '0';
document.body.appendChild(input);
input.select();
document.execCommand('copy');
input.remove();
}
showToast('Ссылка подписки скопирована');
} catch {
showToast('Не удалось скопировать ссылку');
}
};
const confirmDelete = async () => {
if (!pending) return;
const ok = await remove(pending.hwid);
setPending(null);
if (ok) {
showToast(`${pending.model ?? 'Устройство'} отключено`);
} else {
showToast('Не удалось удалить устройство');
}
};
const signOut = () => {
clear();
showToast('Сессия завершена');
navigate('/', { replace: true });
};
return (
<Shell active="account">
<section className="account-hero">
<Reveal>
<div className="kicker">Ваш аккаунт</div>
<h1 className="hero-title">{userInfo?.username ?? 'Аккаунт'}</h1>
{!loadingMeta ? (
<p className="hero-sub">
{subscription?.has_subscription && subscription.expires_at
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
: 'Активная подписка отсутствует'}
</p>
) : null}
</Reveal>
</section>
{loadingMeta ? (
<div className="profile">
{Array.from({ length: 4 }).map((_, i) => (
<div className="stat" key={i}>
<Skeleton width="34px" height="34px" radius="50%" />
<Skeleton width="80px" height="10px" />
<Skeleton width="120px" height="26px" />
</div>
))}
</div>
) : (
<Reveal>
<StatStrip user={userInfo} subscription={subscription} deviceCount={devices.length} />
</Reveal>
)}
{!loadingMeta && subscription?.subscription_link ? (
<Reveal>
<section className="subscription-link" aria-label="Ссылка подписки">
<div className="subscription-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1L11 5.1" />
<path d="M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1" />
</svg>
</div>
<div className="subscription-copy">
<div className="subscription-label">Ссылка подписки</div>
<span className="subscription-url" title={subscription.subscription_link}>
{subscription.subscription_link}
</span>
</div>
<button type="button" className="copy-link" onClick={() => void copySubscriptionLink()}>
Копировать
</button>
</section>
</Reveal>
) : null}
<section>
<Reveal>
<div className="devices-head">
<h2 className="devices-title">Подключённые устройства</h2>
<div className="devices-count">
<strong>{devices.length}</strong>активно
</div>
</div>
</Reveal>
{devicesLoading ? (
<div className="device-grid">
{Array.from({ length: 2 }).map((_, i) => (
<div className="device" key={i}>
<Skeleton width="48px" height="48px" />
<div style={{ flex: 1 }}>
<Skeleton width="70%" height="20px" />
<Skeleton width="50%" height="12px" style={{ marginTop: 8 }} />
</div>
<Skeleton width="36px" height="36px" radius="50%" />
</div>
))}
</div>
) : devices.length === 0 ? (
<div className="devices-empty">
<div className="devices-empty-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="13" rx="1.5" />
<path d="M1 20h22M9 20l1-3h4l1 3" />
</svg>
</div>
<div className="devices-empty-title">Нет подключённых устройств</div>
<div className="devices-empty-sub">
Установите Malenia на устройство и подключите его к этому аккаунту.
</div>
</div>
) : (
<div className="device-grid">
{devices.map((d) => (
<DeviceCard key={d.hwid} device={d} onDelete={handleDelete} />
))}
</div>
)}
</section>
<footer className="account-foot">
<span>MaleniaVPN</span>
<button type="button" onClick={signOut}>
Выйти
</button>
</footer>
<Modal
open={pending !== null}
onClose={() => setPending(null)}
kicker="Подтверждение"
title="Удалить это устройство?"
body={
<>
Отключение <strong>{pending?.model ?? 'устройства'}</strong> немедленно завершит его
VPN-сессию. Вы сможете подключить его снова в любой момент.
</>
}
confirmLabel="Удалить"
onConfirm={confirmDelete}
/>
</Shell>
);
}