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;