v0.0.01b
This commit is contained in:
44
src/App.tsx
Normal file
44
src/App.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { Cursor } from './components/Cursor';
|
||||
import { Toast } from './components/Toast';
|
||||
import { useAuthStore } from './store/useStore';
|
||||
import { Landing } from './pages/Landing';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { PlanPage } from './pages/PlanPage';
|
||||
import { AccountPage } from './pages/AccountPage';
|
||||
|
||||
function ProtectedRoute() {
|
||||
const access = useAuthStore((s) => s.access);
|
||||
const expiresAt = useAuthStore((s) => s.expiresAt);
|
||||
const location = useLocation();
|
||||
if (!access) {
|
||||
return <Navigate to="/login" replace state={{ returnTo: location.pathname }} />;
|
||||
}
|
||||
if (expiresAt !== null && expiresAt * 1000 < Date.now()) {
|
||||
return <Navigate to="/login" replace state={{ returnTo: location.pathname }} />;
|
||||
}
|
||||
return <AccountPage />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const hydrate = useAuthStore((s) => s.hydrate);
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Cursor />
|
||||
<Toast />
|
||||
<Routes>
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route path="/account" element={<ProtectedRoute />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
146
src/api/client.test.ts
Normal file
146
src/api/client.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { api, ApiError, setStoredTokens, clearStoredTokens } from './client';
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
|
||||
const ACCESS = 'access-token';
|
||||
const REFRESH = 'refresh-token';
|
||||
type FetchMock = ReturnType<typeof vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>>;
|
||||
|
||||
function mockFetch(
|
||||
impl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
||||
): FetchMock {
|
||||
const fn = vi.fn(impl);
|
||||
globalThis.fetch = fn as typeof fetch;
|
||||
return fn;
|
||||
}
|
||||
|
||||
function urlOf(input: RequestInfo | URL): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (input instanceof URL) return input.href;
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
clearStoredTokens();
|
||||
localStorage.clear();
|
||||
useAuthStore.setState({
|
||||
access: null,
|
||||
refresh: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
remember: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('injects Authorization header when token is stored', async () => {
|
||||
setStoredTokens({ access_token: ACCESS, refresh_token: REFRESH, expires_at: 9999999999 });
|
||||
const fetchMock = mockFetch(async () => jsonResponse({ ok: true }));
|
||||
await api.getMe();
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
|
||||
expect(init?.headers).toMatchObject({ Authorization: `Bearer ${ACCESS}` });
|
||||
});
|
||||
|
||||
it('does not inject header on auth:false calls', async () => {
|
||||
const fetchMock = mockFetch(async () =>
|
||||
jsonResponse({ device_price: 100, addons: [] }),
|
||||
);
|
||||
await api.getPlans();
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
|
||||
expect(init?.headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
it('serialises body as JSON for POST', async () => {
|
||||
const fetchMock = mockFetch(async (_input, init) => {
|
||||
expect(init?.body).toBe(JSON.stringify({ provider: 'credentials', username: 'u', password: 'p' }));
|
||||
return jsonResponse({
|
||||
access_token: 'a',
|
||||
refresh_token: 'r',
|
||||
expires_at: 1,
|
||||
user: { username: 'u', telegram_id: null, referal_code: 'x' },
|
||||
});
|
||||
});
|
||||
await api.login({ provider: 'credentials', username: 'u', password: 'p' });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('throws ApiError with backend detail on non-2xx', async () => {
|
||||
mockFetch(async () => jsonResponse({ detail: 'bad credentials' }, 401));
|
||||
await expect(
|
||||
api.login({ provider: 'credentials', username: 'u', password: 'p' }),
|
||||
).rejects.toMatchObject({ message: 'bad credentials', status: 401 });
|
||||
});
|
||||
|
||||
it('maps validation error array to a joined message', async () => {
|
||||
mockFetch(async () =>
|
||||
jsonResponse(
|
||||
{ detail: [{ msg: 'field required' }, { msg: 'too short' }] },
|
||||
422,
|
||||
),
|
||||
);
|
||||
await expect(api.getPlans()).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
it('refreshes once on 401 then retries the original request', async () => {
|
||||
setStoredTokens({ access_token: ACCESS, refresh_token: REFRESH, expires_at: 9999999999 });
|
||||
let calls = 0;
|
||||
mockFetch(async (input) => {
|
||||
calls++;
|
||||
const url = urlOf(input);
|
||||
if (url.startsWith('/auth/refresh')) {
|
||||
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' });
|
||||
});
|
||||
const me = await api.getMe();
|
||||
expect(me.username).toBe('u');
|
||||
expect(localStorage.getItem('malenia.access')).toBe('new');
|
||||
});
|
||||
|
||||
it('clears tokens and rejects when refresh fails on 401', async () => {
|
||||
setStoredTokens({ access_token: ACCESS, refresh_token: REFRESH, expires_at: 9999999999 });
|
||||
mockFetch(async (input) => {
|
||||
const url = urlOf(input);
|
||||
if (url.startsWith('/auth/refresh')) return jsonResponse({ detail: 'invalid' }, 401);
|
||||
return jsonResponse({ detail: 'expired' }, 401);
|
||||
});
|
||||
await expect(api.getMe()).rejects.toMatchObject({ status: 401 });
|
||||
expect(localStorage.getItem('malenia.access')).toBeNull();
|
||||
});
|
||||
|
||||
it('checkout sends DELETE-style? no — POST body with order details', async () => {
|
||||
const fetchMock = mockFetch(async () =>
|
||||
jsonResponse({
|
||||
order_id: 'ord-1',
|
||||
total_amount: 100,
|
||||
bonus_paid: 0,
|
||||
amount_to_pay: 100,
|
||||
payment_link: 'https://pay',
|
||||
}, 201),
|
||||
);
|
||||
const res = await api.checkout({ devices: 3, addons: [], duration_days: 30 });
|
||||
expect(res.order_id).toBe('ord-1');
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('deleteDevice uses DELETE method and encodes hwid', async () => {
|
||||
const fetchMock = mockFetch(async () => jsonResponse({ success: true }));
|
||||
await api.deleteDevice('hw id/123');
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
|
||||
expect(init?.method).toBe('DELETE');
|
||||
const url = fetchMock.mock.calls[0]?.[0] as string;
|
||||
expect(url).toContain(encodeURIComponent('hw id/123'));
|
||||
});
|
||||
});
|
||||
184
src/api/client.ts
Normal file
184
src/api/client.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
import type {
|
||||
CheckoutResponse,
|
||||
Device,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OperationResult,
|
||||
OrderDetails,
|
||||
PricingPlans,
|
||||
SignupPayload,
|
||||
SubscriptionData,
|
||||
Tokens,
|
||||
UserInfo,
|
||||
} from '../types';
|
||||
|
||||
const ACCESS_KEY = 'malenia.access';
|
||||
const REFRESH_KEY = 'malenia.refresh';
|
||||
const EXPIRES_KEY = 'malenia.expires_at';
|
||||
|
||||
/* ── Token storage helpers (mirror of store, used by client) ── */
|
||||
function readToken(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredTokens(tokens: Tokens): void {
|
||||
try {
|
||||
localStorage.setItem(ACCESS_KEY, tokens.access_token);
|
||||
localStorage.setItem(REFRESH_KEY, tokens.refresh_token);
|
||||
localStorage.setItem(EXPIRES_KEY, String(tokens.expires_at));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredTokens(): void {
|
||||
try {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
localStorage.removeItem(EXPIRES_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
return readToken(REFRESH_KEY);
|
||||
}
|
||||
|
||||
/* ── RequestError ─────────────────────────────────────────── */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(res: Response): Promise<ApiError> {
|
||||
let detail = `Запрос не выполнен (${res.status})`;
|
||||
try {
|
||||
const data = (await res.json()) as unknown;
|
||||
if (Array.isArray((data as { detail?: unknown }).detail)) {
|
||||
const arr = (data as { detail: { msg?: string }[] }).detail;
|
||||
detail = arr.map((e) => e.msg ?? '').filter(Boolean).join('; ') || detail;
|
||||
} else if (typeof (data as { detail?: unknown }).detail === 'string') {
|
||||
detail = (data as { detail: string }).detail;
|
||||
} else if (typeof data === 'string' && data.length > 0) {
|
||||
detail = data;
|
||||
}
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
return new ApiError(detail, res.status);
|
||||
}
|
||||
|
||||
let refreshing: Promise<boolean> | null = null;
|
||||
|
||||
async function refreshTokens(): Promise<boolean> {
|
||||
if (refreshing) return refreshing;
|
||||
const refresh = getRefreshToken();
|
||||
if (!refresh) return false;
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/auth/refresh?refresh_token=${encodeURIComponent(refresh)}&iss=credentials`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const tokens = (await res.json()) as Tokens;
|
||||
setStoredTokens(tokens);
|
||||
useAuthStore.getState().setTokens(tokens);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
refreshing = null;
|
||||
}
|
||||
})();
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
auth?: boolean;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, signal, auth = true } = opts;
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (auth) {
|
||||
const access = readToken(ACCESS_KEY);
|
||||
if (access) headers.Authorization = `Bearer ${access}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
let res = await fetch(path, {
|
||||
method,
|
||||
headers: buildHeaders(),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (res.status === 401 && auth) {
|
||||
const ok = await refreshTokens();
|
||||
if (ok) {
|
||||
res = await fetch(path, {
|
||||
method,
|
||||
headers: buildHeaders(),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
} else {
|
||||
useAuthStore.getState().clear();
|
||||
clearStoredTokens();
|
||||
throw new ApiError('Сессия истекла', 401);
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) throw await parseError(res);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────── */
|
||||
export const api = {
|
||||
signup(payload: SignupPayload): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('/auth/signup', { method: 'POST', body: payload, auth: false });
|
||||
},
|
||||
login(payload: LoginPayload): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('/auth/login', { method: 'POST', body: payload, auth: false });
|
||||
},
|
||||
getPlans(): Promise<PricingPlans> {
|
||||
return request<PricingPlans>('/plans/', { auth: false });
|
||||
},
|
||||
checkout(order: OrderDetails): Promise<CheckoutResponse> {
|
||||
return request<CheckoutResponse>('/orders/checkout', { method: 'POST', body: order });
|
||||
},
|
||||
getMe(): Promise<UserInfo> {
|
||||
return request<UserInfo>('/users/me');
|
||||
},
|
||||
getSubscription(): Promise<SubscriptionData> {
|
||||
return request<SubscriptionData>('/users/subscription');
|
||||
},
|
||||
getDevices(): Promise<Device[]> {
|
||||
return request<Device[]>('/users/subscription/hwid');
|
||||
},
|
||||
deleteDevice(hwid: string): Promise<OperationResult> {
|
||||
return request<OperationResult>(
|
||||
`/users/subscription/hwid?hwid=${encodeURIComponent(hwid)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
},
|
||||
};
|
||||
43
src/components/AddonList.tsx
Normal file
43
src/components/AddonList.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { AddonData } from '../types';
|
||||
import { formatMoney } from '../lib/format';
|
||||
|
||||
interface AddonListProps {
|
||||
addons: AddonData[];
|
||||
selected: string[];
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
export function AddonList({ addons, selected, onToggle }: AddonListProps) {
|
||||
if (addons.length === 0) return null;
|
||||
return (
|
||||
<div className="plan-addons">
|
||||
<div className="addons-head">
|
||||
<h3 className="addons-title">Дополнения</h3>
|
||||
<span className="addons-cap">Опционально</span>
|
||||
</div>
|
||||
<div className="addon-list">
|
||||
{addons.map((a) => {
|
||||
const on = selected.includes(a.id);
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className={`addon-row ${on ? 'is-selected' : ''}`.trim()}
|
||||
aria-pressed={on}
|
||||
onClick={() => onToggle(a.id)}
|
||||
>
|
||||
<span className="addon-toggle" aria-hidden="true" />
|
||||
<span className="addon-info">
|
||||
<span className="addon-name">{a.name}</span>
|
||||
</span>
|
||||
<span className="addon-price">
|
||||
<span className="plus">+</span>
|
||||
{formatMoney(a.price)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/Breakdown.tsx
Normal file
59
src/components/Breakdown.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { DurationOption } from '../lib/format';
|
||||
import { formatMoney } from '../lib/format';
|
||||
|
||||
interface BreakdownProps {
|
||||
devices: number;
|
||||
devicePrice: number;
|
||||
addonCount: number;
|
||||
addonSum: number;
|
||||
duration: DurationOption;
|
||||
monthly: number;
|
||||
periodTotal: number;
|
||||
}
|
||||
|
||||
export function Breakdown({
|
||||
devices,
|
||||
devicePrice,
|
||||
addonCount,
|
||||
addonSum,
|
||||
duration,
|
||||
monthly,
|
||||
periodTotal,
|
||||
}: BreakdownProps) {
|
||||
return (
|
||||
<div className="plan-breakdown">
|
||||
<div className="breakdown-row">
|
||||
<div className="breakdown-key">
|
||||
Устройства<em>{devices}</em>
|
||||
</div>
|
||||
<div className="breakdown-val">
|
||||
{formatMoney(devicePrice)}
|
||||
<span className="muted">/ месяц</span>
|
||||
</div>
|
||||
</div>
|
||||
{addonCount > 0 ? (
|
||||
<div className="breakdown-row">
|
||||
<div className="breakdown-key">
|
||||
Дополнения<em>{addonCount}</em>
|
||||
</div>
|
||||
<div className="breakdown-val">
|
||||
{formatMoney(addonSum)}
|
||||
<span className="muted">/ месяц</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="breakdown-row breakdown-total">
|
||||
<div className="breakdown-key">Итого</div>
|
||||
<div className="breakdown-val">
|
||||
{formatMoney(monthly)}
|
||||
<span className="per">в месяц</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="breakdown-row breakdown-billed">
|
||||
<div className="breakdown-key">К оплате за {duration.months} мес.</div>
|
||||
<div className="breakdown-val">{formatMoney(periodTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/Cursor.tsx
Normal file
74
src/components/Cursor.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const FINE_QUERY = '(hover: hover) and (pointer: fine)';
|
||||
|
||||
export function Cursor(): null {
|
||||
const dotRef = useRef<HTMLDivElement | null>(null);
|
||||
const ringRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fine = window.matchMedia(FINE_QUERY).matches;
|
||||
if (!fine) return;
|
||||
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'cursor-dot';
|
||||
const ring = document.createElement('div');
|
||||
ring.className = 'cursor-ring';
|
||||
document.body.appendChild(dot);
|
||||
document.body.appendChild(ring);
|
||||
dotRef.current = dot;
|
||||
ringRef.current = ring;
|
||||
|
||||
let mx = -100;
|
||||
let my = -100;
|
||||
let rx = -100;
|
||||
let ry = -100;
|
||||
let raf = 0;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
mx = e.clientX;
|
||||
my = e.clientY;
|
||||
dot.style.left = `${mx}px`;
|
||||
dot.style.top = `${my}px`;
|
||||
};
|
||||
const onOver = (e: MouseEvent) => {
|
||||
const target = e.target as Element | null;
|
||||
const interactive = !!target?.closest('a, button, input, [data-cursor]');
|
||||
ring.classList.toggle('is-active', interactive);
|
||||
};
|
||||
const onLeave = () => {
|
||||
dot.style.opacity = '0';
|
||||
ring.style.opacity = '0';
|
||||
};
|
||||
const onEnter = () => {
|
||||
dot.style.opacity = '1';
|
||||
ring.style.opacity = '1';
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
rx += (mx - rx) * 0.18;
|
||||
ry += (my - ry) * 0.18;
|
||||
ring.style.left = `${rx}px`;
|
||||
ring.style.top = `${ry}px`;
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseover', onOver);
|
||||
document.addEventListener('mouseleave', onLeave);
|
||||
document.addEventListener('mouseenter', onEnter);
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseover', onOver);
|
||||
document.removeEventListener('mouseleave', onLeave);
|
||||
document.removeEventListener('mouseenter', onEnter);
|
||||
cancelAnimationFrame(raf);
|
||||
dot.remove();
|
||||
ring.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
76
src/components/DeviceCard.tsx
Normal file
76
src/components/DeviceCard.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Device } from '../types';
|
||||
|
||||
const GLYPHS_DEFAULT =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="13" rx="6" ry="7"/><path d="M9 9.5l1 1M15 9.5l-1 1M10 17h4"/></svg>';
|
||||
|
||||
const GLYPHS: Record<string, string> = {
|
||||
macos:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="13" rx="1.5"/><path d="M1 20h22M9 20l1-3h4l1 3"/></svg>',
|
||||
ios:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 5.5h2"/></svg>',
|
||||
android:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 14a7 7 0 0 1 14 0v5H5z"/><path d="M5 14L3 12M19 14l2-2M8 9l-1.5-2M16 9l1.5-2M9 8.5h6"/></svg>',
|
||||
windows:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5l8-1v8H3zM12 4l9-1v10h-9zM3 13h8v6l-8-1zM12 13h9v8l-9-1z"/></svg>',
|
||||
linux:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="13" rx="6" ry="7"/><path d="M9 9.5l1 1M15 9.5l-1 1M10 17h4"/></svg>',
|
||||
};
|
||||
|
||||
const OS_LABELS: Record<string, string> = {
|
||||
macos: 'macOS',
|
||||
ios: 'iOS',
|
||||
android: 'Android',
|
||||
windows: 'Windows',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
function glyphFor(os: string | null): string {
|
||||
if (!os) return GLYPHS.linux ?? GLYPHS_DEFAULT;
|
||||
const key = os.toLowerCase();
|
||||
return GLYPHS[key] ?? GLYPHS.linux ?? GLYPHS_DEFAULT;
|
||||
}
|
||||
|
||||
function osLabel(os: string | null): string {
|
||||
if (!os) return 'Устройство';
|
||||
return OS_LABELS[os.toLowerCase()] ?? os;
|
||||
}
|
||||
|
||||
interface DeviceCardProps {
|
||||
device: Device;
|
||||
onDelete: (device: Device) => void;
|
||||
}
|
||||
|
||||
export function DeviceCard({ device, onDelete }: DeviceCardProps) {
|
||||
const model = device.model ?? 'Без названия';
|
||||
return (
|
||||
<article className="device reveal is-in">
|
||||
<div className="device-glyph" aria-hidden="true" dangerouslySetInnerHTML={{ __html: glyphFor(device.os) }} />
|
||||
<div className="device-meta">
|
||||
<h3 className="device-model">{model}</h3>
|
||||
<div className="device-tags">
|
||||
<span>{osLabel(device.os)}</span>
|
||||
{device.client ? (
|
||||
<>
|
||||
<span className="sep" />
|
||||
<span className="tag-client">{device.client}</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="sep" />
|
||||
<span className="tag-client" title={device.hwid}>
|
||||
HWID
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="device-delete"
|
||||
aria-label={`Удалить ${model}`}
|
||||
onClick={() => onDelete(device)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
|
||||
</svg>
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
165
src/components/DeviceSlider.tsx
Normal file
165
src/components/DeviceSlider.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface DeviceSliderProps {
|
||||
min: number;
|
||||
max: number;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
const pctOf = (v: number, min: number, max: number) => (v - min) / (max - min);
|
||||
|
||||
export function DeviceSlider({ min, max, value, onChange }: DeviceSliderProps) {
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||
const hitRef = useRef<HTMLDivElement | null>(null);
|
||||
const pointerRef = useRef<HTMLDivElement | null>(null);
|
||||
const haloRef = useRef<HTMLDivElement | null>(null);
|
||||
const fillRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const renderPositions = useCallback(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return;
|
||||
const innerW = Math.max(0, frame.clientWidth - 8);
|
||||
const x = 4 + pctOf(value, min, max) * innerW;
|
||||
if (pointerRef.current) pointerRef.current.style.left = `${x}px`;
|
||||
if (haloRef.current) haloRef.current.style.left = `${x}px`;
|
||||
if (fillRef.current) fillRef.current.style.width = `${x - 4}px`;
|
||||
}, [max, min, value]);
|
||||
|
||||
useEffect(() => {
|
||||
renderPositions();
|
||||
}, [renderPositions]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', renderPositions);
|
||||
return () => window.removeEventListener('resize', renderPositions);
|
||||
}, [renderPositions]);
|
||||
|
||||
const valueFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return value;
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const innerLeft = rect.left + 4;
|
||||
const innerW = rect.width - 8;
|
||||
const p = (clientX - innerLeft) / innerW;
|
||||
const clamped = Math.max(0, Math.min(1, p));
|
||||
return Math.round(min + clamped * (max - min));
|
||||
},
|
||||
[max, min, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const hit = hitRef.current;
|
||||
if (!hit) return;
|
||||
|
||||
const onDown = (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
hit.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDragging(true);
|
||||
onChange(valueFromClientX(e.clientX));
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
onChange(valueFromClientX(e.clientX));
|
||||
};
|
||||
const onUp = (e: PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
setDragging(false);
|
||||
try {
|
||||
hit.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
hit.addEventListener('pointerdown', onDown);
|
||||
hit.addEventListener('pointermove', onMove);
|
||||
hit.addEventListener('pointerup', onUp);
|
||||
hit.addEventListener('pointercancel', onUp);
|
||||
return () => {
|
||||
hit.removeEventListener('pointerdown', onDown);
|
||||
hit.removeEventListener('pointermove', onMove);
|
||||
hit.removeEventListener('pointerup', onUp);
|
||||
hit.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
}, [dragging, onChange, valueFromClientX]);
|
||||
|
||||
const onFrameClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (dragging) return;
|
||||
if (e.target instanceof Element && e.target.closest('.pointer')) return;
|
||||
onChange(valueFromClientX(e.clientX));
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
let next = value;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = Math.min(max, value + (e.shiftKey ? 2 : 1));
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = Math.max(min, value - (e.shiftKey ? 2 : 1));
|
||||
else if (e.key === 'Home') next = min;
|
||||
else if (e.key === 'End') next = max;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const ticks: number[] = [];
|
||||
for (let i = min; i <= max; i++) ticks.push(i);
|
||||
const mid = Math.round((min + max) / 2);
|
||||
|
||||
return (
|
||||
<div className={`slider-wrap ${dragging ? 'is-dragging' : ''}`.trim()} ref={wrapRef} data-cursor="">
|
||||
<div className="slider-axis">
|
||||
<span>Устройства</span>
|
||||
<span>
|
||||
<span className="axis-now">{value}</span> мест
|
||||
</span>
|
||||
</div>
|
||||
<div className="slider-track-frame" ref={frameRef} onClick={onFrameClick}>
|
||||
<div className="slider-track">
|
||||
<div className="slider-fill" ref={fillRef} />
|
||||
</div>
|
||||
<div className="slider-ticks" aria-hidden="true">
|
||||
{ticks.map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`tick ${i === min || i === max || i === mid ? 'is-major' : ''} ${i === value ? 'is-active' : ''}`.trim()}
|
||||
>
|
||||
<span className="tick-label">{i}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pointer-halo" ref={haloRef} aria-hidden="true" />
|
||||
<div
|
||||
className="pointer"
|
||||
ref={pointerRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Количество устройств"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
aria-valuetext={`${value} устройств`}
|
||||
onKeyDown={onKey}
|
||||
onFocus={() => setDragging(true)}
|
||||
onBlur={() => setDragging(false)}
|
||||
/>
|
||||
<div className="slider-hit" ref={hitRef} />
|
||||
</div>
|
||||
<div className="slider-caption">
|
||||
<span className="hint">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 12h8M12 8l-4 4 4 4M12 8l4 4-4 4" />
|
||||
</svg>
|
||||
Тяните ромб · стрелки для точной настройки
|
||||
</span>
|
||||
<span>Диапазон {min} – {max}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/DurationSelect.tsx
Normal file
36
src/components/DurationSelect.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DURATION_OPTIONS } from '../lib/format';
|
||||
|
||||
interface DurationSelectProps {
|
||||
days: number;
|
||||
onChange: (days: number) => void;
|
||||
}
|
||||
|
||||
export function DurationSelect({ days, onChange }: DurationSelectProps) {
|
||||
return (
|
||||
<div className="plan-duration">
|
||||
<div className="duration-head">
|
||||
<span className="duration-label">Срок</span>
|
||||
<span className="duration-cap">Оплата за весь срок сразу</span>
|
||||
</div>
|
||||
<div className="duration-grid" role="radiogroup" aria-label="Срок подписки">
|
||||
{DURATION_OPTIONS.map((d) => {
|
||||
const active = d.days === days;
|
||||
return (
|
||||
<button
|
||||
key={d.days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={`dur ${active ? 'is-active' : ''}`.trim()}
|
||||
onClick={() => onChange(d.days)}
|
||||
>
|
||||
<span className="dur-num">{d.months}</span>
|
||||
<span className="dur-unit">{d.months === 1 ? 'месяц' : d.months < 12 ? 'месяцев' : 'год'}</span>
|
||||
<span className="dur-unit">{d.tag}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/components/Emblem.tsx
Normal file
16
src/components/Emblem.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export function Emblem({ size = 90 }: { size?: number }) {
|
||||
return (
|
||||
<div className="emblem" aria-hidden="true" style={{ width: size, height: size }}>
|
||||
<svg viewBox="0 0 90 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="45" cy="45" r="42" stroke="currentColor" strokeWidth="0.8" strokeDasharray="3 4" />
|
||||
<circle cx="45" cy="45" r="32" stroke="currentColor" strokeWidth="0.5" opacity="0.5" />
|
||||
<path d="M45 18 L72 45 L45 72 L18 45 Z" stroke="currentColor" strokeWidth="0.9" fill="none" />
|
||||
<circle cx="45" cy="45" r="3.5" fill="currentColor" />
|
||||
<line x1="45" y1="3" x2="45" y2="12" stroke="currentColor" strokeWidth="0.8" />
|
||||
<line x1="45" y1="78" x2="45" y2="87" stroke="currentColor" strokeWidth="0.8" />
|
||||
<line x1="3" y1="45" x2="12" y2="45" stroke="currentColor" strokeWidth="0.8" />
|
||||
<line x1="78" y1="45" x2="87" y2="45" stroke="currentColor" strokeWidth="0.8" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/components/Logo.tsx
Normal file
9
src/components/Logo.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function Logo({ to = '/' }: { to?: string }) {
|
||||
return (
|
||||
<Link to={to} className="logo" aria-label="Malenia">
|
||||
Mal<span className="e">e</span>nia
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
77
src/components/Modal.tsx
Normal file
77
src/components/Modal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
kicker?: string;
|
||||
body: React.ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
kicker,
|
||||
body,
|
||||
confirmLabel,
|
||||
cancelLabel = 'Оставить',
|
||||
onConfirm,
|
||||
}: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`modal-veil ${open ? 'is-open' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal" role="document">
|
||||
<div className="modal-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
|
||||
</svg>
|
||||
</div>
|
||||
{kicker ? <div className="modal-kicker">{kicker}</div> : null}
|
||||
<h3 className="modal-title" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="modal-body">{body}</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose} autoFocus>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
43
src/components/Reveal.tsx
Normal file
43
src/components/Reveal.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createElement, useEffect, useRef, type ReactNode } from 'react';
|
||||
|
||||
interface RevealProps {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Reveal({ children, delay = 0, className }: RevealProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
el.classList.add('is-in');
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((en) => {
|
||||
if (en.isIntersecting) {
|
||||
en.target.classList.add('is-in');
|
||||
io.unobserve(en.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.12 },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
return createElement(
|
||||
'div',
|
||||
{
|
||||
ref,
|
||||
className: `reveal ${className ?? ''}`.trim(),
|
||||
style: { transitionDelay: `${delay}ms` },
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
16
src/components/Shell.tsx
Normal file
16
src/components/Shell.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { TopBar } from './TopBar';
|
||||
|
||||
interface ShellProps {
|
||||
active?: 'plan' | 'account' | 'support' | 'channel';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Shell({ active, children }: ShellProps) {
|
||||
return (
|
||||
<div className="plan-shell">
|
||||
<TopBar active={active} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/components/Skeleton.tsx
Normal file
19
src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string;
|
||||
height?: string;
|
||||
radius?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function Skeleton({ width = '100%', height = '1em', radius, className, style }: SkeletonProps) {
|
||||
return (
|
||||
<span
|
||||
className={`skeleton ${className ?? ''}`.trim()}
|
||||
style={{ display: 'block', width, height, borderRadius: radius ?? undefined, ...style }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
94
src/components/StatStrip.tsx
Normal file
94
src/components/StatStrip.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { SubscriptionData, UserInfo } from '../types';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
interface StatStripProps {
|
||||
user: UserInfo | null;
|
||||
subscription: SubscriptionData | null;
|
||||
deviceCount: number;
|
||||
}
|
||||
|
||||
function Stat({
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="stat">
|
||||
<div className="stat-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
<div className="stat-value">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
||||
const username = user?.username ?? null;
|
||||
const telegramId = user?.telegram_id ?? null;
|
||||
const devicesLimit = subscription?.devices ?? null;
|
||||
const expiresAt = subscription?.expires_at ?? null;
|
||||
|
||||
return (
|
||||
<section className="profile" aria-label="Сводка по аккаунту">
|
||||
<Stat
|
||||
icon={
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c0-3.6 3.6-6 8-6s8 2.4 8 6" />
|
||||
</svg>
|
||||
}
|
||||
label="Имя пользователя"
|
||||
>
|
||||
{username ? <span>{username}</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">
|
||||
<path d="M21 4L3 11l5 2 2 6 3-4 4 3 4-14z" />
|
||||
<path d="M8 13l8-6" />
|
||||
</svg>
|
||||
}
|
||||
label="Telegram ID"
|
||||
>
|
||||
{telegramId ? <span className="is-mono">@{telegramId}</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">
|
||||
<rect x="3" y="4" width="18" height="12" rx="1.5" />
|
||||
<path d="M9 20h6M12 16v4" />
|
||||
</svg>
|
||||
}
|
||||
label="Устройства"
|
||||
>
|
||||
{devicesLimit ? (
|
||||
<span>
|
||||
{deviceCount} <span className="accent">/</span>{' '}
|
||||
<span className="stat-small">из {devicesLimit}</span>
|
||||
</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">
|
||||
<rect x="3" y="5" width="18" height="16" rx="1.5" />
|
||||
<path d="M3 9h18M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
}
|
||||
label="Продление"
|
||||
>
|
||||
{expiresAt ? <span>{formatDate(expiresAt)}</span> : <span className="is-empty">—</span>}
|
||||
</Stat>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
17
src/components/Toast.tsx
Normal file
17
src/components/Toast.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useToastStore } from '../store/useToast';
|
||||
|
||||
export function Toast() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
if (toasts.length === 0) return null;
|
||||
return createPortal(
|
||||
<div aria-live="polite" role="status">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className="toast is-show">
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
38
src/components/TopBar.tsx
Normal file
38
src/components/TopBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Logo } from './Logo';
|
||||
|
||||
interface TopBarProps {
|
||||
active?: 'plan' | 'account' | 'support' | 'channel';
|
||||
}
|
||||
|
||||
export function TopBar({ active }: TopBarProps) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<Logo to="/" />
|
||||
<nav className="nav" aria-label="Основная навигация">
|
||||
<Link to="/plan" className={active === 'plan' ? 'is-active' : undefined}>
|
||||
Оформить
|
||||
</Link>
|
||||
<a
|
||||
href="https://t.me/maleniavpn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={active === 'channel' ? 'is-active' : undefined}
|
||||
>
|
||||
Канал
|
||||
</a>
|
||||
<a
|
||||
href="https://t.me/maleniasupportbot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={active === 'support' ? 'is-active' : undefined}
|
||||
>
|
||||
Поддержка
|
||||
</a>
|
||||
<Link to="/account" className={active === 'account' ? 'is-active' : undefined}>
|
||||
Аккаунт
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
25
src/hooks/useAuth.ts
Normal file
25
src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
|
||||
export function useAuth() {
|
||||
const access = useAuthStore((s) => s.access);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const expiresAt = useAuthStore((s) => s.expiresAt);
|
||||
const setSession = useAuthStore((s) => s.setSession);
|
||||
const updateUser = useAuthStore((s) => s.updateUser);
|
||||
const setRemember = useAuthStore((s) => s.setRemember);
|
||||
const remember = useAuthStore((s) => s.remember);
|
||||
const clear = useAuthStore((s) => s.clear);
|
||||
|
||||
const isAuthenticated = Boolean(access);
|
||||
const isExpired = expiresAt !== null && expiresAt * 1000 < Date.now();
|
||||
|
||||
return {
|
||||
isAuthenticated: isAuthenticated && !isExpired,
|
||||
user,
|
||||
remember,
|
||||
setSession,
|
||||
updateUser,
|
||||
setRemember,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
54
src/hooks/useDevices.ts
Normal file
54
src/hooks/useDevices.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import type { Device } from '../types';
|
||||
|
||||
interface UseDevicesState {
|
||||
data: Device[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
remove: (hwid: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function useDevices(): UseDevicesState {
|
||||
const [data, setData] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const devices = await api.getDevices();
|
||||
setData(devices);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof ApiError ? err.message : 'Не удалось загрузить устройства';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const remove = useCallback(
|
||||
async (hwid: string) => {
|
||||
try {
|
||||
const res = await api.deleteDevice(hwid);
|
||||
if (res.success) {
|
||||
setData((prev) => prev.filter((d) => d.hwid !== hwid));
|
||||
}
|
||||
return res.success;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof ApiError ? err.message : 'Ошибка удаления устройства';
|
||||
setError(message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { data, loading, error, reload: load, remove };
|
||||
}
|
||||
39
src/hooks/useLocalStorage.ts
Normal file
39
src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initial: T): [T, (v: T | ((prev: T) => T)) => void] {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? initial : (JSON.parse(raw) as T);
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
});
|
||||
|
||||
const set = useCallback(
|
||||
(v: T | ((prev: T) => T)) => {
|
||||
setValue((prev) => {
|
||||
const next = typeof v === 'function' ? (v as (prev: T) => T)(prev) : v;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw !== null) setValue(raw === null ? initial : (JSON.parse(raw) as T));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [value, set];
|
||||
}
|
||||
37
src/hooks/usePlans.ts
Normal file
37
src/hooks/usePlans.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import type { PricingPlans } from '../types';
|
||||
|
||||
interface UsePlansState {
|
||||
data: PricingPlans | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function usePlans(): UsePlansState {
|
||||
const [state, setState] = useState<UsePlansState>({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
api
|
||||
.getPlans()
|
||||
.then((plans) => {
|
||||
if (active) setState({ data: plans, loading: false, error: null });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!active) return;
|
||||
const message = err instanceof ApiError ? err.message : 'Не удалось загрузить тарифы';
|
||||
setState({ data: null, loading: false, error: message });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
109
src/lib/format.test.ts
Normal file
109
src/lib/format.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DURATION_OPTIONS,
|
||||
calculatePricing,
|
||||
durationByDays,
|
||||
pluralize,
|
||||
round2,
|
||||
sumAddonPrices,
|
||||
devicesLabel,
|
||||
formatMoney,
|
||||
} from './format';
|
||||
import type { AddonData, PricingPlans } from '../types';
|
||||
|
||||
const pricing: PricingPlans = {
|
||||
device_price: 100,
|
||||
addons: [
|
||||
{ id: 'a', name: 'A', price: 50, free_threshold: 0 },
|
||||
{ id: 'b', name: 'B', price: 30, free_threshold: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('round2', () => {
|
||||
it('rounds to two decimals', () => {
|
||||
expect(round2(1.005)).toBe(1.01);
|
||||
expect(round2(2.345)).toBe(2.35);
|
||||
expect(round2(10)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumAddonPrices', () => {
|
||||
const addons: AddonData[] = pricing.addons;
|
||||
it('sums selected addon prices', () => {
|
||||
expect(sumAddonPrices(addons, ['a', 'b'])).toBe(80);
|
||||
expect(sumAddonPrices(addons, ['a'])).toBe(50);
|
||||
expect(sumAddonPrices(addons, ['unknown'])).toBe(0);
|
||||
expect(sumAddonPrices(addons, [])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePricing', () => {
|
||||
it('computes linear device cost + addons + duration total', () => {
|
||||
const r = calculatePricing({
|
||||
pricing,
|
||||
devices: 6,
|
||||
addonIds: ['a'],
|
||||
duration: DURATION_OPTIONS[1]!, // 90 days
|
||||
});
|
||||
// monthly = 6*100 + 50 = 650
|
||||
// total = 650 * 90 / 30 = 1950
|
||||
expect(r.deviceCost).toBe(600);
|
||||
expect(r.addonCost).toBe(50);
|
||||
expect(r.monthly).toBe(650);
|
||||
expect(r.periodTotal).toBe(1950);
|
||||
});
|
||||
|
||||
it('handles 1 month / 30 days', () => {
|
||||
const r = calculatePricing({
|
||||
pricing,
|
||||
devices: 3,
|
||||
addonIds: [],
|
||||
duration: DURATION_OPTIONS[0]!,
|
||||
});
|
||||
expect(r.monthly).toBe(300);
|
||||
expect(r.periodTotal).toBe(300);
|
||||
});
|
||||
|
||||
it('12 months / 365 days yields near 12x', () => {
|
||||
const r = calculatePricing({
|
||||
pricing,
|
||||
devices: 1,
|
||||
addonIds: [],
|
||||
duration: DURATION_OPTIONS[3]!,
|
||||
});
|
||||
// monthly=100, total = 100 * 365/30 = 1216.67
|
||||
expect(r.periodTotal).toBeCloseTo(1216.67, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('durationByDays', () => {
|
||||
it('finds duration by days', () => {
|
||||
expect(durationByDays(30).months).toBe(1);
|
||||
expect(durationByDays(90).months).toBe(3);
|
||||
expect(durationByDays(180).months).toBe(6);
|
||||
expect(durationByDays(365).months).toBe(12);
|
||||
});
|
||||
it('falls back to first option for unknown days', () => {
|
||||
expect(durationByDays(7).months).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pluralize / devicesLabel', () => {
|
||||
it('picks correct form', () => {
|
||||
expect(pluralize(1, 'устройство', 'устройства', 'устройств')).toBe('устройство');
|
||||
expect(pluralize(2, 'устройство', 'устройства', 'устройств')).toBe('устройства');
|
||||
expect(pluralize(5, 'устройство', 'устройства', 'устройств')).toBe('устройств');
|
||||
expect(pluralize(11, 'устройство', 'устройства', 'устройств')).toBe('устройств');
|
||||
});
|
||||
it('devicesLabel combines number and noun', () => {
|
||||
expect(devicesLabel(3)).toBe('3 устройства');
|
||||
expect(devicesLabel(6)).toBe('6 устройств');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMoney', () => {
|
||||
it('formats rubles', () => {
|
||||
expect(formatMoney(100)).toMatch(/100/);
|
||||
expect(formatMoney(1950)).toMatch(/1\s950/);
|
||||
});
|
||||
});
|
||||
103
src/lib/format.ts
Normal file
103
src/lib/format.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { AddonData, PricingPlans } from '../types';
|
||||
|
||||
/* ── Money ───────────────────────────────────────────────── */
|
||||
const RUB = new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return RUB.format(round2(value));
|
||||
}
|
||||
|
||||
export function formatMoneyPlain(value: number): string {
|
||||
return String(round2(value));
|
||||
}
|
||||
|
||||
export function round2(value: number): number {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
/* ── Duration ───────────────────────────────────────────── */
|
||||
export interface DurationOption {
|
||||
months: number;
|
||||
days: number;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export const DURATION_OPTIONS: DurationOption[] = [
|
||||
{ months: 1, days: 30, tag: '1 месяц' },
|
||||
{ months: 3, days: 90, tag: '3 месяца' },
|
||||
{ months: 6, days: 180, tag: '6 месяцев' },
|
||||
{ months: 12, days: 365, tag: '1 год' },
|
||||
];
|
||||
|
||||
export function durationByDays(days: number): DurationOption {
|
||||
return DURATION_OPTIONS.find((d) => d.days === days) ?? DURATION_OPTIONS[0]!;
|
||||
}
|
||||
|
||||
/* ── Pricing ────────────────────────────────────────────── */
|
||||
export interface PricingInput {
|
||||
pricing: PricingPlans;
|
||||
devices: number;
|
||||
addonIds: string[];
|
||||
duration: DurationOption;
|
||||
}
|
||||
|
||||
export interface PricingBreakdown {
|
||||
deviceCost: number;
|
||||
addonCost: number;
|
||||
monthly: number;
|
||||
periodTotal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the backend `services/subscriptions.py`:
|
||||
* monthly = devices * device_price + sum(selected addon prices)
|
||||
* total = monthly * duration_days / 30
|
||||
*/
|
||||
export function calculatePricing({
|
||||
pricing,
|
||||
devices,
|
||||
addonIds,
|
||||
duration,
|
||||
}: PricingInput): PricingBreakdown {
|
||||
const deviceCost = pricing.device_price * devices;
|
||||
const addonCost = sumAddonPrices(pricing.addons, addonIds);
|
||||
const monthly = deviceCost + addonCost;
|
||||
const periodTotal = round2((monthly * duration.days) / 30);
|
||||
return {
|
||||
deviceCost: round2(deviceCost),
|
||||
addonCost: round2(addonCost),
|
||||
monthly: round2(monthly),
|
||||
periodTotal,
|
||||
};
|
||||
}
|
||||
|
||||
export function sumAddonPrices(addons: AddonData[], selected: string[]): number {
|
||||
const priceById = new Map(addons.map((a) => [a.id, a.price] as const));
|
||||
return selected.reduce<number>((sum, id) => sum + (priceById.get(id) ?? 0), 0);
|
||||
}
|
||||
|
||||
/* ── Date ────────────────────────────────────────────────── */
|
||||
export function formatDate(value: string | number | Date): string {
|
||||
const d = new Date(value);
|
||||
return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
.format(d)
|
||||
.replace(' г.', '');
|
||||
}
|
||||
|
||||
/* ── Pluralization (Russian) ─────────────────────────────── */
|
||||
export function pluralize(n: number, one: string, few: string, many: string): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return one;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
export function devicesLabel(n: number): string {
|
||||
return `${n} ${pluralize(n, 'устройство', 'устройства', 'устройств')}`;
|
||||
}
|
||||
48
src/lib/planConfig.test.ts
Normal file
48
src/lib/planConfig.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { PLAN_CONFIG_KEY, readPlanConfig, resolvePlanConfig, savePlanConfig } from './planConfig';
|
||||
|
||||
describe('plan configuration storage', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('saves and restores a complete calculator configuration', () => {
|
||||
savePlanConfig({ devices: 8, durationDays: 180, addons: ['ip', 'multi-hop'] });
|
||||
expect(readPlanConfig()).toEqual({ devices: 8, durationDays: 180, addons: ['ip', 'multi-hop'] });
|
||||
});
|
||||
|
||||
it('ignores malformed or invalid saved data', () => {
|
||||
localStorage.setItem(PLAN_CONFIG_KEY, '{bad json');
|
||||
expect(readPlanConfig()).toBeNull();
|
||||
|
||||
localStorage.setItem(
|
||||
PLAN_CONFIG_KEY,
|
||||
JSON.stringify({ devices: 20, durationDays: 7, addons: [12] }),
|
||||
);
|
||||
expect(readPlanConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('restores saved data when URL does not contain a configuration', () => {
|
||||
savePlanConfig({ devices: 7, durationDays: 90, addons: ['ip'] });
|
||||
expect(resolvePlanConfig(new URLSearchParams())).toEqual({
|
||||
devices: 7,
|
||||
durationDays: 90,
|
||||
addons: ['ip'],
|
||||
});
|
||||
});
|
||||
|
||||
it('gives valid URL parameters priority over saved data', () => {
|
||||
savePlanConfig({ devices: 7, durationDays: 90, addons: ['ip'] });
|
||||
expect(resolvePlanConfig(new URLSearchParams('devices=10&duration=365&addons=multi-hop'))).toEqual({
|
||||
devices: 10,
|
||||
durationDays: 365,
|
||||
addons: ['multi-hop'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses defaults when no valid configuration is available', () => {
|
||||
expect(resolvePlanConfig(new URLSearchParams('devices=1&duration=10'))).toEqual({
|
||||
devices: 6,
|
||||
durationDays: 30,
|
||||
addons: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
80
src/lib/planConfig.ts
Normal file
80
src/lib/planConfig.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { DURATION_OPTIONS } from './format';
|
||||
|
||||
export const PLAN_CONFIG_KEY = 'malenia.plan_config';
|
||||
export const MIN_DEVICES = 3;
|
||||
export const MAX_DEVICES = 12;
|
||||
|
||||
export interface PlanConfig {
|
||||
devices: number;
|
||||
durationDays: number;
|
||||
addons: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PlanConfig = {
|
||||
devices: 6,
|
||||
durationDays: DURATION_OPTIONS[0]!.days,
|
||||
addons: [],
|
||||
};
|
||||
|
||||
function validDevices(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= MIN_DEVICES &&
|
||||
value <= MAX_DEVICES
|
||||
);
|
||||
}
|
||||
|
||||
function validDuration(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
DURATION_OPTIONS.some((option) => option.days === value)
|
||||
);
|
||||
}
|
||||
|
||||
function validAddons(value: unknown): value is string[] {
|
||||
if (!Array.isArray(value)) return false;
|
||||
return value.every((addon) => typeof addon === 'string' && addon.length > 0);
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
export function readPlanConfig(): PlanConfig | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PLAN_CONFIG_KEY);
|
||||
if (!raw) return null;
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (typeof value !== 'object' || value === null) return null;
|
||||
const config = value as Partial<PlanConfig>;
|
||||
if (!validDevices(config.devices) || !validDuration(config.durationDays) || !validAddons(config.addons)) {
|
||||
return null;
|
||||
}
|
||||
return { devices: config.devices, durationDays: config.durationDays, addons: unique(config.addons) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePlanConfig(config: PlanConfig): void {
|
||||
try {
|
||||
localStorage.setItem(PLAN_CONFIG_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
/* Storage may be unavailable in private browsing modes. */
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePlanConfig(search: URLSearchParams): PlanConfig {
|
||||
const saved = readPlanConfig() ?? DEFAULT_CONFIG;
|
||||
const urlDevices = Number(search.get('devices'));
|
||||
const urlDuration = Number(search.get('duration'));
|
||||
const urlAddons = search.get('addons')?.split(',').filter(Boolean);
|
||||
|
||||
return {
|
||||
devices: validDevices(urlDevices) ? urlDevices : saved.devices,
|
||||
durationDays: validDuration(urlDuration) ? urlDuration : saved.durationDays,
|
||||
addons: search.has('addons') ? unique(urlAddons ?? []) : saved.addons,
|
||||
};
|
||||
}
|
||||
3
src/lib/redirect.ts
Normal file
3
src/lib/redirect.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function redirectTo(url: string): void {
|
||||
window.location.assign(url);
|
||||
}
|
||||
12
src/main.tsx
Normal file
12
src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/base.css';
|
||||
import './styles/components.css';
|
||||
import './styles/pages.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
150
src/pages/AccountPage.test.tsx
Normal file
150
src/pages/AccountPage.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { AccountPage } from './AccountPage';
|
||||
import { api } from '../api/client';
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
api: {
|
||||
getPlans: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
getMe: vi.fn(),
|
||||
getSubscription: vi.fn(),
|
||||
getDevices: vi.fn(),
|
||||
deleteDevice: vi.fn(),
|
||||
login: vi.fn(),
|
||||
signup: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(m: string, public status: number) {
|
||||
super(m);
|
||||
}
|
||||
},
|
||||
setStoredTokens: vi.fn(),
|
||||
clearStoredTokens: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderAccount(authed = true) {
|
||||
if (authed) {
|
||||
useAuthStore.setState({
|
||||
access: 'a',
|
||||
refresh: 'r',
|
||||
expiresAt: 9999999999,
|
||||
user: { username: 'alice', telegram_id: 'alice_id', referal_code: 'c' },
|
||||
remember: true,
|
||||
});
|
||||
} else {
|
||||
useAuthStore.setState({
|
||||
access: null,
|
||||
refresh: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
remember: true,
|
||||
});
|
||||
}
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/account']}>
|
||||
<Routes>
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/login" element={<div>login page</div>} />
|
||||
<Route path="/" element={<div>landing</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c' };
|
||||
const SUB = {
|
||||
devices: 6,
|
||||
expires_at: '2026-03-14T00:00:00Z',
|
||||
addon_ids: [],
|
||||
subscription_link: 'link',
|
||||
has_subscription: true,
|
||||
};
|
||||
const DEVICES = [
|
||||
{ os: 'macos', model: 'MacBook Pro', client: 'Malenia', hwid: 'h1' },
|
||||
{ os: 'ios', model: 'iPhone 15', client: 'Malenia', hwid: 'h2' },
|
||||
];
|
||||
|
||||
describe('AccountPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.getMe).mockReset();
|
||||
vi.mocked(api.getSubscription).mockReset();
|
||||
vi.mocked(api.getDevices).mockReset();
|
||||
vi.mocked(api.deleteDevice).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('redirects to /login when not authenticated', async () => {
|
||||
renderAccount(false);
|
||||
await waitFor(() => expect(screen.getByText('login page')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders hero username and stat strip', async () => {
|
||||
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||
renderAccount();
|
||||
expect(await screen.findByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('@alice_id')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not render a subscription status before subscription data loads', async () => {
|
||||
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||
renderAccount();
|
||||
|
||||
expect(screen.queryByText(/активная подписка отсутствует/i)).not.toBeInTheDocument();
|
||||
expect(await screen.findByText(/подписка до/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the subscription link and copy action', async () => {
|
||||
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||
renderAccount();
|
||||
expect(await screen.findByText(SUB.subscription_link)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /копировать/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders empty state when no devices', async () => {
|
||||
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||
vi.mocked(api.getDevices).mockResolvedValue([]);
|
||||
renderAccount();
|
||||
expect(await screen.findByText(/нет подключённых устройств/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens confirmation modal and deletes device on confirm', async () => {
|
||||
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||
vi.mocked(api.deleteDevice).mockResolvedValue({ success: true });
|
||||
const user = userEvent.setup();
|
||||
renderAccount();
|
||||
await screen.findByText(/iPhone 15/);
|
||||
await user.click(screen.getByRole('button', { name: /удалить iPhone 15/i }));
|
||||
expect(await screen.findByText(/удалить это устройство\?/i)).toBeInTheDocument();
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /^удалить$/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]!);
|
||||
await waitFor(() => expect(api.deleteDevice).toHaveBeenCalledWith('h2'));
|
||||
await waitFor(() => expect(screen.queryByText(/iPhone 15/)).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('signs out and navigates to landing', async () => {
|
||||
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||
const user = userEvent.setup();
|
||||
renderAccount();
|
||||
await screen.findByText(/alice/);
|
||||
await user.click(screen.getByRole('button', { name: /выйти/i }));
|
||||
await waitFor(() => expect(screen.getByText('landing')).toBeInTheDocument());
|
||||
expect(useAuthStore.getState().access).toBeNull();
|
||||
});
|
||||
});
|
||||
215
src/pages/AccountPage.tsx
Normal file
215
src/pages/AccountPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/pages/Landing.test.tsx
Normal file
24
src/pages/Landing.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Landing } from './Landing';
|
||||
|
||||
describe('Landing', () => {
|
||||
it('renders logo, navigation and CTAs', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Landing />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByRole('link', { name: /Malenia/ })).toHaveAttribute('href', '/');
|
||||
expect(screen.getAllByRole('link', { name: /Оформить/i }).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('link', { name: /Канал/i })[0]).toHaveAttribute(
|
||||
'href',
|
||||
'https://t.me/maleniavpn',
|
||||
);
|
||||
expect(screen.getAllByRole('link', { name: /Поддержка/i })[0]).toHaveAttribute(
|
||||
'href',
|
||||
'https://t.me/maleniasupportbot',
|
||||
);
|
||||
});
|
||||
});
|
||||
153
src/pages/Landing.tsx
Normal file
153
src/pages/Landing.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Emblem } from '../components/Emblem';
|
||||
import { Logo } from '../components/Logo';
|
||||
|
||||
const QUOTES: string[] = [
|
||||
'Верните себе\nсвободный интернет.',
|
||||
'Свобода должна\nбыть доступной.',
|
||||
'Интернет без границ —\nне привилегия, а норма.',
|
||||
'Ваш трафик.\nВаши правила.',
|
||||
'Тишина в сети —\nэто тоже выбор.',
|
||||
'Открытый мир\nначинается здесь.',
|
||||
'Без цензуры.\nБез компромиссов.',
|
||||
'Простота —\nлучшая форма свободы.',
|
||||
'Один клик\nмежду вами и миром.',
|
||||
'Malenia —\nсвобода в форме привычки.',
|
||||
'Будьте везде.\nОставайтесь невидимым.',
|
||||
'Приватность — это право,\nа не роскошь.',
|
||||
'Интернет таким,\nкаким он задумывался.',
|
||||
'Ваши данные —\nтолько ваше дело.',
|
||||
'Закрытых дверей\nбольше не существует.',
|
||||
'Любой сайт.\nИз любой точки мира.',
|
||||
'Оставьте след в истории,\nа не в браузере.',
|
||||
'Malenia —\nключ к открытой сети.',
|
||||
'Фильтры придумали они.\nРешение выбрали вы.',
|
||||
'Безопасность, которую\nвы не замечаете.',
|
||||
'Сотрите цифровые границы.\nОстаньтесь собой.',
|
||||
'Скорость без компромиссов.\nСеть без преград.',
|
||||
'Включили и забыли.\nВы в безопасности.',
|
||||
'Блокировки иллюзорны.\nСвобода реальна.',
|
||||
'Никакой слежки.\nТолько вы и интернет.',
|
||||
'Доступ к информации —\nваше базовое право.',
|
||||
'Malenia.\nИнтернет снова ваш.',
|
||||
'Контроль над сетью\nв ваших руках.',
|
||||
'Невидимый щит\nдля вашей свободы.',
|
||||
'Весь мир на экране.\nБез лишних вопросов.',
|
||||
'Никогда не поздно\nвыбрать свободу.',
|
||||
'Привычка к запретам —\nповод проснуться.',
|
||||
'Откройте глаза.\nСтен больше нет.',
|
||||
'Вспомните, как выглядит\nнастоящая сеть.',
|
||||
'Запреты стали нормой.\nПора это менять.',
|
||||
'Свобода начинается\nс вашего решения.',
|
||||
'Выход из спящего режима.\nСделайте первый шаг.',
|
||||
'Лучший момент для\nсвободы — сейчас.',
|
||||
'Иллюзия границ рушится\nв один клик.',
|
||||
'Хватит мириться с рамками.\nМир шире, чем кажется.',
|
||||
'Не ждите свободы.\nВключите её.',
|
||||
'Цифровая амнезия окончена.\nВерните своё.',
|
||||
'Осознанность начинается\nтам, где кончается цензура.',
|
||||
'Первый шаг к свободе —\nперестать верить в стены.',
|
||||
'Malenia.\nВаше цифровое пробуждение.',
|
||||
];
|
||||
|
||||
function TelegramIcon() {
|
||||
return (
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.265 2.428a1.523 1.523 0 0 0-1.547-.232L1.91 9.9a1.523 1.523 0 0 0 .089 2.844l4.498 1.498 1.815 5.784a.761.761 0 0 0 1.295.3l2.617-2.818 4.898 3.674a1.523 1.523 0 0 0 2.389-.98l3.047-16.762a1.523 1.523 0 0 0-.293-1.012zM9.94 13.88l-.98 3.267-.98-3.143L18.5 6.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SupportIcon() {
|
||||
return (
|
||||
<svg className="btn-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2a2 2 0 0 1 2 2 2 2 0 0 1-1 1.732V7h3a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3v-8a3 3 0 0 1 3-3h3V5.732A2 2 0 0 1 10 4a2 2 0 0 1 2-2zm-3 9a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm6 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function renderQuote(raw: string): { head: string; tail: string | null } {
|
||||
const lines = raw.split('\n');
|
||||
if (lines.length === 2) return { head: lines[0] ?? raw, tail: lines[1] ?? null };
|
||||
return { head: raw, tail: null };
|
||||
}
|
||||
|
||||
export function Landing() {
|
||||
const quote = useMemo(() => {
|
||||
const chosen = QUOTES[Math.floor(Math.random() * QUOTES.length)];
|
||||
if (!chosen) return null;
|
||||
return renderQuote(chosen);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<header className="landing-header">
|
||||
<Logo />
|
||||
<div className="header-rule" />
|
||||
<nav aria-label="Верхняя навигация">
|
||||
<Link to="/plan">Оформить</Link>
|
||||
<a href="https://t.me/maleniavpn" target="_blank" rel="noopener noreferrer">
|
||||
Канал
|
||||
</a>
|
||||
<a href="https://t.me/maleniasupportbot" target="_blank" rel="noopener noreferrer">
|
||||
Поддержка
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="landing-hero">
|
||||
<Emblem size={90} />
|
||||
|
||||
{quote ? (
|
||||
<div className="quote-block">
|
||||
<p className="quote-text">
|
||||
{quote.head}
|
||||
{quote.tail ? (
|
||||
<>
|
||||
<br />
|
||||
<em>{quote.tail}</em>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="quote-author">malenia.space</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="landing-divider" aria-hidden="true">
|
||||
<div className="divider-diamond" />
|
||||
</div>
|
||||
|
||||
<div className="cta-group">
|
||||
<Link to="/plan" className="btn btn-primary">
|
||||
<span>Оформить</span>
|
||||
</Link>
|
||||
<a
|
||||
className="btn btn-secondary"
|
||||
href="https://t.me/maleniavpn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<TelegramIcon />
|
||||
<span>Канал</span>
|
||||
</a>
|
||||
<a
|
||||
className="btn btn-secondary"
|
||||
href="https://t.me/maleniasupportbot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<SupportIcon />
|
||||
<span>Поддержка</span>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="landing-footer">
|
||||
<span className="footer-copy">© 2026 Malenia</span>
|
||||
<div className="footer-dot" />
|
||||
<span className="footer-copy">malenia.space</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/pages/LoginPage.test.tsx
Normal file
99
src/pages/LoginPage.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { LoginPage } from './LoginPage';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
api: { login: vi.fn(), signup: vi.fn(), getPlans: vi.fn() },
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(m: string, public status: number) {
|
||||
super(m);
|
||||
}
|
||||
},
|
||||
setStoredTokens: vi.fn(),
|
||||
clearStoredTokens: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderLogin() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/login']}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/account" element={<div>account page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
access: null,
|
||||
refresh: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
remember: true,
|
||||
});
|
||||
vi.mocked(api.login).mockReset();
|
||||
vi.mocked(api.signup).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('shows validation error when fields empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLogin();
|
||||
await user.click(screen.getByRole('button', { name: /Войти/i }));
|
||||
expect(await screen.findByText(/обязательны/i)).toBeInTheDocument();
|
||||
expect(api.login).not.toHaveBeenCalled();
|
||||
}); it('submits login and redirects to /account by default', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.login).mockResolvedValue({
|
||||
access_token: 'a',
|
||||
refresh_token: 'r',
|
||||
expires_at: 9999999999,
|
||||
user: { username: 'alice', telegram_id: null, referal_code: 'c' },
|
||||
});
|
||||
renderLogin();
|
||||
await user.type(screen.getByLabelText('Имя пользователя'), 'alice');
|
||||
await user.type(screen.getByLabelText('Пароль'), 'secret');
|
||||
await user.click(screen.getByRole('button', { name: /Войти/i }));
|
||||
await waitFor(() => expect(api.login).toHaveBeenCalledWith({
|
||||
provider: 'credentials',
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByText('account page')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('toggles password visibility', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLogin();
|
||||
const pass = screen.getByLabelText('Пароль') as HTMLInputElement;
|
||||
await user.type(pass, 'secret');
|
||||
expect(pass.type).toBe('password');
|
||||
await user.click(screen.getByRole('button', { name: /показать пароль/i }));
|
||||
expect(pass.type).toBe('text');
|
||||
});
|
||||
|
||||
it('switches to signup and shows referral field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLogin();
|
||||
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
||||
expect(screen.getByRole('heading', { name: /создать аккаунт/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/реферальный код/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows backend error on login failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.login).mockRejectedValue(new ApiError('неверные данные', 401));
|
||||
renderLogin();
|
||||
await user.type(screen.getByLabelText('Имя пользователя'), 'a');
|
||||
await user.type(screen.getByLabelText('Пароль'), 'secret');
|
||||
await user.click(screen.getByRole('button', { name: /Войти/i }));
|
||||
expect(await screen.findByText('неверные данные')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
278
src/pages/LoginPage.tsx
Normal file
278
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useToastStore } from '../store/useToast';
|
||||
import { Emblem } from '../components/Emblem';
|
||||
import { Logo } from '../components/Logo';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
|
||||
type Mode = 'signin' | 'signup';
|
||||
|
||||
function EyeIcon({ open }: { open: boolean }) {
|
||||
if (open) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3l18 18M10.5 10.7a3 3 0 0 0 4 4M9.4 5.2A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.3 17.3 0 0 1-3.2 4.1M6.1 6.2A17.3 17.3 0 0 0 2 12s3.5 7 10 7c1.4 0 2.7-.3 3.8-.7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface LocationState {
|
||||
returnTo?: string;
|
||||
}
|
||||
|
||||
function returnPath(value: string | null | undefined): string | null {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//')) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, setSession } = useAuth();
|
||||
const showToast = useToastStore((s) => s.show);
|
||||
|
||||
const [mode, setMode] = useState<Mode>('signin');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [referral, setReferral] = useState('');
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [statusKind, setStatusKind] = useState<'idle' | 'error' | 'info'>('idle');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const queryReturnTo = new URLSearchParams(location.search).get('returnTo');
|
||||
const returnTo =
|
||||
returnPath(queryReturnTo) ?? returnPath((location.state as LocationState | null)?.returnTo) ?? '/account';
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) navigate(returnTo, { replace: true });
|
||||
}, [isAuthenticated, navigate, returnTo]);
|
||||
|
||||
const setStatusMsg = (msg: string, kind: 'error' | 'info') => {
|
||||
setStatus(msg);
|
||||
setStatusKind(kind);
|
||||
};
|
||||
|
||||
const switchMode = (next: Mode) => {
|
||||
setMode(next);
|
||||
setStatus('');
|
||||
setStatusKind('idle');
|
||||
};
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
setStatusMsg('Имя пользователя и пароль обязательны.', 'error');
|
||||
return;
|
||||
}
|
||||
if (mode === 'signup' && password.length < 4) {
|
||||
setStatusMsg('Пароль слишком короткий (минимум 4 символа).', 'error');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setStatusMsg('Проверяем…', 'info');
|
||||
try {
|
||||
if (mode === 'signin') {
|
||||
const res = await api.login({
|
||||
provider: 'credentials',
|
||||
username: username.trim(),
|
||||
password,
|
||||
});
|
||||
setSession(res, remember);
|
||||
showToast(`Здравствуйте, ${res.user.username ?? username.trim()}`);
|
||||
navigate(returnTo, { replace: true });
|
||||
} else {
|
||||
const res = await api.signup({
|
||||
provider: 'credentials',
|
||||
username: username.trim(),
|
||||
password,
|
||||
referal_code: referral.trim() || null,
|
||||
});
|
||||
setSession(res, remember);
|
||||
showToast('Аккаунт создан');
|
||||
navigate(returnTo, { replace: true });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof ApiError ? err.message : 'Не удалось связаться с сервером';
|
||||
setStatusMsg(message, 'error');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-shell">
|
||||
<Reveal className="auth-topbar">
|
||||
<Logo to="/" />
|
||||
<div className="kicker">Private access</div>
|
||||
</Reveal>
|
||||
|
||||
<main className="auth-card">
|
||||
<Reveal>
|
||||
<div className="auth-emblem">
|
||||
<Emblem size={44} />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<div className="auth-tabs" role="tablist" aria-label="Режим авторизации">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'signin'}
|
||||
className={`auth-tab ${mode === 'signin' ? 'is-active' : ''}`.trim()}
|
||||
onClick={() => switchMode('signin')}
|
||||
>
|
||||
Вход
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'signup'}
|
||||
className={`auth-tab ${mode === 'signup' ? 'is-active' : ''}`.trim()}
|
||||
onClick={() => switchMode('signup')}
|
||||
>
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<h1 className="auth-title">{mode === 'signin' ? 'С возвращением' : 'Создать аккаунт'}</h1>
|
||||
<p className="auth-sub">
|
||||
{mode === 'signin'
|
||||
? 'Введите данные для продолжения сессии.'
|
||||
: 'Логин и пароль — этого достаточно.'}
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<form className="auth-form" onSubmit={submit} noValidate>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="username">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
className="field-input"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="ваш логин"
|
||||
spellCheck={false}
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<span className="field-line" />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="password">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
className="field-input"
|
||||
type={showPass ? 'text' : 'password'}
|
||||
autoComplete={mode === 'signin' ? 'current-password' : 'new-password'}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<span className="field-line" />
|
||||
<button
|
||||
type="button"
|
||||
className="field-icon"
|
||||
aria-label={showPass ? 'Скрыть пароль' : 'Показать пароль'}
|
||||
aria-pressed={showPass}
|
||||
onClick={() => setShowPass((v) => !v)}
|
||||
>
|
||||
<EyeIcon open={showPass} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'signup' ? (
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="referral">
|
||||
Реферальный код (необязательно)
|
||||
</label>
|
||||
<input
|
||||
id="referral"
|
||||
name="referral"
|
||||
className="field-input"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="код от друга"
|
||||
spellCheck={false}
|
||||
value={referral}
|
||||
onChange={(e) => setReferral(e.target.value)}
|
||||
/>
|
||||
<span className="field-line" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-row">
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
/>
|
||||
<span>Запомнить меня</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-block" disabled={submitting}>
|
||||
{submitting ? 'Сохранение…' : mode === 'signin' ? 'Войти' : 'Создать аккаунт'}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`auth-status ${statusKind === 'error' ? 'is-error' : ''} ${statusKind === 'info' ? 'is-info' : ''}`.trim()}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
</form>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<p className="auth-signup">
|
||||
{mode === 'signin' ? (
|
||||
<>
|
||||
Нет аккаунта?{' '}
|
||||
<Link to="/login" className="link" onClick={() => switchMode('signup')}>
|
||||
Создать
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Уже зарегистрированы?{' '}
|
||||
<Link to="/login" className="link" onClick={() => switchMode('signin')}>
|
||||
Войти
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</Reveal>
|
||||
</main>
|
||||
|
||||
<Reveal className="auth-foot">
|
||||
Защищено MaleniaVPN
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
src/pages/PlanPage.test.tsx
Normal file
133
src/pages/PlanPage.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { PlanPage } from './PlanPage';
|
||||
import { api } from '../api/client';
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
import { redirectTo } from '../lib/redirect';
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
api: {
|
||||
getPlans: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
getMe: vi.fn(),
|
||||
getSubscription: vi.fn(),
|
||||
getDevices: vi.fn(),
|
||||
deleteDevice: vi.fn(),
|
||||
login: vi.fn(),
|
||||
signup: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(m: string, public status: number) {
|
||||
super(m);
|
||||
}
|
||||
},
|
||||
setStoredTokens: vi.fn(),
|
||||
clearStoredTokens: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/redirect', () => ({ redirectTo: vi.fn() }));
|
||||
|
||||
function renderPlan(initialEntries: string[] = ['/plan']) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route path="/login" element={<div>login page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
const PLANS = {
|
||||
device_price: 100,
|
||||
addons: [
|
||||
{ id: 'dedicated-ip', name: 'Dedicated IP', price: 50, free_threshold: 0 },
|
||||
{ id: 'multi-hop', name: 'Multi-Hop', price: 30, free_threshold: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('PlanPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.getPlans).mockReset();
|
||||
vi.mocked(api.checkout).mockReset();
|
||||
useAuthStore.setState({
|
||||
access: null,
|
||||
refresh: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
remember: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('renders readout and breakdown from backend pricing', async () => {
|
||||
vi.mocked(api.getPlans).mockResolvedValue(PLANS);
|
||||
renderPlan();
|
||||
// default 6 devices -> monthly 600
|
||||
expect((await screen.findAllByText(/600/)).length).toBeGreaterThan(0);
|
||||
expect(api.getPlans).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('redirects to /login when confirming without auth', async () => {
|
||||
vi.mocked(api.getPlans).mockResolvedValue(PLANS);
|
||||
const user = userEvent.setup();
|
||||
renderPlan();
|
||||
await screen.findAllByText(/600/);
|
||||
await user.click(screen.getByRole('button', { name: /^Оформить$/i }));
|
||||
await waitFor(() => expect(screen.getByText('login page')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('creates checkout with chosen config when authed', async () => {
|
||||
vi.mocked(api.getPlans).mockResolvedValue(PLANS);
|
||||
vi.mocked(api.checkout).mockResolvedValue({
|
||||
order_id: 'ord-9',
|
||||
total_amount: 650,
|
||||
bonus_paid: 0,
|
||||
amount_to_pay: 650,
|
||||
payment_link: null,
|
||||
});
|
||||
useAuthStore.setState({
|
||||
access: 'a',
|
||||
refresh: 'r',
|
||||
expiresAt: 9999999999,
|
||||
user: { username: 'u', telegram_id: null, referal_code: 'c' },
|
||||
remember: true,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderPlan();
|
||||
await screen.findAllByText(/600/);
|
||||
// select an addon -> 600 + 50 = 650
|
||||
await user.click(screen.getByRole('button', { name: /Dedicated IP/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^Оформить$/i }));
|
||||
await waitFor(() => expect(api.checkout).toHaveBeenCalledOnce());
|
||||
const arg = vi.mocked(api.checkout).mock.calls[0]?.[0];
|
||||
expect(arg).toMatchObject({ devices: 6, addons: ['dedicated-ip'], duration_days: 30 });
|
||||
});
|
||||
|
||||
it('redirects to payment link when checkout returns one', async () => {
|
||||
vi.mocked(api.getPlans).mockResolvedValue(PLANS);
|
||||
vi.mocked(api.checkout).mockResolvedValue({
|
||||
order_id: 'ord-9',
|
||||
total_amount: 600,
|
||||
bonus_paid: 0,
|
||||
amount_to_pay: 600,
|
||||
payment_link: 'https://pay.example/abc',
|
||||
});
|
||||
useAuthStore.setState({
|
||||
access: 'a',
|
||||
refresh: 'r',
|
||||
expiresAt: 9999999999,
|
||||
user: { username: 'u', telegram_id: null, referal_code: 'c' },
|
||||
remember: true,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderPlan();
|
||||
await screen.findAllByText(/600/);
|
||||
await user.click(screen.getByRole('button', { name: /^Оформить$/i }));
|
||||
await waitFor(() => expect(api.checkout).toHaveBeenCalledOnce());
|
||||
expect(redirectTo).toHaveBeenCalledWith('https://pay.example/abc');
|
||||
});
|
||||
});
|
||||
229
src/pages/PlanPage.tsx
Normal file
229
src/pages/PlanPage.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { usePlans } from '../hooks/usePlans';
|
||||
import { useToastStore } from '../store/useToast';
|
||||
import { Shell } from '../components/Shell';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
import { Skeleton } from '../components/Skeleton';
|
||||
import { DeviceSlider } from '../components/DeviceSlider';
|
||||
import { DurationSelect } from '../components/DurationSelect';
|
||||
import { AddonList } from '../components/AddonList';
|
||||
import { Breakdown } from '../components/Breakdown';
|
||||
import {
|
||||
calculatePricing,
|
||||
devicesLabel,
|
||||
durationByDays,
|
||||
formatMoney,
|
||||
} from '../lib/format';
|
||||
import { MAX_DEVICES, MIN_DEVICES, resolvePlanConfig, savePlanConfig } from '../lib/planConfig';
|
||||
import { redirectTo } from '../lib/redirect';
|
||||
import type { CheckoutResponse } from '../types';
|
||||
|
||||
export function PlanPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const showToast = useToastStore((s) => s.show);
|
||||
const { data: plans, loading, error } = usePlans();
|
||||
const initialConfig = useMemo(() => resolvePlanConfig(searchParams), [searchParams]);
|
||||
|
||||
const [devices, setDevices] = useState<number>(initialConfig.devices);
|
||||
const [durationDays, setDurationDays] = useState<number>(initialConfig.durationDays);
|
||||
const [selectedAddons, setSelectedAddons] = useState<string[]>(initialConfig.addons);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirmation, setConfirmation] = useState<CheckoutResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!plans) return;
|
||||
const available = new Set(plans.addons.map((addon) => addon.id));
|
||||
setSelectedAddons((current) => current.filter((addon) => available.has(addon)));
|
||||
}, [plans]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new URLSearchParams();
|
||||
next.set('devices', String(devices));
|
||||
next.set('duration', String(durationDays));
|
||||
if (selectedAddons.length > 0) next.set('addons', selectedAddons.join(','));
|
||||
setSearchParams(next, { replace: true });
|
||||
savePlanConfig({ devices, durationDays, addons: selectedAddons });
|
||||
}, [devices, durationDays, selectedAddons, setSearchParams]);
|
||||
|
||||
const duration = useMemo(() => durationByDays(durationDays), [durationDays]);
|
||||
|
||||
const pricing = useMemo(() => {
|
||||
if (!plans) return null;
|
||||
return calculatePricing({
|
||||
pricing: plans,
|
||||
devices,
|
||||
addonIds: selectedAddons,
|
||||
duration,
|
||||
});
|
||||
}, [plans, devices, selectedAddons, duration]);
|
||||
|
||||
const toggleAddon = (id: string) => {
|
||||
setSelectedAddons((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!plans || !pricing) return;
|
||||
if (!isAuthenticated) {
|
||||
const returnTo = `/plan?${searchParams.toString()}`;
|
||||
navigate(`/login?returnTo=${encodeURIComponent(returnTo)}`);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await api.checkout({
|
||||
devices,
|
||||
addons: selectedAddons,
|
||||
duration_days: durationDays,
|
||||
});
|
||||
if (res.payment_link) {
|
||||
redirectTo(res.payment_link);
|
||||
return;
|
||||
}
|
||||
setConfirmation(res);
|
||||
showToast('Заказ оформлен');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof ApiError ? err.message : 'Не удалось оформить заказ';
|
||||
showToast(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell active="plan">
|
||||
<section className="plan-hero">
|
||||
<Reveal>
|
||||
<div className="kicker">Соберите свой план</div>
|
||||
<h1 className="hero-title">Круг, подогнанный под вас.</h1>
|
||||
<p className="hero-sub">
|
||||
Выберите, сколько устройств разделят вашу свободу. Цена меняется тихо — фиксированная
|
||||
ставка за каждое, без уровней и наценок.
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{confirmation ? (
|
||||
<section className="checkout-confirmation">
|
||||
<Reveal>
|
||||
<div className="kicker">Заказ оформлен</div>
|
||||
<h2 className="title">Оплата получена</h2>
|
||||
<p className="body">
|
||||
Сумма списана из бонусного баланса. Подписка активируется автоматически — мы пришлём
|
||||
уведомление в канал.
|
||||
</p>
|
||||
<div className="checkout-amount">{formatMoney(confirmation.amount_to_pay)}</div>
|
||||
<div className="checkout-order">Заказ №{confirmation.order_id}</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
) : (
|
||||
<section className="plan-calc">
|
||||
{loading || !plans || !pricing ? (
|
||||
<PlanSkeleton loading={loading} error={error} />
|
||||
) : (
|
||||
<>
|
||||
<Reveal>
|
||||
<div className="plan-calc-stage">
|
||||
<div className="calc-readout">
|
||||
<div className="readout-label">Оценка · за месяц</div>
|
||||
<div className="readout-value">
|
||||
<span>{formatMoney(pricing.monthly)}</span>
|
||||
<span className="unit">₽ / мес</span>
|
||||
</div>
|
||||
<div className="readout-note">
|
||||
{devicesLabel(devices)} · {formatMoney(plans.device_price)} за устройство
|
||||
</div>
|
||||
</div>
|
||||
<DeviceSlider
|
||||
min={MIN_DEVICES}
|
||||
max={MAX_DEVICES}
|
||||
value={devices}
|
||||
onChange={setDevices}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<DurationSelect days={durationDays} onChange={setDurationDays} />
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<Breakdown
|
||||
devices={devices}
|
||||
devicePrice={pricing.deviceCost}
|
||||
addonCount={selectedAddons.length}
|
||||
addonSum={pricing.addonCost}
|
||||
duration={duration}
|
||||
monthly={pricing.monthly}
|
||||
periodTotal={pricing.periodTotal}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<AddonList
|
||||
addons={plans.addons}
|
||||
selected={selectedAddons}
|
||||
onToggle={toggleAddon}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<div className="plan-cta">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{submitting ? 'Сохранение…' : 'Оформить'}
|
||||
</button>
|
||||
<div className="cta-note">
|
||||
{isAuthenticated
|
||||
? 'План вступит в силу при продлении · без блокировки'
|
||||
: 'Потребуется войти, чтобы оформить заказ'}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="plan-foot">
|
||||
<span>MaleniaVPN</span>
|
||||
<span>Предварительная оценка</span>
|
||||
</footer>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanSkeleton({ loading, error }: { loading: boolean; error: string | null }) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="plan-calc-stage" style={{ textAlign: 'center', padding: '64px 0' }}>
|
||||
<p className="readout-note">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!loading) return null;
|
||||
return (
|
||||
<div className="plan-calc-stage" style={{ gap: 56 }}>
|
||||
<div className="calc-readout">
|
||||
<Skeleton width="200px" height="14px" />
|
||||
<Skeleton width="280px" height="96px" style={{ margin: '18px auto' }} />
|
||||
<Skeleton width="220px" height="14px" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton width="100%" height="14px" />
|
||||
<Skeleton width="100%" height="88px" style={{ margin: '22px 0' }} />
|
||||
<Skeleton width="100%" height="14px" />
|
||||
</div>
|
||||
<Skeleton width="100%" height="120px" />
|
||||
<Skeleton width="100%" height="220px" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/store/useStore.ts
Normal file
106
src/store/useStore.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { create } from 'zustand';
|
||||
import type { LoginResponse, Tokens, UserInfo } from '../types';
|
||||
import { clearStoredTokens, setStoredTokens } from '../api/client';
|
||||
|
||||
interface AuthState {
|
||||
access: string | null;
|
||||
refresh: string | null;
|
||||
expiresAt: number | null;
|
||||
user: UserInfo | null;
|
||||
remember: boolean;
|
||||
setSession: (session: LoginResponse, remember: boolean) => void;
|
||||
setTokens: (tokens: Tokens) => void;
|
||||
updateUser: (user: UserInfo) => void;
|
||||
setRemember: (remember: boolean) => void;
|
||||
hydrate: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const ACCESS_KEY = 'malenia.access';
|
||||
const REFRESH_KEY = 'malenia.refresh';
|
||||
const EXPIRES_KEY = 'malenia.expires_at';
|
||||
const USER_KEY = 'malenia.user';
|
||||
const REMEMBER_KEY = 'malenia.remember';
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
access: null,
|
||||
refresh: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
remember: true,
|
||||
|
||||
setSession: (session, remember) => {
|
||||
setStoredTokens({
|
||||
access_token: session.access_token,
|
||||
refresh_token: session.refresh_token,
|
||||
expires_at: session.expires_at,
|
||||
});
|
||||
try {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(session.user));
|
||||
localStorage.setItem(REMEMBER_KEY, remember ? '1' : '0');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
set({
|
||||
access: session.access_token,
|
||||
refresh: session.refresh_token,
|
||||
expiresAt: session.expires_at,
|
||||
user: session.user,
|
||||
remember,
|
||||
});
|
||||
},
|
||||
|
||||
setTokens: (tokens) => {
|
||||
set({
|
||||
access: tokens.access_token,
|
||||
refresh: tokens.refresh_token,
|
||||
expiresAt: tokens.expires_at,
|
||||
});
|
||||
},
|
||||
|
||||
updateUser: (user) => {
|
||||
try {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
set({ user });
|
||||
},
|
||||
|
||||
setRemember: (remember) => set({ remember }),
|
||||
|
||||
hydrate: () => {
|
||||
try {
|
||||
const access = localStorage.getItem(ACCESS_KEY);
|
||||
const refresh = localStorage.getItem(REFRESH_KEY);
|
||||
const expiresRaw = localStorage.getItem(EXPIRES_KEY);
|
||||
const userRaw = localStorage.getItem(USER_KEY);
|
||||
const rememberRaw = localStorage.getItem(REMEMBER_KEY);
|
||||
const expiresAt = expiresRaw ? Number(expiresRaw) : null;
|
||||
const user = userRaw ? (JSON.parse(userRaw) as UserInfo) : null;
|
||||
set({
|
||||
access,
|
||||
refresh,
|
||||
expiresAt,
|
||||
user,
|
||||
remember: rememberRaw !== '0',
|
||||
});
|
||||
// If "remember" is off and tokens are expired, drop them.
|
||||
if (rememberRaw === '0' && expiresAt !== null && expiresAt * 1000 < Date.now()) {
|
||||
get().clear();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
clearStoredTokens();
|
||||
try {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
set({ access: null, refresh: null, expiresAt: null, user: null });
|
||||
},
|
||||
}));
|
||||
24
src/store/useToast.ts
Normal file
24
src/store/useToast.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: ToastItem[];
|
||||
show: (message: string) => void;
|
||||
dismiss: (id: number) => void;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export const useToastStore = create<ToastState>((set, get) => ({
|
||||
toasts: [],
|
||||
show: (message) => {
|
||||
const id = ++counter;
|
||||
set((s) => ({ toasts: [...s.toasts, { id, message }] }));
|
||||
window.setTimeout(() => get().dismiss(id), 2200);
|
||||
},
|
||||
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
}));
|
||||
93
src/styles/base.css
Normal file
93
src/styles/base.css
Normal file
@@ -0,0 +1,93 @@
|
||||
@import './tokens.css';
|
||||
|
||||
/* ── Grain overlay ───────────────────────────────────── */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
pointer-events: none;
|
||||
opacity: 0.035;
|
||||
mix-blend-mode: multiply;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat;
|
||||
background-size: 180px;
|
||||
}
|
||||
|
||||
/* ── Custom cursor ───────────────────────────────────── */
|
||||
.cursor-dot,
|
||||
.cursor-ring {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.cursor-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.cursor-ring {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1), height 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.25s, opacity 0.25s;
|
||||
}
|
||||
.cursor-ring.is-active {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-color: var(--accent-light);
|
||||
opacity: 0.9;
|
||||
}
|
||||
body.is-hovering .cursor-ring {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-color: var(--accent-light);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.cursor-dot,
|
||||
.cursor-ring {
|
||||
display: none;
|
||||
}
|
||||
* {
|
||||
cursor: auto !important;
|
||||
}
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
body,
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
[data-cursor] {
|
||||
cursor: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cursor-ring.is-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reveal animation ──────────────────────────────────── */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
transition: opacity 0.7s ease, transform 0.7s ease;
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
1177
src/styles/components.css
Normal file
1177
src/styles/components.css
Normal file
File diff suppressed because it is too large
Load Diff
535
src/styles/pages.css
Normal file
535
src/styles/pages.css
Normal file
@@ -0,0 +1,535 @@
|
||||
/* ── Landing ─────────────────────────────────────────────── */
|
||||
.landing-page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
.landing-header {
|
||||
padding: 2.5rem 4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.landing-header .header-rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0 2rem;
|
||||
}
|
||||
.landing-header nav {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.landing-header nav a {
|
||||
position: relative;
|
||||
padding: 4px 0;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.landing-header nav a:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.header-tag {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.landing-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 6rem 2rem;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
.landing-hero .emblem {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
}
|
||||
.quote-block {
|
||||
max-width: 640px;
|
||||
}
|
||||
.quote-text {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||
font-weight: 300;
|
||||
line-height: 1.25;
|
||||
color: var(--fg);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.quote-text em {
|
||||
font-style: italic;
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
.quote-author {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.landing-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
.landing-divider::before,
|
||||
.landing-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.cta-group {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.landing-footer {
|
||||
padding: 2rem 4rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.footer-copy {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.footer-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.landing-header {
|
||||
padding: 1.75rem 1.5rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.landing-header .header-rule {
|
||||
display: none;
|
||||
}
|
||||
.landing-header .header-tag {
|
||||
display: none;
|
||||
}
|
||||
.landing-header nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.landing-hero {
|
||||
padding: 4rem 1.5rem;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
.landing-footer {
|
||||
padding: 1.5rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.cta-group {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.cta-group .btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Auth (login) ───────────────────────────────────────── */
|
||||
.auth-shell {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 32px 48px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.auth-topbar {
|
||||
text-align: center;
|
||||
}
|
||||
.auth-topbar .kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.auth-emblem {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 28px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.auth-emblem svg {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.auth-card {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.auth-kicker {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.auth-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: 36px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
text-align: center;
|
||||
color: var(--fg);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.auth-sub {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
.auth-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 32px 0 22px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--hairline);
|
||||
}
|
||||
.auth-signup {
|
||||
text-align: center;
|
||||
margin-top: 36px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.auth-signup .link {
|
||||
color: var(--fg);
|
||||
}
|
||||
.auth-status {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
margin-top: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
min-height: 14px;
|
||||
}
|
||||
.auth-status.is-error {
|
||||
color: var(--fg);
|
||||
opacity: 1;
|
||||
}
|
||||
.auth-status.is-info {
|
||||
opacity: 1;
|
||||
}
|
||||
.auth-foot {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
opacity: 0.6;
|
||||
padding-top: 40px;
|
||||
}
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
margin-bottom: 32px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.auth-tab {
|
||||
flex: 1;
|
||||
padding: 12px 10px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
transition: background 0.25s, color 0.25s;
|
||||
}
|
||||
.auth-tab.is-active {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.auth-shell {
|
||||
padding: 40px 22px 36px;
|
||||
}
|
||||
.auth-title {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Plan shell ─────────────────────────────────────────── */
|
||||
.plan-shell,
|
||||
.account-shell {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 64px 32px 96px;
|
||||
}
|
||||
.plan-hero,
|
||||
.account-hero {
|
||||
padding: 64px 0 40px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.plan-hero .kicker,
|
||||
.account-hero .kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.plan-hero .hero-title,
|
||||
.account-hero .hero-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: 52px;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.plan-hero .hero-sub,
|
||||
.account-hero .hero-sub {
|
||||
margin: 18px auto 0;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
max-width: 46ch;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.plan-calc {
|
||||
padding: 72px 0 64px;
|
||||
}
|
||||
.plan-calc-stage {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 56px;
|
||||
align-items: center;
|
||||
}
|
||||
.calc-readout {
|
||||
text-align: center;
|
||||
}
|
||||
.readout-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.readout-value {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: 96px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--fg);
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.readout-value .unit {
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-left: 14px;
|
||||
align-self: center;
|
||||
}
|
||||
.readout-note {
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.plan-duration {
|
||||
margin-top: 56px;
|
||||
}
|
||||
.plan-breakdown {
|
||||
margin-top: 64px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
.plan-addons {
|
||||
margin-top: 64px;
|
||||
}
|
||||
.plan-cta {
|
||||
margin-top: 56px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.cta-note {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.plan-foot,
|
||||
.account-foot {
|
||||
margin-top: 64px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.plan-foot button {
|
||||
font: inherit;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
transition: color 0.25s;
|
||||
}
|
||||
.plan-foot button:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.readout-value {
|
||||
font-size: 76px;
|
||||
}
|
||||
.plan-hero .hero-title,
|
||||
.account-hero .hero-title {
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.plan-shell,
|
||||
.account-shell {
|
||||
padding: 40px 20px 64px;
|
||||
}
|
||||
.plan-hero,
|
||||
.account-hero {
|
||||
padding: 44px 0 32px;
|
||||
}
|
||||
.plan-hero .hero-title,
|
||||
.account-hero .hero-title {
|
||||
font-size: 34px;
|
||||
}
|
||||
.plan-calc {
|
||||
padding: 48px 0 40px;
|
||||
}
|
||||
.readout-value {
|
||||
font-size: 60px;
|
||||
}
|
||||
.readout-value .unit {
|
||||
font-size: 13px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.breakdown-total .breakdown-val {
|
||||
font-size: 30px;
|
||||
}
|
||||
.plan-foot,
|
||||
.account-foot {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.plan-cta .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Account hero specifics ─────────────────────────────── */
|
||||
.account-hero .hero-sub {
|
||||
max-width: none;
|
||||
}
|
||||
.checkout-confirmation {
|
||||
text-align: center;
|
||||
padding: 96px 0 64px;
|
||||
}
|
||||
.checkout-confirmation .kicker {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.checkout-confirmation .title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: 44px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.checkout-confirmation .body {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
max-width: 46ch;
|
||||
margin: 0 auto 32px;
|
||||
}
|
||||
.checkout-amount {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: 56px;
|
||||
line-height: 1;
|
||||
color: var(--fg);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.checkout-order {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
79
src/styles/tokens.css
Normal file
79
src/styles/tokens.css
Normal file
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
--bg: #faf7f2;
|
||||
--surface: #f5f0e8;
|
||||
--fg: #1a1610;
|
||||
--muted: #3d3428;
|
||||
--fg-muted: #7a6e5f;
|
||||
--accent: #c9a84c;
|
||||
--accent-light: #e8ca7a;
|
||||
--accent-dim: #8b6914;
|
||||
--hairline: rgba(26, 22, 16, 0.1);
|
||||
--hairline-strong: rgba(26, 22, 16, 0.18);
|
||||
--border: rgba(201, 168, 76, 0.25);
|
||||
|
||||
--font-display: 'Cormorant Garamond', Georgia, 'Times New Roman', serif;
|
||||
--font-body: 'Tenor Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
--radius: 8px;
|
||||
--baseline: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
8
src/test/setup.ts
Normal file
8
src/test/setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
78
src/types.ts
Normal file
78
src/types.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type Provider = 'credentials' | 'telegram' | 'api';
|
||||
|
||||
export interface UserInfo {
|
||||
username: string | null;
|
||||
telegram_id: string | null;
|
||||
referal_code: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: number;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
export interface Tokens {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface AddonData {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
free_threshold: number;
|
||||
}
|
||||
|
||||
export interface PricingPlans {
|
||||
device_price: number;
|
||||
addons: AddonData[];
|
||||
}
|
||||
|
||||
export interface OrderDetails {
|
||||
devices: number;
|
||||
addons: string[];
|
||||
duration_days: number;
|
||||
}
|
||||
|
||||
export interface CheckoutResponse {
|
||||
order_id: string;
|
||||
total_amount: number;
|
||||
bonus_paid: number;
|
||||
amount_to_pay: number;
|
||||
payment_link: string | null;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
os: string | null;
|
||||
model: string | null;
|
||||
client: string | null;
|
||||
hwid: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionData {
|
||||
devices: number | null;
|
||||
expires_at: string | null;
|
||||
addon_ids: string[];
|
||||
subscription_link: string | null;
|
||||
has_subscription?: boolean;
|
||||
}
|
||||
|
||||
export interface OperationResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface SignupPayload {
|
||||
provider: 'credentials';
|
||||
username: string;
|
||||
password: string;
|
||||
referal_code?: string | null;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
provider: 'credentials';
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
Reference in New Issue
Block a user