feat: modal windows

This commit is contained in:
2026-08-14 19:49:27 +07:00
parent 3d8b78ec52
commit 76f26430ba
13 changed files with 377 additions and 16 deletions

File diff suppressed because one or more lines are too long

View File

@@ -68,7 +68,7 @@ describe('api client', () => {
access_token: 'a',
refresh_token: 'r',
expires_at: 1,
user: { username: 'u', telegram_id: null, referal_code: 'x' },
user: { username: 'u', telegram_id: null, referal_code: 'x', bonus_balance: 0 },
});
});
await api.login({ provider: 'credentials', username: 'u', password: 'p' });
@@ -102,7 +102,7 @@ describe('api client', () => {
return jsonResponse({ access_token: 'new', refresh_token: 'new-r', expires_at: 9999999999 });
}
if (calls === 1) return jsonResponse({ detail: 'expired' }, 401);
return jsonResponse({ username: 'u', telegram_id: null, referal_code: 'x' });
return jsonResponse({ username: 'u', telegram_id: null, referal_code: 'x', bonus_balance: 0 });
});
const me = await api.getMe();
expect(me.username).toBe('u');

View File

@@ -0,0 +1,111 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
interface Client {
name: string;
href: string;
note?: string;
}
interface Platform {
name: string;
clients: Client[];
}
const PLATFORMS: Platform[] = [
{
name: 'iOS',
clients: [
{ name: 'Incy Client', href: 'https://apps.apple.com/us/app/incy/id6756943388', note: 'Приложение может быть недоступно в РФ, смените регион.' },
],
},
{
name: 'Windows',
clients: [
{ name: 'Happ', href: 'https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x64.exe' },
{
name: 'Throne',
href: 'https://github.com/throneproj/Throne/releases/latest',
note: 'При первом запуске: Настройки -> Подписка -> Разрешить отправку HWID.',
},
],
},
{
name: 'Android',
clients: [
{ name: 'Happ (Google Play)', href: 'https://play.google.com/store/apps/details?id=com.happproxy&pli=1' },
{ name: 'Happ (APK)', href: 'https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk' },
{ name: 'v2raytun', href: 'https://play.google.com/store/apps/details?id=com.v2raytun.android&hl=ru' },
],
},
{
name: 'Linux',
clients: [
{
name: 'Throne',
href: 'https://github.com/throneproj/Throne/releases/latest',
note: 'При первом запуске: Настройки -> Подписка -> Разрешить отправку HWID.',
},
],
},
];
interface ClientLinksModalProps {
open: boolean;
onClose: () => void;
}
export function ClientLinksModal({ open, onClose }: ClientLinksModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [onClose, open]);
if (!open) return null;
return createPortal(
<div
className="modal-veil is-open"
role="dialog"
aria-modal="true"
aria-labelledby="client-links-title"
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="modal client-links-modal" role="document">
<div className="modal-kicker">Подключение устройства</div>
<h3 className="modal-title" id="client-links-title">
Выберите клиент
</h3>
<div className="client-platforms">
{PLATFORMS.map((platform) => (
<section className="client-platform" key={platform.name} aria-label={platform.name}>
<h4>{platform.name}</h4>
{platform.clients.map((client) => (
<div className="client-link" key={client.href}>
<a href={client.href} target="_blank" rel="noreferrer">
{client.name}
</a>
{client.note ? <p>{client.note}</p> : null}
</div>
))}
</section>
))}
</div>
<button type="button" className="btn" onClick={onClose} autoFocus>
Закрыть
</button>
</div>
</div>,
document.body,
);
}

View File

