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}
-
} />
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 ? (
+ setIsRenewalOpen(true)}>
+ Продлить
+
+ ) : 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;