Files
malenia-frontend/src/pages/AccountPage.tsx
2026-08-17 21:34:44 +07:00

351 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}