351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
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 { SubscriptionQrModal } from '../components/SubscriptionQrModal';
|
||
import { ClientLinksModal } from '../components/ClientLinksModal';
|
||
import { formatMoney } from '../lib/format';
|
||
import { redirectTo } from '../lib/redirect';
|
||
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);
|
||
const [isQrOpen, setIsQrOpen] = useState(false);
|
||
const [isReferralQrOpen, setIsReferralQrOpen] = useState(false);
|
||
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
|
||
const [isRenewalOpen, setIsRenewalOpen] = useState(false);
|
||
const [isRenewing, setIsRenewing] = useState(false);
|
||
|
||
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 referralLink = userInfo?.referal_code
|
||
? `${window.location.origin}/?ref=${encodeURIComponent(userInfo.referal_code)}`
|
||
: null;
|
||
|
||
const copyLink = async (link: string | null, label: string) => {
|
||
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(`${label} скопирована`);
|
||
} 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 });
|
||
};
|
||
|
||
const openPlanWithCurrentConfig = () => {
|
||
if (!subscription) return;
|
||
const params = new URLSearchParams();
|
||
if (subscription.devices) params.set('devices', String(subscription.devices));
|
||
if (subscription.duration_days) params.set('duration', String(subscription.duration_days));
|
||
if (subscription.addon_ids.length > 0) params.set('addons', subscription.addon_ids.join(','));
|
||
navigate(`/plan?${params.toString()}`);
|
||
};
|
||
|
||
const renewCurrentPlan = async () => {
|
||
if (!subscription?.devices || !subscription.duration_days) {
|
||
openPlanWithCurrentConfig();
|
||
return;
|
||
}
|
||
setIsRenewing(true);
|
||
try {
|
||
const result = await api.checkout({
|
||
devices: subscription.devices,
|
||
addons: subscription.addon_ids,
|
||
duration_days: subscription.duration_days,
|
||
});
|
||
if (result.payment_link) {
|
||
redirectTo(result.payment_link);
|
||
return;
|
||
}
|
||
setIsRenewalOpen(false);
|
||
showToast(`Заказ ${result.order_id} оформлен. К оплате: ${formatMoney(result.amount_to_pay)}`);
|
||
} catch (err: unknown) {
|
||
const message = err instanceof ApiError ? err.message : 'Не удалось оформить продление';
|
||
showToast(message);
|
||
} finally {
|
||
setIsRenewing(false);
|
||
}
|
||
};
|
||
|
||
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>
|
||
{subscription?.has_subscription ? (
|
||
<button type="button" className="btn btn-primary account-renew" onClick={() => setIsRenewalOpen(true)}>
|
||
Продлить
|
||
</button>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</Reveal>
|
||
</section>
|
||
|
||
{loadingMeta ? (
|
||
<div className="profile">
|
||
{Array.from({ length: 5 }).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>
|
||
<div className="subscription-actions">
|
||
<button
|
||
type="button"
|
||
className="subscription-qr-button"
|
||
aria-label="Показать QR-код ссылки подписки"
|
||
onClick={() => setIsQrOpen(true)}
|
||
>
|
||
QR
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="copy-link"
|
||
aria-label="Копировать ссылку подписки"
|
||
onClick={() => void copyLink(subscription.subscription_link, 'Ссылка подписки')}
|
||
>
|
||
Копировать
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</Reveal>
|
||
) : null}
|
||
|
||
{!loadingMeta && referralLink ? (
|
||
<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">
|
||
<circle cx="12" cy="8" r="3" />
|
||
<path d="M6 20c.7-3.1 2.7-5 6-5s5.3 1.9 6 5M19 8h3M20.5 6.5v3" />
|
||
</svg>
|
||
</div>
|
||
<div className="subscription-copy">
|
||
<div className="subscription-label">Реферальная ссылка</div>
|
||
<span className="subscription-url" title={referralLink}>
|
||
{referralLink}
|
||
</span>
|
||
</div>
|
||
<div className="subscription-actions">
|
||
<button
|
||
type="button"
|
||
className="subscription-qr-button"
|
||
aria-label="Показать QR-код реферальной ссылки"
|
||
onClick={() => setIsReferralQrOpen(true)}
|
||
>
|
||
QR
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="copy-link"
|
||
aria-label="Копировать реферальную ссылку"
|
||
onClick={() => void copyLink(referralLink, 'Реферальная ссылка')}
|
||
>
|
||
Копировать
|
||
</button>
|
||
</div>
|
||
</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>
|
||
<button
|
||
type="button"
|
||
className="devices-empty-help"
|
||
aria-label="Показать клиенты для подключения"
|
||
onClick={() => setIsClientLinksOpen(true)}
|
||
>
|
||
?
|
||
</button>
|
||
</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}
|
||
/>
|
||
<Modal
|
||
open={isRenewalOpen}
|
||
onClose={() => setIsRenewalOpen(false)}
|
||
kicker="Продление"
|
||
title="Продлить с текущими параметрами?"
|
||
body="Мы повторим число устройств, дополнения и срок вашей текущей подписки."
|
||
confirmLabel={isRenewing ? 'Оформление…' : 'Да, продлить'}
|
||
cancelLabel="Нет, настроить"
|
||
confirmClassName="btn-primary"
|
||
pending={isRenewing}
|
||
onCancel={openPlanWithCurrentConfig}
|
||
onConfirm={() => void renewCurrentPlan()}
|
||
/>
|
||
<SubscriptionQrModal
|
||
link={isQrOpen ? subscription?.subscription_link ?? null : null}
|
||
onClose={() => setIsQrOpen(false)}
|
||
/>
|
||
<SubscriptionQrModal
|
||
link={isReferralQrOpen ? referralLink : null}
|
||
label="Реферальная ссылка"
|
||
imageAlt="QR-код реферальной ссылки"
|
||
onClose={() => setIsReferralQrOpen(false)}
|
||
/>
|
||
<ClientLinksModal open={isClientLinksOpen} onClose={() => setIsClientLinksOpen(false)} />
|
||
</Shell>
|
||
);
|
||
}
|