Compare commits
2 Commits
8a9248d337
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c7255b759 | |||
| 6749b89bbd |
File diff suppressed because one or more lines are too long
@@ -1,9 +1,10 @@
|
|||||||
import { useAuthStore } from '../store/useStore';
|
import { useAuthStore } from '../store/useStore';
|
||||||
import type {
|
import type {
|
||||||
|
AuthenticatedUser,
|
||||||
CheckoutResponse,
|
CheckoutResponse,
|
||||||
Device,
|
Device,
|
||||||
|
LinkCodeResponse,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginResponse,
|
|
||||||
OperationResult,
|
OperationResult,
|
||||||
OrderDetails,
|
OrderDetails,
|
||||||
PricingPlans,
|
PricingPlans,
|
||||||
@@ -154,11 +155,11 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|||||||
|
|
||||||
/* ── Public API ───────────────────────────────────────────── */
|
/* ── Public API ───────────────────────────────────────────── */
|
||||||
export const api = {
|
export const api = {
|
||||||
signup(payload: SignupPayload): Promise<LoginResponse> {
|
signup(payload: SignupPayload): Promise<AuthenticatedUser> {
|
||||||
return request<LoginResponse>('/api/auth/signup', { method: 'POST', body: payload, auth: false });
|
return request<AuthenticatedUser>('/api/auth/signup', { method: 'POST', body: payload, auth: false });
|
||||||
},
|
},
|
||||||
login(payload: LoginPayload): Promise<LoginResponse> {
|
login(payload: LoginPayload): Promise<AuthenticatedUser> {
|
||||||
return request<LoginResponse>('/api/auth/login', { method: 'POST', body: payload, auth: false });
|
return request<AuthenticatedUser>('/api/auth/login', { method: 'POST', body: payload, auth: false });
|
||||||
},
|
},
|
||||||
getPlans(): Promise<PricingPlans> {
|
getPlans(): Promise<PricingPlans> {
|
||||||
return request<PricingPlans>('/api/plans/', { auth: false });
|
return request<PricingPlans>('/api/plans/', { auth: false });
|
||||||
@@ -181,4 +182,7 @@ export const api = {
|
|||||||
{ method: 'DELETE' },
|
{ method: 'DELETE' },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
createTelegramLinkCode(): Promise<LinkCodeResponse> {
|
||||||
|
return request<LinkCodeResponse>('/api/link-codes', { method: 'POST' });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function DeviceSlider({ min, max, value, onChange }: DeviceSliderProps) {
|
|||||||
<div className="slider-axis">
|
<div className="slider-axis">
|
||||||
<span>Устройства</span>
|
<span>Устройства</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="axis-now">{value}</span> мест
|
<span className="axis-now">{value}</span> устройств
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="slider-track-frame" ref={frameRef} onClick={onFrameClick}>
|
<div className="slider-track-frame" ref={frameRef} onClick={onFrameClick}>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
|||||||
const telegramId = user?.telegram_id ?? null;
|
const telegramId = user?.telegram_id ?? null;
|
||||||
const devicesLimit = subscription?.devices ?? null;
|
const devicesLimit = subscription?.devices ?? null;
|
||||||
const expiresAt = subscription?.expires_at ?? null;
|
const expiresAt = subscription?.expires_at ?? null;
|
||||||
|
const isExpired = expiresAt !== null && new Date(expiresAt).getTime() < Date.now();
|
||||||
const bonusBalance = user?.bonus_balance ?? 0;
|
const bonusBalance = user?.bonus_balance ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,7 +58,7 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
|||||||
}
|
}
|
||||||
label="Telegram ID"
|
label="Telegram ID"
|
||||||
>
|
>
|
||||||
{telegramId ? <span className="is-mono">@{telegramId}</span> : <span className="is-empty">не привязан</span>}
|
{telegramId ? <span className="is-mono">{telegramId}</span> : <span className="is-empty">не привязан</span>}
|
||||||
</Stat>
|
</Stat>
|
||||||
|
|
||||||
<Stat
|
<Stat
|
||||||
@@ -86,9 +87,13 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
|||||||
<path d="M3 9h18M8 3v4M16 3v4" />
|
<path d="M3 9h18M8 3v4M16 3v4" />
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
label="Продление"
|
label="Подписка до"
|
||||||
>
|
>
|
||||||
{expiresAt ? <span>{formatDate(expiresAt)}</span> : <span className="is-empty">—</span>}
|
{expiresAt ? (
|
||||||
|
<span>{isExpired ? 'Истекла' : formatDate(expiresAt)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="is-empty">—</span>
|
||||||
|
)}
|
||||||
</Stat>
|
</Stat>
|
||||||
|
|
||||||
<Stat
|
<Stat
|
||||||
|
|||||||
@@ -53,17 +53,16 @@ export function SubscriptionQrModal({
|
|||||||
className="modal-veil is-open"
|
className="modal-veil is-open"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="subscription-qr-title"
|
aria-label="Откройте на устройстве"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target === event.currentTarget) onClose();
|
if (event.target === event.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="modal subscription-qr-modal" role="document">
|
<div className="modal subscription-qr-modal" role="document">
|
||||||
<div className="modal-kicker">{label}</div>
|
<div className="modal-kicker">Сканировать</div>
|
||||||
<h3 className="modal-title" id="subscription-qr-title">
|
<h3 className="modal-title" id="subscription-qr-title">
|
||||||
Откройте на устройстве
|
{label}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="modal-body">Отсканируйте код камерой или приложением Malenia.</p>
|
|
||||||
<div className="subscription-qr-image">
|
<div className="subscription-qr-image">
|
||||||
{image ? <img src={image} alt={imageAlt} /> : <span>Генерируем QR-код</span>}
|
{image ? <img src={image} alt={imageAlt} /> : <span>Генерируем QR-код</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
|
|||||||
import { AccountPage } from './AccountPage';
|
import { AccountPage } from './AccountPage';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useAuthStore } from '../store/useStore';
|
import { useAuthStore } from '../store/useStore';
|
||||||
|
import { redirectTo } from '../lib/redirect';
|
||||||
|
|
||||||
vi.mock('qrcode', () => ({
|
vi.mock('qrcode', () => ({
|
||||||
default: { toDataURL: () => Promise.resolve('data:image/png;base64,qr') },
|
default: { toDataURL: () => Promise.resolve('data:image/png;base64,qr') },
|
||||||
@@ -18,6 +19,7 @@ vi.mock('../api/client', () => ({
|
|||||||
getSubscription: vi.fn(),
|
getSubscription: vi.fn(),
|
||||||
getDevices: vi.fn(),
|
getDevices: vi.fn(),
|
||||||
deleteDevice: vi.fn(),
|
deleteDevice: vi.fn(),
|
||||||
|
createTelegramLinkCode: vi.fn(),
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
signup: vi.fn(),
|
signup: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -30,13 +32,17 @@ vi.mock('../api/client', () => ({
|
|||||||
clearStoredTokens: vi.fn(),
|
clearStoredTokens: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/redirect', () => ({
|
||||||
|
redirectTo: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
function renderAccount(authed = true) {
|
function renderAccount(authed = true) {
|
||||||
if (authed) {
|
if (authed) {
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
access: 'a',
|
access: 'a',
|
||||||
refresh: 'r',
|
refresh: 'r',
|
||||||
expiresAt: 9999999999,
|
expiresAt: 9999999999,
|
||||||
user: { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 },
|
user: { username: 'alice', telegram_id: 123456789, referal_code: 'c', bonus_balance: 150 },
|
||||||
remember: true,
|
remember: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -65,11 +71,11 @@ function PlanLocation() {
|
|||||||
return <div>{`plan page${location.search}`}</div>;
|
return <div>{`plan page${location.search}`}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 };
|
const ME = { username: 'alice', telegram_id: 123456789, referal_code: 'c', bonus_balance: 150 };
|
||||||
const SUB = {
|
const SUB = {
|
||||||
devices: 6,
|
devices: 6,
|
||||||
duration_days: 90,
|
duration_days: 90,
|
||||||
expires_at: '2026-03-14T00:00:00Z',
|
expires_at: '2099-03-14T00:00:00Z',
|
||||||
addon_ids: [],
|
addon_ids: [],
|
||||||
subscription_link: 'link',
|
subscription_link: 'link',
|
||||||
has_subscription: true,
|
has_subscription: true,
|
||||||
@@ -85,6 +91,7 @@ describe('AccountPage', () => {
|
|||||||
vi.mocked(api.getSubscription).mockReset();
|
vi.mocked(api.getSubscription).mockReset();
|
||||||
vi.mocked(api.getDevices).mockReset();
|
vi.mocked(api.getDevices).mockReset();
|
||||||
vi.mocked(api.deleteDevice).mockReset();
|
vi.mocked(api.deleteDevice).mockReset();
|
||||||
|
vi.mocked(api.createTelegramLinkCode).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
@@ -100,7 +107,7 @@ describe('AccountPage', () => {
|
|||||||
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||||
renderAccount();
|
renderAccount();
|
||||||
expect(await screen.findByText('alice')).toBeInTheDocument();
|
expect(await screen.findByText('alice')).toBeInTheDocument();
|
||||||
expect(screen.getByText('@alice_id')).toBeInTheDocument();
|
expect(screen.getByText('123456789')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/150\s*₽/)).toBeInTheDocument();
|
expect(screen.getByText(/150\s*₽/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/только на подписку/i)).toBeInTheDocument();
|
expect(screen.getByText(/только на подписку/i)).toBeInTheDocument();
|
||||||
expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1);
|
expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1);
|
||||||
@@ -113,7 +120,17 @@ describe('AccountPage', () => {
|
|||||||
renderAccount();
|
renderAccount();
|
||||||
|
|
||||||
expect(screen.queryByText(/активная подписка отсутствует/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/активная подписка отсутствует/i)).not.toBeInTheDocument();
|
||||||
expect(await screen.findByText(/подписка до/i)).toBeInTheDocument();
|
expect(await screen.findAllByText(/подписка до/i)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders expired subscription status', async () => {
|
||||||
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||||
|
vi.mocked(api.getSubscription).mockResolvedValue({ ...SUB, expires_at: '2020-01-01T00:00:00Z' });
|
||||||
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
expect(await screen.findAllByText('Истекла')).toHaveLength(2);
|
||||||
|
expect(screen.queryByText(/^Подписка до \d/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the subscription link and copy action', async () => {
|
it('renders the subscription link and copy action', async () => {
|
||||||
@@ -148,6 +165,24 @@ describe('AccountPage', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requests a Telegram link code and follows its deeplink', async () => {
|
||||||
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||||
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||||
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||||
|
vi.mocked(api.createTelegramLinkCode).mockResolvedValue({
|
||||||
|
code: 'code-1',
|
||||||
|
expires_at: '2026-03-14T00:00:00Z',
|
||||||
|
deep_link: 'https://t.me/malenia_bot?start=code-1',
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderAccount();
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: /привязать telegram/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.createTelegramLinkCode).toHaveBeenCalledOnce());
|
||||||
|
expect(redirectTo).toHaveBeenCalledWith('https://t.me/malenia_bot?start=code-1');
|
||||||
|
});
|
||||||
|
|
||||||
it('opens the plan page with current subscription settings when renewal is adjusted', async () => {
|
it('opens the plan page with current subscription settings when renewal is adjusted', async () => {
|
||||||
vi.mocked(api.getMe).mockResolvedValue(ME);
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||||
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function AccountPage() {
|
|||||||
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
|
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
|
||||||
const [isRenewalOpen, setIsRenewalOpen] = useState(false);
|
const [isRenewalOpen, setIsRenewalOpen] = useState(false);
|
||||||
const [isRenewing, setIsRenewing] = useState(false);
|
const [isRenewing, setIsRenewing] = useState(false);
|
||||||
|
const [isLinkingTelegram, setIsLinkingTelegram] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -137,6 +138,19 @@ export function AccountPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const linkTelegram = async () => {
|
||||||
|
setIsLinkingTelegram(true);
|
||||||
|
try {
|
||||||
|
const { deep_link } = await api.createTelegramLinkCode();
|
||||||
|
redirectTo(deep_link);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof ApiError ? err.message : 'Не удалось начать привязку Telegram';
|
||||||
|
showToast(message);
|
||||||
|
} finally {
|
||||||
|
setIsLinkingTelegram(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell active="account">
|
<Shell active="account">
|
||||||
<section className="account-hero">
|
<section className="account-hero">
|
||||||
@@ -145,16 +159,28 @@ export function AccountPage() {
|
|||||||
<h1 className="hero-title">{userInfo?.username ?? 'Аккаунт'}</h1>
|
<h1 className="hero-title">{userInfo?.username ?? 'Аккаунт'}</h1>
|
||||||
{!loadingMeta ? (
|
{!loadingMeta ? (
|
||||||
<>
|
<>
|
||||||
<p className="hero-sub">
|
<p className="hero-sub">
|
||||||
{subscription?.has_subscription && subscription.expires_at
|
{subscription?.has_subscription && subscription.expires_at
|
||||||
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
? new Date(subscription.expires_at).getTime() < Date.now()
|
||||||
: 'Активная подписка отсутствует'}
|
? 'Истекла'
|
||||||
</p>
|
: `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
||||||
{subscription?.has_subscription ? (
|
: 'Активная подписка отсутствует'}
|
||||||
<button type="button" className="btn btn-primary account-renew" onClick={() => setIsRenewalOpen(true)}>
|
</p>
|
||||||
Продлить
|
<div className="account-actions">
|
||||||
</button>
|
{subscription?.has_subscription ? (
|
||||||
) : null}
|
<button type="button" className="btn btn-primary" onClick={() => setIsRenewalOpen(true)}>
|
||||||
|
Продлить
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={isLinkingTelegram}
|
||||||
|
onClick={() => void linkTelegram()}
|
||||||
|
>
|
||||||
|
{isLinkingTelegram ? 'Подготовка…' : 'Привязать Telegram'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -100,9 +100,9 @@ export function PlanPage() {
|
|||||||
<section className="plan-hero">
|
<section className="plan-hero">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="kicker">Соберите свой план</div>
|
<div className="kicker">Соберите свой план</div>
|
||||||
<h1 className="hero-title">Круг, подогнанный под вас.</h1>
|
<h1 className="hero-title">Платите только за то, что используете.</h1>
|
||||||
<p className="hero-sub">
|
<p className="hero-sub">
|
||||||
Выберите, сколько устройств разделят вашу свободу. Цена меняется тихо — фиксированная
|
Выберите, сколько устройств разделят вашу свободу. Цена меняется — фиксированная
|
||||||
ставка за каждое, без уровней и наценок.
|
ставка за каждое, без уровней и наценок.
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { LoginResponse, Tokens, UserInfo } from '../types';
|
import type { AuthenticatedUser, Tokens, UserInfo } from '../types';
|
||||||
import { clearStoredTokens, setStoredTokens } from '../api/client';
|
import { clearStoredTokens, setStoredTokens } from '../api/client';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -8,7 +8,7 @@ interface AuthState {
|
|||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
user: UserInfo | null;
|
user: UserInfo | null;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
setSession: (session: LoginResponse, remember: boolean) => void;
|
setSession: (session: AuthenticatedUser, remember: boolean) => void;
|
||||||
setTokens: (tokens: Tokens) => void;
|
setTokens: (tokens: Tokens) => void;
|
||||||
updateUser: (user: UserInfo) => void;
|
updateUser: (user: UserInfo) => void;
|
||||||
setRemember: (remember: boolean) => void;
|
setRemember: (remember: boolean) => void;
|
||||||
|
|||||||
@@ -522,8 +522,12 @@
|
|||||||
.account-hero .hero-sub {
|
.account-hero .hero-sub {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
.account-renew {
|
.account-actions {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.checkout-confirmation {
|
.checkout-confirmation {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
13
src/types.ts
13
src/types.ts
@@ -2,18 +2,21 @@ export type Provider = 'credentials' | 'telegram' | 'api';
|
|||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
username: string | null;
|
username: string | null;
|
||||||
telegram_id: string | null;
|
telegram_id: number | null;
|
||||||
referal_code: string;
|
referal_code: string;
|
||||||
bonus_balance: number;
|
bonus_balance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface AuthenticatedUser {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
expires_at: number;
|
expires_at: number;
|
||||||
user: UserInfo;
|
user: UserInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Переименовано в AuthenticatedUser вслед за бэкендом. */
|
||||||
|
export type LoginResponse = AuthenticatedUser;
|
||||||
|
|
||||||
export interface Tokens {
|
export interface Tokens {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
@@ -67,6 +70,12 @@ export interface OperationResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkCodeResponse {
|
||||||
|
code: string;
|
||||||
|
expires_at: string;
|
||||||
|
deep_link: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignupPayload {
|
export interface SignupPayload {
|
||||||
provider: 'credentials';
|
provider: 'credentials';
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user