@@ -1,5 +1,5 @@
import type { SubscriptionData, UserInfo } from '../types';
import { formatDate } from '../lib/format';
import { formatDate, formatMoney } from '../lib/format';
interface StatStripProps {
user: UserInfo | null;
@@ -32,6 +32,7 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
const telegramId = user?.telegram_id ?? null;
const devicesLimit = subscription?.devices ?? null;
const expiresAt = subscription?.expires_at ?? null;
const bonusBalance = user?.bonus_balance ?? 0;
return (
<section className="profile" aria-label="Сводка по аккаунту">
@@ -89,6 +90,19 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
>
{expiresAt ? <span>{formatDate(expiresAt)}</span> : <span className="is-empty">—</span>}
</Stat>
<Stat
icon={
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="8.5" />
<path d="M9 9.5c.4-1 1.5-1.6 3-1.6 1.7 0 3 .9 3 2.3 0 3.3-5.8 1.7-5.8 4.7 0 1.4 1.3 2.3 3 2.3 1.5 0 2.7-.6 3.2-1.6M12 6.2v11.6" />
</svg>
}
label="Бонусный баланс"
>
<span>{formatMoney(bonusBalance)}</span>
<span className="stat-note">только на подписку</span>
</Stat>
</section>
);
}

View File

@@ -0,0 +1,77 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import QRCode from 'qrcode';
interface SubscriptionQrModalProps {
link: string | null;
label?: string;
imageAlt?: string;
onClose: () => void;
}
export function SubscriptionQrModal({
link,
label = 'Ссылка подписки',
imageAlt = 'QR-код ссылки подписки',
onClose,
}: SubscriptionQrModalProps) {
const [image, setImage] = useState<string | null>(null);
useEffect(() => {
if (!link) return;
let active = true;
void QRCode.toDataURL(link, {
errorCorrectionLevel: 'M',
margin: 2,
width: 280,
color: { dark: '#1a1610', light: '#faf7f2' },
})
.then((dataUrl) => {
if (active) setImage(dataUrl);
})
.catch(() => {
if (active) setImage(null);
});
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
active = false;
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [link, onClose]);
if (!link) return null;
return createPortal(
<div
className="modal-veil is-open"
role="dialog"
aria-modal="true"
aria-labelledby="subscription-qr-title"
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="modal subscription-qr-modal" role="document">
<div className="modal-kicker">{label}</div>
<h3 className="modal-title" id="subscription-qr-title">
Откройте на устройстве
</h3>
<p className="modal-body">Отсканируйте код камерой или приложением Malenia.</p>
<div className="subscription-qr-image">
{image ? <img src={image} alt={imageAlt} /> : <span>Генерируем QR-код</span>}
</div>
<button type="button" className="btn" onClick={onClose} autoFocus>
Закрыть
</button>
</div>
</div>,
document.body,
);
}

20
src/lib/referral.ts Normal file
View File

@@ -0,0 +1,20 @@
export const REFERRAL_STORAGE_KEY = 'malenia.referral_code';
export function getReferralCode(): string | null {
try {
return localStorage.getItem(REFERRAL_STORAGE_KEY);
} catch {
return null;
}
}
export function saveReferralCode(code: string): void {
const normalized = code.trim();
if (!normalized) return;
try {
localStorage.setItem(REFERRAL_STORAGE_KEY, normalized);
} catch {
// Registration can continue when storage is unavailable.
}
}

View File

@@ -36,7 +36,7 @@ function renderAccount(authed = true) {
access: 'a',
refresh: 'r',
expiresAt: 9999999999,
user: { username: 'alice', telegram_id: 'alice_id', referal_code: 'c' },
user: { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 },
remember: true,
});
} else {
@@ -59,7 +59,7 @@ function renderAccount(authed = true) {
);
}
const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c' };
const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 };
const SUB = {
devices: 6,
expires_at: '2026-03-14T00:00:00Z',
@@ -94,6 +94,8 @@ describe('AccountPage', () => {
renderAccount();
expect(await screen.findByText('alice')).toBeInTheDocument();
expect(screen.getByText('@alice_id')).toBeInTheDocument();
expect(screen.getByText(/150\s*₽/)).toBeInTheDocument();
expect(screen.getByText(/только на подписку/i)).toBeInTheDocument();
expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1);
});
@@ -149,6 +151,23 @@ describe('AccountPage', () => {
vi.mocked(api.getDevices).mockResolvedValue([]);
renderAccount();
expect(await screen.findByText(/нет подключённых устройств/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /показать клиенты для подключения/i })).toBeEnabled();
});
it('opens the client links dialog from the empty state', async () => {
vi.mocked(api.getMe).mockResolvedValue(ME);
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
vi.mocked(api.getDevices).mockResolvedValue([]);
const user = userEvent.setup();
renderAccount();
await user.click(await screen.findByRole('button', { name: /показать клиенты для подключения/i }));
expect(await screen.findByRole('dialog', { name: /выберите клиент/i })).toBeInTheDocument();
expect(screen.getAllByRole('link', { name: 'v2raytun' })[0]).toHaveAttribute(
'href',
'https://apps.apple.com/en/app/v2raytun/id6476628951',
);
expect(screen.getAllByText(/разрешить отправку hwid/i)).toHaveLength(2);
});
it('opens confirmation modal and deletes device on confirm', async () => {

View File

@@ -11,6 +11,7 @@ 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 type { Device, SubscriptionData, UserInfo } from '../types';
export function AccountPage() {
@@ -25,6 +26,7 @@ export function AccountPage() {
const [pending, setPending] = useState<Device | null>(null);
const [isQrOpen, setIsQrOpen] = useState(false);
const [isReferralQrOpen, setIsReferralQrOpen] = useState(false);
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
useEffect(() => {
if (!isAuthenticated) {
@@ -114,7 +116,7 @@ export function AccountPage() {
{loadingMeta ? (
<div className="profile">
{Array.from({ length: 4 }).map((_, i) => (
{Array.from({ length: 5 }).map((_, i) => (
<div className="stat" key={i}>
<Skeleton width="34px" height="34px" radius="50%" />
<Skeleton width="80px" height="10px" />
@@ -234,9 +236,14 @@ export function AccountPage() {
</svg>
</div>
<div className="devices-empty-title">Нет подключённых устройств</div>
<div className="devices-empty-sub">
Установите Malenia на устройство и подключите его к этому аккаунту.
</div>
<button
type="button"
className="devices-empty-help"
aria-label="Показать клиенты для подключения"
onClick={() => setIsClientLinksOpen(true)}
>
?
</button>
</div>
) : (
<div className="device-grid">
@@ -278,6 +285,7 @@ export function AccountPage() {
imageAlt="QR-код реферальной ссылки"
onClose={() => setIsReferralQrOpen(false)}
/>
<ClientLinksModal open={isClientLinksOpen} onClose={() => setIsClientLinksOpen(false)} />
</Shell>
);
}

View File

@@ -55,7 +55,7 @@ describe('LoginPage', () => {
access_token: 'a',
refresh_token: 'r',
expires_at: 9999999999,
user: { username: 'alice', telegram_id: null, referal_code: 'c' },
user: { username: 'alice', telegram_id: null, referal_code: 'c', bonus_balance: 0 },
});
renderLogin();
await user.type(screen.getByLabelText('Имя пользователя'), 'alice');
@@ -94,7 +94,7 @@ describe('LoginPage', () => {
access_token: 'a',
refresh_token: 'r',
expires_at: 9999999999,
user: { username: 'alice', telegram_id: null, referal_code: 'new-code' },
user: { username: 'alice', telegram_id: null, referal_code: 'new-code', bonus_balance: 0 },
});
renderLogin();
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));

View File

@@ -93,7 +93,7 @@ describe('PlanPage', () => {
access: 'a',
refresh: 'r',
expiresAt: 9999999999,
user: { username: 'u', telegram_id: null, referal_code: 'c' },
user: { username: 'u', telegram_id: null, referal_code: 'c', bonus_balance: 0 },
remember: true,
});
const user = userEvent.setup();
@@ -120,7 +120,7 @@ describe('PlanPage', () => {
access: 'a',
refresh: 'r',
expiresAt: 9999999999,
user: { username: 'u', telegram_id: null, referal_code: 'c' },
user: { username: 'u', telegram_id: null, referal_code: 'c', bonus_balance: 0 },
remember: true,
});
const user = userEvent.setup();

View File

@@ -272,6 +272,12 @@
}
@media (max-width: 560px) {
.client-links-modal {
padding: 28px 20px 24px;
}
.client-platforms {
grid-template-columns: 1fr;
}
.topbar {
flex-direction: column;
align-items: center;
@@ -431,7 +437,7 @@
/* ── Stat strip (account) ───────────────────────────────── */
.profile {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
border-bottom: 1px solid var(--hairline);
}
.stat {
@@ -497,6 +503,47 @@
color: var(--muted);
letter-spacing: 0.04em;
}
.stat-value .stat-note {
display: block;
margin-top: 5px;
font-family: var(--font-body);
font-size: 10px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
@media (max-width: 860px) {
.profile {
grid-template-columns: repeat(3, 1fr);
}
.stat:nth-child(3) {
border-right: none;
}
.stat:nth-child(4) {
border-right: 1px solid var(--hairline);
}
}
@media (max-width: 560px) {
.profile {
grid-template-columns: repeat(2, 1fr);
}
.stat:nth-child(2n) {
border-right: none;
}
.stat:nth-child(3),
.stat:nth-child(4) {
border-right: 1px solid var(--hairline);
}
.stat:nth-child(4) {
border-right: none;
}
.stat:last-child {
grid-column: span 2;
}
}
/* ── Subscription link (account) ───────────────────────── */
.subscription-link {
@@ -608,6 +655,52 @@
text-transform: uppercase;
color: var(--muted);
}
.client-links-modal {
max-width: 560px;
text-align: left;
}
.client-links-modal .modal-kicker,
.client-links-modal .modal-title {
text-align: center;
}
.client-platforms {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1px;
margin: 0 -8px 26px;
border: 1px solid var(--hairline);
background: var(--hairline);
}
.client-platform {
padding: 18px;
background: var(--surface);
}
.client-platform h4 {
margin-bottom: 12px;
font-family: var(--font-display);
font-size: 19px;
font-weight: 500;
}
.client-link + .client-link {
margin-top: 10px;
}
.client-link a {
color: var(--fg);
font-size: 12px;
letter-spacing: 0.06em;
text-decoration: underline;
text-decoration-color: var(--accent);
text-underline-offset: 3px;
}
.client-link a:hover {
color: var(--accent-dim);
}
.client-link p {
margin-top: 5px;
color: var(--muted);
font-size: 11px;
line-height: 1.45;
}
/* ── Device grid (account) ──────────────────────────────── */
.devices-head {
@@ -754,6 +847,24 @@
color: var(--muted);
letter-spacing: 0.02em;
}
.devices-empty-help {
width: 28px;
height: 28px;
margin-top: 16px;
border: 1px solid var(--hairline-strong);
border-radius: 50%;
background: transparent;
color: var(--muted);
font-family: var(--font-display);
font-size: 18px;
line-height: 1;
transition: background 0.25s, border-color 0.25s, color 0.25s;
}
.devices-empty-help:hover {
border-color: var(--accent);
background: var(--accent);
color: var(--fg);
}
/* ── Plan slider ────────────────────────────────────────── */
.slider-wrap {

View File

@@ -4,6 +4,7 @@ export interface UserInfo {
username: string | null;
telegram_id: string | null;
referal_code: string;
bonus_balance: number;
}
export interface LoginResponse {

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/lib/referral.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/ClientLinksModal.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/lib/referral.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"}