feat(frontend): align with API schema updates and improve subscription UX
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,10 +1,10 @@
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
import type {
|
||||
AuthenticatedUser,
|
||||
CheckoutResponse,
|
||||
Device,
|
||||
LinkCodeResponse,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OperationResult,
|
||||
OrderDetails,
|
||||
PricingPlans,
|
||||
@@ -155,11 +155,11 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
|
||||
/* ── Public API ───────────────────────────────────────────── */
|
||||
export const api = {
|
||||
signup(payload: SignupPayload): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('/api/auth/signup', { method: 'POST', body: payload, auth: false });
|
||||
signup(payload: SignupPayload): Promise<AuthenticatedUser> {
|
||||
return request<AuthenticatedUser>('/api/auth/signup', { method: 'POST', body: payload, auth: false });
|
||||
},
|
||||
login(payload: LoginPayload): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('/api/auth/login', { method: 'POST', body: payload, auth: false });
|
||||
login(payload: LoginPayload): Promise<AuthenticatedUser> {
|
||||
return request<AuthenticatedUser>('/api/auth/login', { method: 'POST', body: payload, auth: false });
|
||||
},
|
||||
getPlans(): Promise<PricingPlans> {
|
||||
return request<PricingPlans>('/api/plans/', { auth: false });
|
||||
|
||||
@@ -117,7 +117,7 @@ export function DeviceSlider({ min, max, value, onChange }: DeviceSliderProps) {
|
||||
<div className="slider-axis">
|
||||
<span>Устройства</span>
|
||||
<span>
|
||||
<span className="axis-now">{value}</span> мест
|
||||
<span className="axis-now">{value}</span> устройств
|
||||
</span>
|
||||
</div>
|
||||
<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 devicesLimit = subscription?.devices ?? null;
|
||||
const expiresAt = subscription?.expires_at ?? null;
|
||||
const isExpired = expiresAt !== null && new Date(expiresAt).getTime() < Date.now();
|
||||
const bonusBalance = user?.bonus_balance ?? 0;
|
||||
|
||||
return (
|
||||
@@ -57,7 +58,7 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
||||
}
|
||||
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
|
||||
@@ -86,9 +87,13 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) {
|
||||
<path d="M3 9h18M8 3v4M16 3v4" />
|
||||
</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
|
||||
|
||||
@@ -53,17 +53,16 @@ export function SubscriptionQrModal({
|
||||
className="modal-veil is-open"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="subscription-qr-title"
|
||||
aria-label="Откройте на устройстве"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
Откройте на устройстве
|
||||
{label}
|
||||
</h3>
|
||||
<p className="modal-body">Отсканируйте код камерой или приложением Malenia.</p>
|
||||
<div className="subscription-qr-image">
|
||||
{image ? <img src={image} alt={imageAlt} /> : <span>Генерируем QR-код</span>}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ function renderAccount(authed = true) {
|
||||
access: 'a',
|
||||
refresh: 'r',
|
||||
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,
|
||||
});
|
||||
} else {
|
||||
@@ -71,11 +71,11 @@ function PlanLocation() {
|
||||
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 = {
|
||||
devices: 6,
|
||||
duration_days: 90,
|
||||
expires_at: '2026-03-14T00:00:00Z',
|
||||
expires_at: '2099-03-14T00:00:00Z',
|
||||
addon_ids: [],
|
||||
subscription_link: 'link',
|
||||
has_subscription: true,
|
||||
@@ -107,7 +107,7 @@ describe('AccountPage', () => {
|
||||
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
||||
renderAccount();
|
||||
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(/только на подписку/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1);
|
||||
@@ -120,7 +120,17 @@ describe('AccountPage', () => {
|
||||
renderAccount();
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -161,7 +161,9 @@ export function AccountPage() {
|
||||
<>
|
||||
<p className="hero-sub">
|
||||
{subscription?.has_subscription && subscription.expires_at
|
||||
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
||||
? new Date(subscription.expires_at).getTime() < Date.now()
|
||||
? 'Истекла'
|
||||
: `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
||||
: 'Активная подписка отсутствует'}
|
||||
</p>
|
||||
<div className="account-actions">
|
||||
|
||||
@@ -100,9 +100,9 @@ export function PlanPage() {
|
||||
<section className="plan-hero">
|
||||
<Reveal>
|
||||
<div className="kicker">Соберите свой план</div>
|
||||
<h1 className="hero-title">Круг, подогнанный под вас.</h1>
|
||||
<h1 className="hero-title">Платите только за то, что используете.</h1>
|
||||
<p className="hero-sub">
|
||||
Выберите, сколько устройств разделят вашу свободу. Цена меняется тихо — фиксированная
|
||||
Выберите, сколько устройств разделят вашу свободу. Цена меняется — фиксированная
|
||||
ставка за каждое, без уровней и наценок.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { LoginResponse, Tokens, UserInfo } from '../types';
|
||||
import type { AuthenticatedUser, Tokens, UserInfo } from '../types';
|
||||
import { clearStoredTokens, setStoredTokens } from '../api/client';
|
||||
|
||||
interface AuthState {
|
||||
@@ -8,7 +8,7 @@ interface AuthState {
|
||||
expiresAt: number | null;
|
||||
user: UserInfo | null;
|
||||
remember: boolean;
|
||||
setSession: (session: LoginResponse, remember: boolean) => void;
|
||||
setSession: (session: AuthenticatedUser, remember: boolean) => void;
|
||||
setTokens: (tokens: Tokens) => void;
|
||||
updateUser: (user: UserInfo) => void;
|
||||
setRemember: (remember: boolean) => void;
|
||||
|
||||
@@ -2,18 +2,21 @@ export type Provider = 'credentials' | 'telegram' | 'api';
|
||||
|
||||
export interface UserInfo {
|
||||
username: string | null;
|
||||
telegram_id: string | null;
|
||||
telegram_id: number | null;
|
||||
referal_code: string;
|
||||
bonus_balance: number;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
export interface AuthenticatedUser {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: number;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
/** @deprecated Переименовано в AuthenticatedUser вслед за бэкендом. */
|
||||
export type LoginResponse = AuthenticatedUser;
|
||||
|
||||
export interface Tokens {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
|
||||
Reference in New Issue
Block a user