Compare commits
2 Commits
42f168e1ac
...
76f26430ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 76f26430ba | |||
| 3d8b78ec52 |
File diff suppressed because one or more lines are too long
@@ -68,7 +68,7 @@ describe('api client', () => {
|
|||||||
access_token: 'a',
|
access_token: 'a',
|
||||||
refresh_token: 'r',
|
refresh_token: 'r',
|
||||||
expires_at: 1,
|
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' });
|
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 });
|
return jsonResponse({ access_token: 'new', refresh_token: 'new-r', expires_at: 9999999999 });
|
||||||
}
|
}
|
||||||
if (calls === 1) return jsonResponse({ detail: 'expired' }, 401);
|
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();
|
const me = await api.getMe();
|
||||||
expect(me.username).toBe('u');
|
expect(me.username).toBe('u');
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { AddonData } from '../types';
|
import type { AddonData } from '../types';
|
||||||
import { formatMoney } from '../lib/format';
|
import { addonPriceForDevices, formatMoney } from '../lib/format';
|
||||||
|
|
||||||
interface AddonListProps {
|
interface AddonListProps {
|
||||||
addons: AddonData[];
|
addons: AddonData[];
|
||||||
|
devices: number;
|
||||||
selected: string[];
|
selected: string[];
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddonList({ addons, selected, onToggle }: AddonListProps) {
|
export function AddonList({ addons, devices, selected, onToggle }: AddonListProps) {
|
||||||
if (addons.length === 0) return null;
|
if (addons.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="plan-addons">
|
<div className="plan-addons">
|
||||||
@@ -18,6 +19,7 @@ export function AddonList({ addons, selected, onToggle }: AddonListProps) {
|
|||||||
<div className="addon-list">
|
<div className="addon-list">
|
||||||
{addons.map((a) => {
|
{addons.map((a) => {
|
||||||
const on = selected.includes(a.id);
|
const on = selected.includes(a.id);
|
||||||
|
const price = addonPriceForDevices(a, devices);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@@ -31,8 +33,7 @@ export function AddonList({ addons, selected, onToggle }: AddonListProps) {
|
|||||||
<span className="addon-name">{a.name}</span>
|
<span className="addon-name">{a.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="addon-price">
|
<span className="addon-price">
|
||||||
<span className="plus">+</span>
|
{price === 0 ? 'Бесплатно' : <><span className="plus">+</span>{formatMoney(price)}</>}
|
||||||
{formatMoney(a.price)}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
111
src/components/ClientLinksModal.tsx
Normal file
111
src/components/ClientLinksModal.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SubscriptionData, UserInfo } from '../types';
|
import type { SubscriptionData, UserInfo } from '../types';
|
||||||
import { formatDate } from '../lib/format';
|
import { formatDate, formatMoney } from '../lib/format';
|
||||||
|
|
||||||
interface StatStripProps {
|
interface StatStripProps {
|
||||||
user: UserInfo | null;
|
user: UserInfo | null;
|
||||||
@@ -32,6 +32,7 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
|||||||
const telegramId = user?.telegram_id ?? null;
|
const telegramId = user?.telegram_id ?? null;
|
||||||
const devicesLimit = subscription?.devices ?? null;
|
const devicesLimit = subscription?.devices ?? null;
|
||||||
const expiresAt = subscription?.expires_at ?? null;
|
const expiresAt = subscription?.expires_at ?? null;
|
||||||
|
const bonusBalance = user?.bonus_balance ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="profile" aria-label="Сводка по аккаунту">
|
<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>}
|
{expiresAt ? <span>{formatDate(expiresAt)}</span> : <span className="is-empty">—</span>}
|
||||||
</Stat>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/components/SubscriptionQrModal.tsx
Normal file
77
src/components/SubscriptionQrModal.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ import type { AddonData, PricingPlans } from '../types';
|
|||||||
const pricing: PricingPlans = {
|
const pricing: PricingPlans = {
|
||||||
device_price: 100,
|
device_price: 100,
|
||||||
addons: [
|
addons: [
|
||||||
{ id: 'a', name: 'A', price: 50, free_threshold: 0 },
|
{ id: 'a', name: 'A', price: 50, free_threshold: -1 },
|
||||||
{ id: 'b', name: 'B', price: 30, free_threshold: 0 },
|
{ id: 'b', name: 'B', price: 30, free_threshold: -1 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,10 +30,20 @@ describe('round2', () => {
|
|||||||
describe('sumAddonPrices', () => {
|
describe('sumAddonPrices', () => {
|
||||||
const addons: AddonData[] = pricing.addons;
|
const addons: AddonData[] = pricing.addons;
|
||||||
it('sums selected addon prices', () => {
|
it('sums selected addon prices', () => {
|
||||||
expect(sumAddonPrices(addons, ['a', 'b'])).toBe(80);
|
expect(sumAddonPrices(addons, ['a', 'b'], 6)).toBe(80);
|
||||||
expect(sumAddonPrices(addons, ['a'])).toBe(50);
|
expect(sumAddonPrices(addons, ['a'], 6)).toBe(50);
|
||||||
expect(sumAddonPrices(addons, ['unknown'])).toBe(0);
|
expect(sumAddonPrices(addons, ['unknown'], 6)).toBe(0);
|
||||||
expect(sumAddonPrices(addons, [])).toBe(0);
|
expect(sumAddonPrices(addons, [], 6)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes addons free at their device threshold but keeps -1 paid', () => {
|
||||||
|
const thresholdAddons: AddonData[] = [
|
||||||
|
{ id: 'threshold', name: 'Threshold', price: 50, free_threshold: 6 },
|
||||||
|
{ id: 'paid', name: 'Paid', price: 30, free_threshold: -1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(sumAddonPrices(thresholdAddons, ['threshold', 'paid'], 5)).toBe(80);
|
||||||
|
expect(sumAddonPrices(thresholdAddons, ['threshold', 'paid'], 6)).toBe(30);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export interface PricingBreakdown {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors the backend `services/subscriptions.py`:
|
* Mirrors the backend `services/subscriptions.py`:
|
||||||
* monthly = devices * device_price + sum(selected addon prices)
|
* monthly = devices * device_price + sum(selected applicable addon prices)
|
||||||
* total = monthly * duration_days / 30
|
* total = monthly * duration_days / 30
|
||||||
*/
|
*/
|
||||||
export function calculatePricing({
|
export function calculatePricing({
|
||||||
@@ -65,7 +65,7 @@ export function calculatePricing({
|
|||||||
duration,
|
duration,
|
||||||
}: PricingInput): PricingBreakdown {
|
}: PricingInput): PricingBreakdown {
|
||||||
const deviceCost = pricing.device_price * devices;
|
const deviceCost = pricing.device_price * devices;
|
||||||
const addonCost = sumAddonPrices(pricing.addons, addonIds);
|
const addonCost = sumAddonPrices(pricing.addons, addonIds, devices);
|
||||||
const monthly = deviceCost + addonCost;
|
const monthly = deviceCost + addonCost;
|
||||||
const periodTotal = round2((monthly * duration.days) / 30);
|
const periodTotal = round2((monthly * duration.days) / 30);
|
||||||
return {
|
return {
|
||||||
@@ -76,9 +76,16 @@ export function calculatePricing({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sumAddonPrices(addons: AddonData[], selected: string[]): number {
|
export function addonPriceForDevices(addon: AddonData, devices: number): number {
|
||||||
const priceById = new Map(addons.map((a) => [a.id, a.price] as const));
|
return addon.free_threshold >= 0 && devices >= addon.free_threshold ? 0 : addon.price;
|
||||||
return selected.reduce<number>((sum, id) => sum + (priceById.get(id) ?? 0), 0);
|
}
|
||||||
|
|
||||||
|
export function sumAddonPrices(addons: AddonData[], selected: string[], devices: number): number {
|
||||||
|
const addonById = new Map(addons.map((addon) => [addon.id, addon] as const));
|
||||||
|
return selected.reduce<number>((sum, id) => {
|
||||||
|
const addon = addonById.get(id);
|
||||||
|
return sum + (addon ? addonPriceForDevices(addon, devices) : 0);
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Date ────────────────────────────────────────────────── */
|
/* ── Date ────────────────────────────────────────────────── */
|
||||||
|
|||||||
20
src/lib/referral.ts
Normal file
20
src/lib/referral.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ function renderAccount(authed = true) {
|
|||||||
access: 'a',
|
access: 'a',
|
||||||
refresh: 'r',
|
refresh: 'r',
|
||||||
expiresAt: 9999999999,
|
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,
|
remember: true,
|
||||||
});
|
});
|
||||||
} else {
|
} 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 = {
|
const SUB = {
|
||||||
devices: 6,
|
devices: 6,
|
||||||
expires_at: '2026-03-14T00:00:00Z',
|
expires_at: '2026-03-14T00:00:00Z',
|
||||||
@@ -94,6 +94,8 @@ describe('AccountPage', () => {
|
|||||||
renderAccount();
|
renderAccount();
|
||||||
expect(await screen.findByText('alice')).toBeInTheDocument();
|
expect(await screen.findByText('alice')).toBeInTheDocument();
|
||||||
expect(screen.getByText('@alice_id')).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);
|
expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,6 +151,23 @@ describe('AccountPage', () => {
|
|||||||
vi.mocked(api.getDevices).mockResolvedValue([]);
|
vi.mocked(api.getDevices).mockResolvedValue([]);
|
||||||
renderAccount();
|
renderAccount();
|
||||||
expect(await screen.findByText(/нет подключённых устройств/i)).toBeInTheDocument();
|
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 () => {
|
it('opens confirmation modal and deletes device on confirm', async () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { StatStrip } from '../components/StatStrip';
|
|||||||
import { DeviceCard } from '../components/DeviceCard';
|
import { DeviceCard } from '../components/DeviceCard';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { SubscriptionQrModal } from '../components/SubscriptionQrModal';
|
import { SubscriptionQrModal } from '../components/SubscriptionQrModal';
|
||||||
|
import { ClientLinksModal } from '../components/ClientLinksModal';
|
||||||
import type { Device, SubscriptionData, UserInfo } from '../types';
|
import type { Device, SubscriptionData, UserInfo } from '../types';
|
||||||
|
|
||||||
export function AccountPage() {
|
export function AccountPage() {
|
||||||
@@ -25,6 +26,7 @@ export function AccountPage() {
|
|||||||
const [pending, setPending] = useState<Device | null>(null);
|
const [pending, setPending] = useState<Device | null>(null);
|
||||||
const [isQrOpen, setIsQrOpen] = useState(false);
|
const [isQrOpen, setIsQrOpen] = useState(false);
|
||||||
const [isReferralQrOpen, setIsReferralQrOpen] = useState(false);
|
const [isReferralQrOpen, setIsReferralQrOpen] = useState(false);
|
||||||
|
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -114,7 +116,7 @@ export function AccountPage() {
|
|||||||
|
|
||||||
{loadingMeta ? (
|
{loadingMeta ? (
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div className="stat" key={i}>
|
<div className="stat" key={i}>
|
||||||
<Skeleton width="34px" height="34px" radius="50%" />
|
<Skeleton width="34px" height="34px" radius="50%" />
|
||||||
<Skeleton width="80px" height="10px" />
|
<Skeleton width="80px" height="10px" />
|
||||||
@@ -234,9 +236,14 @@ export function AccountPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="devices-empty-title">Нет подключённых устройств</div>
|
<div className="devices-empty-title">Нет подключённых устройств</div>
|
||||||
<div className="devices-empty-sub">
|
<button
|
||||||
Установите Malenia на устройство и подключите его к этому аккаунту.
|
type="button"
|
||||||
</div>
|
className="devices-empty-help"
|
||||||
|
aria-label="Показать клиенты для подключения"
|
||||||
|
onClick={() => setIsClientLinksOpen(true)}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="device-grid">
|
<div className="device-grid">
|
||||||
@@ -278,6 +285,7 @@ export function AccountPage() {
|
|||||||
imageAlt="QR-код реферальной ссылки"
|
imageAlt="QR-код реферальной ссылки"
|
||||||
onClose={() => setIsReferralQrOpen(false)}
|
onClose={() => setIsReferralQrOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<ClientLinksModal open={isClientLinksOpen} onClose={() => setIsClientLinksOpen(false)} />
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ describe('LoginPage', () => {
|
|||||||
access_token: 'a',
|
access_token: 'a',
|
||||||
refresh_token: 'r',
|
refresh_token: 'r',
|
||||||
expires_at: 9999999999,
|
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();
|
renderLogin();
|
||||||
await user.type(screen.getByLabelText('Имя пользователя'), 'alice');
|
await user.type(screen.getByLabelText('Имя пользователя'), 'alice');
|
||||||
@@ -94,7 +94,7 @@ describe('LoginPage', () => {
|
|||||||
access_token: 'a',
|
access_token: 'a',
|
||||||
refresh_token: 'r',
|
refresh_token: 'r',
|
||||||
expires_at: 9999999999,
|
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();
|
renderLogin();
|
||||||
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function renderPlan(initialEntries: string[] = ['/plan']) {
|
|||||||
const PLANS = {
|
const PLANS = {
|
||||||
device_price: 100,
|
device_price: 100,
|
||||||
addons: [
|
addons: [
|
||||||
{ id: 'dedicated-ip', name: 'Dedicated IP', price: 50, free_threshold: 0 },
|
{ id: 'dedicated-ip', name: 'Dedicated IP', price: 50, free_threshold: -1 },
|
||||||
{ id: 'multi-hop', name: 'Multi-Hop', price: 30, free_threshold: 0 },
|
{ id: 'multi-hop', name: 'Multi-Hop', price: 30, free_threshold: -1 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ describe('PlanPage', () => {
|
|||||||
access: 'a',
|
access: 'a',
|
||||||
refresh: 'r',
|
refresh: 'r',
|
||||||
expiresAt: 9999999999,
|
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,
|
remember: true,
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -120,7 +120,7 @@ describe('PlanPage', () => {
|
|||||||
access: 'a',
|
access: 'a',
|
||||||
refresh: 'r',
|
refresh: 'r',
|
||||||
expiresAt: 9999999999,
|
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,
|
remember: true,
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export function PlanPage() {
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<AddonList
|
<AddonList
|
||||||
addons={plans.addons}
|
addons={plans.addons}
|
||||||
|
devices={devices}
|
||||||
selected={selectedAddons}
|
selected={selectedAddons}
|
||||||
onToggle={toggleAddon}
|
onToggle={toggleAddon}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -272,6 +272,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
.client-links-modal {
|
||||||
|
padding: 28px 20px 24px;
|
||||||
|
}
|
||||||
|
.client-platforms {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -431,7 +437,7 @@
|
|||||||
/* ── Stat strip (account) ───────────────────────────────── */
|
/* ── Stat strip (account) ───────────────────────────────── */
|
||||||
.profile {
|
.profile {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
border-bottom: 1px solid var(--hairline);
|
border-bottom: 1px solid var(--hairline);
|
||||||
}
|
}
|
||||||
.stat {
|
.stat {
|
||||||
@@ -497,6 +503,47 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
letter-spacing: 0.04em;
|
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 (account) ───────────────────────── */
|
||||||
.subscription-link {
|
.subscription-link {
|
||||||
@@ -608,6 +655,52 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
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) ──────────────────────────────── */
|
/* ── Device grid (account) ──────────────────────────────── */
|
||||||
.devices-head {
|
.devices-head {
|
||||||
@@ -754,6 +847,24 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
letter-spacing: 0.02em;
|
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 ────────────────────────────────────────── */
|
/* ── Plan slider ────────────────────────────────────────── */
|
||||||
.slider-wrap {
|
.slider-wrap {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface UserInfo {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
telegram_id: string | null;
|
telegram_id: string | null;
|
||||||
referal_code: string;
|
referal_code: string;
|
||||||
|
bonus_balance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user