feat: linking btn
This commit is contained in:
@@ -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' });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user