feat: linking btn
This commit is contained in:
@@ -2,6 +2,7 @@ import { useAuthStore } from '../store/useStore';
|
||||
import type {
|
||||
CheckoutResponse,
|
||||
Device,
|
||||
LinkCodeResponse,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OperationResult,
|
||||
@@ -181,4 +182,7 @@ export const api = {
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
},
|
||||
createTelegramLinkCode(): Promise<LinkCodeResponse> {
|
||||
return request<LinkCodeResponse>('/api/link-codes', { method: 'POST' });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { AccountPage } from './AccountPage';
|
||||
import { api } from '../api/client';
|
||||
import { useAuthStore } from '../store/useStore';
|
||||
import { redirectTo } from '../lib/redirect';
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: { toDataURL: () => Promise.resolve('data:image/png;base64,qr') },
|
||||
@@ -18,6 +19,7 @@ vi.mock('../api/client', () => ({
|
||||
getSubscription: vi.fn(),
|
||||
getDevices: vi.fn(),
|
||||
deleteDevice: vi.fn(),
|
||||
createTelegramLinkCode: vi.fn(),
|
||||
login: vi.fn(),
|
||||
signup: vi.fn(),
|
||||
},
|
||||
@@ -30,6 +32,10 @@ vi.mock('../api/client', () => ({
|
||||
clearStoredTokens: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/redirect', () => ({
|
||||
redirectTo: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderAccount(authed = true) {
|
||||
if (authed) {
|
||||
useAuthStore.setState({
|
||||
@@ -85,6 +91,7 @@ describe('AccountPage', () => {
|
||||
vi.mocked(api.getSubscription).mockReset();
|
||||
vi.mocked(api.getDevices).mockReset();
|
||||
vi.mocked(api.deleteDevice).mockReset();
|
||||
vi.mocked(api.createTelegramLinkCode).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
@@ -148,6 +155,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 () => {
|
||||
vi.mocked(api.getMe).mockResolvedValue(ME);
|
||||
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
||||
|
||||
@@ -31,6 +31,7 @@ export function AccountPage() {
|
||||
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
|
||||
const [isRenewalOpen, setIsRenewalOpen] = useState(false);
|
||||
const [isRenewing, setIsRenewing] = useState(false);
|
||||
const [isLinkingTelegram, setIsLinkingTelegram] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<Shell active="account">
|
||||
<section className="account-hero">
|
||||
@@ -150,11 +164,21 @@ export function AccountPage() {
|
||||
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
||||
: 'Активная подписка отсутствует'}
|
||||
</p>
|
||||
<div className="account-actions">
|
||||
{subscription?.has_subscription ? (
|
||||
<button type="button" className="btn btn-primary account-renew" onClick={() => setIsRenewalOpen(true)}>
|
||||
<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}
|
||||
</Reveal>
|
||||
|
||||
@@ -522,8 +522,12 @@
|
||||
.account-hero .hero-sub {
|
||||
max-width: none;
|
||||
}
|
||||
.account-renew {
|
||||
.account-actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.checkout-confirmation {
|
||||
text-align: center;
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface OperationResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface LinkCodeResponse {
|
||||
code: string;
|
||||
expires_at: string;
|
||||
deep_link: string;
|
||||
}
|
||||
|
||||
export interface SignupPayload {
|
||||
provider: 'credentials';
|
||||
username: string;
|
||||
|
||||
Reference in New Issue
Block a user