diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 852bba4..a9f310d 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -9,6 +9,9 @@ interface ModalProps { body: React.ReactNode; confirmLabel: string; cancelLabel?: string; + confirmClassName?: string; + pending?: boolean; + onCancel?: () => void; onConfirm: () => void; } @@ -20,6 +23,9 @@ export function Modal({ body, confirmLabel, cancelLabel = 'Оставить', + confirmClassName = 'btn-danger', + pending = false, + onCancel, onConfirm, }: ModalProps) { useEffect(() => { @@ -59,13 +65,14 @@ export function Modal({
{body}
- diff --git a/src/pages/AccountPage.test.tsx b/src/pages/AccountPage.test.tsx index a924576..fed3ccf 100644 --- a/src/pages/AccountPage.test.tsx +++ b/src/pages/AccountPage.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; import { AccountPage } from './AccountPage'; import { api } from '../api/client'; import { useAuthStore } from '../store/useStore'; @@ -54,14 +54,21 @@ function renderAccount(authed = true) { } /> login page
} /> landing} /> + } /> , ); } +function PlanLocation() { + const location = useLocation(); + return
{`plan page${location.search}`}
; +} + const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 }; const SUB = { devices: 6, + duration_days: 90, expires_at: '2026-03-14T00:00:00Z', addon_ids: [], subscription_link: 'link', @@ -118,6 +125,44 @@ describe('AccountPage', () => { expect(screen.getByRole('button', { name: /копировать ссылку подписки/i })).toBeEnabled(); }); + it('creates checkout with current subscription settings when renewal is confirmed', async () => { + vi.mocked(api.getMe).mockResolvedValue(ME); + vi.mocked(api.getSubscription).mockResolvedValue(SUB); + vi.mocked(api.getDevices).mockResolvedValue(DEVICES); + vi.mocked(api.checkout).mockResolvedValue({ + order_id: 'order-1', + total_amount: 600, + bonus_paid: 0, + amount_to_pay: 600, + payment_link: null, + }); + const user = userEvent.setup(); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: /^продлить$/i })); + expect(await screen.findByText(/продлить с текущими параметрами/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /да, продлить/i })); + + await waitFor(() => + expect(api.checkout).toHaveBeenCalledWith({ devices: 6, addons: [], duration_days: 90 }), + ); + }); + + 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); + vi.mocked(api.getDevices).mockResolvedValue(DEVICES); + const user = userEvent.setup(); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: /^продлить$/i })); + await user.click(screen.getByRole('button', { name: /нет, настроить/i })); + + await waitFor(() => + expect(screen.getByText('plan page?devices=6&duration=90')).toBeInTheDocument(), + ); + }); + it('renders a referral link with copy and QR actions', 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 7ac1e63..9d661f9 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -12,6 +12,8 @@ import { DeviceCard } from '../components/DeviceCard'; import { Modal } from '../components/Modal'; import { SubscriptionQrModal } from '../components/SubscriptionQrModal'; import { ClientLinksModal } from '../components/ClientLinksModal'; +import { formatMoney } from '../lib/format'; +import { redirectTo } from '../lib/redirect'; import type { Device, SubscriptionData, UserInfo } from '../types'; export function AccountPage() { @@ -27,6 +29,8 @@ export function AccountPage() { const [isQrOpen, setIsQrOpen] = useState(false); const [isReferralQrOpen, setIsReferralQrOpen] = useState(false); const [isClientLinksOpen, setIsClientLinksOpen] = useState(false); + const [isRenewalOpen, setIsRenewalOpen] = useState(false); + const [isRenewing, setIsRenewing] = useState(false); useEffect(() => { if (!isAuthenticated) { @@ -98,18 +102,60 @@ export function AccountPage() { navigate('/', { replace: true }); }; + const openPlanWithCurrentConfig = () => { + if (!subscription) return; + const params = new URLSearchParams(); + if (subscription.devices) params.set('devices', String(subscription.devices)); + if (subscription.duration_days) params.set('duration', String(subscription.duration_days)); + if (subscription.addon_ids.length > 0) params.set('addons', subscription.addon_ids.join(',')); + navigate(`/plan?${params.toString()}`); + }; + + const renewCurrentPlan = async () => { + if (!subscription?.devices || !subscription.duration_days) { + openPlanWithCurrentConfig(); + return; + } + setIsRenewing(true); + try { + const result = await api.checkout({ + devices: subscription.devices, + addons: subscription.addon_ids, + duration_days: subscription.duration_days, + }); + if (result.payment_link) { + redirectTo(result.payment_link); + return; + } + setIsRenewalOpen(false); + showToast(`Заказ ${result.order_id} оформлен. К оплате: ${formatMoney(result.amount_to_pay)}`); + } catch (err: unknown) { + const message = err instanceof ApiError ? err.message : 'Не удалось оформить продление'; + showToast(message); + } finally { + setIsRenewing(false); + } + }; + return (
Ваш аккаунт

{userInfo?.username ?? 'Аккаунт'}

- {!loadingMeta ? ( -

- {subscription?.has_subscription && subscription.expires_at - ? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}` - : 'Активная подписка отсутствует'} -

+ {!loadingMeta ? ( + <> +

+ {subscription?.has_subscription && subscription.expires_at + ? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}` + : 'Активная подписка отсутствует'} +

+ {subscription?.has_subscription ? ( + + ) : null} + ) : null}
@@ -275,6 +321,19 @@ export function AccountPage() { confirmLabel="Удалить" onConfirm={confirmDelete} /> + setIsRenewalOpen(false)} + kicker="Продление" + title="Продлить с текущими параметрами?" + body="Мы повторим число устройств, дополнения и срок вашей текущей подписки." + confirmLabel={isRenewing ? 'Оформление…' : 'Да, продлить'} + cancelLabel="Нет, настроить" + confirmClassName="btn-primary" + pending={isRenewing} + onCancel={openPlanWithCurrentConfig} + onConfirm={() => void renewCurrentPlan()} + /> setIsQrOpen(false)} diff --git a/src/styles/pages.css b/src/styles/pages.css index 9cc6939..44471d0 100644 --- a/src/styles/pages.css +++ b/src/styles/pages.css @@ -491,6 +491,9 @@ .account-hero .hero-sub { max-width: none; } +.account-renew { + margin-top: 24px; +} .checkout-confirmation { text-align: center; padding: 96px 0 64px; diff --git a/src/types.ts b/src/types.ts index b59d929..463dac5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,6 +55,7 @@ export interface Device { export interface SubscriptionData { devices: number | null; + duration_days: number | null; expires_at: string | null; addon_ids: string[]; subscription_link: string | null;