This commit is contained in:
2026-08-10 12:01:03 +07:00
commit 265b1202ad
95 changed files with 27718 additions and 0 deletions

44
src/App.tsx Normal file
View 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
View 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
View 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' },
);
},
};

View 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>
);
}

View 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
View 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;
}

View 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>
);
}

View 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> &nbsp;мест
</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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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"
/>
);
}

View 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
View 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
View 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
View 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
View 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 };
}

View 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
View 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
View 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
View 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, 'устройство', 'устройства', 'устройств')}`;
}

View 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
View 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
View File

@@ -0,0 +1,3 @@
export function redirectTo(url: string): void {
window.location.assign(url);
}

12
src/main.tsx Normal file
View 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>,
);

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

535
src/styles/pages.css Normal file
View 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
View 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
View 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
View 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;
}