feat: renewal btn
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +102,41 @@ 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">
|
||||
@@ -105,11 +144,18 @@ export function AccountPage() {
|
||||
<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>
|
||||
{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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user