feat: renewal btn

This commit is contained in:
2026-08-17 21:34:44 +07:00
parent 4e788312ab
commit f2940e55bd
5 changed files with 124 additions and 9 deletions

View File

@@ -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({
</h3>
<div className="modal-body">{body}</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose} autoFocus>
<button type="button" className="btn" onClick={onCancel ?? onClose} disabled={pending} autoFocus>
{cancelLabel}
</button>
<button
type="button"
className="btn btn-danger"
className={`btn ${confirmClassName}`}
onClick={onConfirm}
disabled={pending}
>
{confirmLabel}
</button>

View File

@@ -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) {
<Route path="/account" element={<AccountPage />} />
<Route path="/login" element={<div>login page</div>} />
<Route path="/" element={<div>landing</div>} />
<Route path="/plan" element={<PlanLocation />} />
</Routes>
</MemoryRouter>,
);
}
function PlanLocation() {
const location = useLocation();
return <div>{`plan page${location.search}`}</div>;
}
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);

View File

@@ -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 (
<Shell active="account">
<section className="account-hero">
<Reveal>
<div className="kicker">Ваш аккаунт</div>
<h1 className="hero-title">{userInfo?.username ?? 'Аккаунт'}</h1>
{!loadingMeta ? (
<p className="hero-sub">
{subscription?.has_subscription && subscription.expires_at
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
: 'Активная подписка отсутствует'}
</p>
{!loadingMeta ? (
<>
<p className="hero-sub">
{subscription?.has_subscription && subscription.expires_at
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
: 'Активная подписка отсутствует'}
</p>
{subscription?.has_subscription ? (
<button type="button" className="btn btn-primary account-renew" onClick={() => setIsRenewalOpen(true)}>
Продлить
</button>
) : null}
</>
) : null}
</Reveal>
</section>
@@ -275,6 +321,19 @@ export function AccountPage() {
confirmLabel="Удалить"
onConfirm={confirmDelete}
/>
<Modal
open={isRenewalOpen}
onClose={() => setIsRenewalOpen(false)}
kicker="Продление"
title="Продлить с текущими параметрами?"
body="Мы повторим число устройств, дополнения и срок вашей текущей подписки."
confirmLabel={isRenewing ? 'Оформление…' : 'Да, продлить'}
cancelLabel="Нет, настроить"
confirmClassName="btn-primary"
pending={isRenewing}
onCancel={openPlanWithCurrentConfig}
onConfirm={() => void renewCurrentPlan()}
/>
<SubscriptionQrModal
link={isQrOpen ? subscription?.subscription_link ?? null : null}
onClose={() => setIsQrOpen(false)}

View File

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

View File

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