feat: renewal btn
This commit is contained in:
@@ -9,6 +9,9 @@ interface ModalProps {
|
|||||||
body: React.ReactNode;
|
body: React.ReactNode;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
cancelLabel?: string;
|
cancelLabel?: string;
|
||||||
|
confirmClassName?: string;
|
||||||
|
pending?: boolean;
|
||||||
|
onCancel?: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +23,9 @@ export function Modal({
|
|||||||
body,
|
body,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
cancelLabel = 'Оставить',
|
cancelLabel = 'Оставить',
|
||||||
|
confirmClassName = 'btn-danger',
|
||||||
|
pending = false,
|
||||||
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,13 +65,14 @@ export function Modal({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="modal-body">{body}</div>
|
<div className="modal-body">{body}</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button type="button" className="btn" onClick={onClose} autoFocus>
|
<button type="button" className="btn" onClick={onCancel ?? onClose} disabled={pending} autoFocus>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger"
|
className={`btn ${confirmClassName}`}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
|
disabled={pending}
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
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 { AccountPage } from './AccountPage';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useAuthStore } from '../store/useStore';
|
import { useAuthStore } from '../store/useStore';
|
||||||
@@ -54,14 +54,21 @@ function renderAccount(authed = true) {
|
|||||||
<Route path="/account" element={<AccountPage />} />
|
<Route path="/account" element={<AccountPage />} />
|
||||||
<Route path="/login" element={<div>login page</div>} />
|
<Route path="/login" element={<div>login page</div>} />
|
||||||
<Route path="/" element={<div>landing</div>} />
|
<Route path="/" element={<div>landing</div>} />
|
||||||
|
<Route path="/plan" element={<PlanLocation />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>,
|
</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 ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 };
|
||||||
const SUB = {
|
const SUB = {
|
||||||
devices: 6,
|
devices: 6,
|
||||||
|
duration_days: 90,
|
||||||
expires_at: '2026-03-14T00:00:00Z',
|
expires_at: '2026-03-14T00:00:00Z',
|
||||||
addon_ids: [],
|
addon_ids: [],
|
||||||
subscription_link: 'link',
|
subscription_link: 'link',
|
||||||
@@ -118,6 +125,44 @@ describe('AccountPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /копировать ссылку подписки/i })).toBeEnabled();
|
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 () => {
|
it('renders a referral link with copy and QR actions', 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);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { DeviceCard } from '../components/DeviceCard';
|
|||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { SubscriptionQrModal } from '../components/SubscriptionQrModal';
|
import { SubscriptionQrModal } from '../components/SubscriptionQrModal';
|
||||||
import { ClientLinksModal } from '../components/ClientLinksModal';
|
import { ClientLinksModal } from '../components/ClientLinksModal';
|
||||||
|
import { formatMoney } from '../lib/format';
|
||||||
|
import { redirectTo } from '../lib/redirect';
|
||||||
import type { Device, SubscriptionData, UserInfo } from '../types';
|
import type { Device, SubscriptionData, UserInfo } from '../types';
|
||||||
|
|
||||||
export function AccountPage() {
|
export function AccountPage() {
|
||||||
@@ -27,6 +29,8 @@ export function AccountPage() {
|
|||||||
const [isQrOpen, setIsQrOpen] = useState(false);
|
const [isQrOpen, setIsQrOpen] = useState(false);
|
||||||
const [isReferralQrOpen, setIsReferralQrOpen] = useState(false);
|
const [isReferralQrOpen, setIsReferralQrOpen] = useState(false);
|
||||||
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
|
const [isClientLinksOpen, setIsClientLinksOpen] = useState(false);
|
||||||
|
const [isRenewalOpen, setIsRenewalOpen] = useState(false);
|
||||||
|
const [isRenewing, setIsRenewing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -98,18 +102,60 @@ export function AccountPage() {
|
|||||||
navigate('/', { replace: true });
|
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 (
|
return (
|
||||||
<Shell active="account">
|
<Shell active="account">
|
||||||
<section className="account-hero">
|
<section className="account-hero">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="kicker">Ваш аккаунт</div>
|
<div className="kicker">Ваш аккаунт</div>
|
||||||
<h1 className="hero-title">{userInfo?.username ?? 'Аккаунт'}</h1>
|
<h1 className="hero-title">{userInfo?.username ?? 'Аккаунт'}</h1>
|
||||||
{!loadingMeta ? (
|
{!loadingMeta ? (
|
||||||
<p className="hero-sub">
|
<>
|
||||||
{subscription?.has_subscription && subscription.expires_at
|
<p className="hero-sub">
|
||||||
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
{subscription?.has_subscription && subscription.expires_at
|
||||||
: 'Активная подписка отсутствует'}
|
? `Подписка до ${new Date(subscription.expires_at).toLocaleDateString('ru-RU')}`
|
||||||
</p>
|
: 'Активная подписка отсутствует'}
|
||||||
|
</p>
|
||||||
|
{subscription?.has_subscription ? (
|
||||||
|
<button type="button" className="btn btn-primary account-renew" onClick={() => setIsRenewalOpen(true)}>
|
||||||
|
Продлить
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
@@ -275,6 +321,19 @@ export function AccountPage() {
|
|||||||
confirmLabel="Удалить"
|
confirmLabel="Удалить"
|
||||||
onConfirm={confirmDelete}
|
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
|
<SubscriptionQrModal
|
||||||
link={isQrOpen ? subscription?.subscription_link ?? null : null}
|
link={isQrOpen ? subscription?.subscription_link ?? null : null}
|
||||||
onClose={() => setIsQrOpen(false)}
|
onClose={() => setIsQrOpen(false)}
|
||||||
|
|||||||
@@ -491,6 +491,9 @@
|
|||||||
.account-hero .hero-sub {
|
.account-hero .hero-sub {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
.account-renew {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
.checkout-confirmation {
|
.checkout-confirmation {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 96px 0 64px;
|
padding: 96px 0 64px;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface Device {
|
|||||||
|
|
||||||
export interface SubscriptionData {
|
export interface SubscriptionData {
|
||||||
devices: number | null;
|
devices: number | null;
|
||||||
|
duration_days: number | null;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
addon_ids: string[];
|
addon_ids: string[];
|
||||||
subscription_link: string | null;
|
subscription_link: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user