feat: linking btn

This commit is contained in:
2026-08-18 21:31:17 +07:00
parent 8a9248d337
commit 6749b89bbd
5 changed files with 69 additions and 6 deletions

View File

@@ -2,6 +2,7 @@ import { useAuthStore } from '../store/useStore';
import type { import type {
CheckoutResponse, CheckoutResponse,
Device, Device,
LinkCodeResponse,
LoginPayload, LoginPayload,
LoginResponse, LoginResponse,
OperationResult, OperationResult,
@@ -181,4 +182,7 @@ export const api = {
{ method: 'DELETE' }, { method: 'DELETE' },
); );
}, },
createTelegramLinkCode(): Promise<LinkCodeResponse> {
return request<LinkCodeResponse>('/api/link-codes', { method: 'POST' });
},
}; };

View File

@@ -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,6 +32,10 @@ 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({
@@ -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());
@@ -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 () => { 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);

View File

@@ -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">
@@ -150,11 +164,21 @@ export function AccountPage() {
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}` ? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
: 'Активная подписка отсутствует'} : 'Активная подписка отсутствует'}
</p> </p>
{subscription?.has_subscription ? ( <div className="account-actions">
<button type="button" className="btn btn-primary account-renew" onClick={() => setIsRenewalOpen(true)}> {subscription?.has_subscription ? (
Продлить <button type="button" className="btn btn-primary" onClick={() => setIsRenewalOpen(true)}>
</button> Продлить
) : null} </button>
) : null}
<button
type="button"
className="btn btn-secondary"
disabled={isLinkingTelegram}
onClick={() => void linkTelegram()}
>
{isLinkingTelegram ? 'Подготовка…' : 'Привязать Telegram'}
</button>
</div>
</> </>
) : null} ) : null}
</Reveal> </Reveal>

View File

@@ -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;

View File

@@ -67,6 +67,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;