From 6749b89bbdfa1993d2144d36e2a1b00564d89f71 Mon Sep 17 00:00:00 2001 From: hexdev Date: Tue, 18 Aug 2026 21:31:17 +0700 Subject: [PATCH] feat: linking btn --- src/api/client.ts | 4 ++++ src/pages/AccountPage.test.tsx | 25 +++++++++++++++++++++++++ src/pages/AccountPage.tsx | 34 +++++++++++++++++++++++++++++----- src/styles/pages.css | 6 +++++- src/types.ts | 6 ++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index cfe0f90..2630ce3 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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 { + return request('/api/link-codes', { method: 'POST' }); + }, }; diff --git a/src/pages/AccountPage.test.tsx b/src/pages/AccountPage.test.tsx index fed3ccf..73395d3 100644 --- a/src/pages/AccountPage.test.tsx +++ b/src/pages/AccountPage.test.tsx @@ -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); diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx index 9d661f9..7451761 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -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 (
@@ -150,11 +164,21 @@ export function AccountPage() { ? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}` : 'Активная подписка отсутствует'}

- {subscription?.has_subscription ? ( - - ) : null} +
+ {subscription?.has_subscription ? ( + + ) : null} + +
) : null} diff --git a/src/styles/pages.css b/src/styles/pages.css index 0ec42c3..c13a356 100644 --- a/src/styles/pages.css +++ b/src/styles/pages.css @@ -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; diff --git a/src/types.ts b/src/types.ts index fac0eeb..ccf6ded 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;