feat(pwa): главный экран из двух половин — деньги сверху, выписка снизу
По образцу приложения вендора: баланс в парке и «зачисляется», под ними крупная кнопка «Пополнить», ниже — период с календарями, фильтры и список операций (аренда, пени, штрафы, пополнения). Разбивка по статьям переехала в шторку «Пополнить». Она нужна ровно в момент выбора, куда платить; всё остальное время это шесть строк, отодвигающих выписку за нижний край телефона. В шторке только те статьи, в которые платёж проводится. Экран суммы, придя из шторки, показывает выбранную категорию строкой, а не рядом чипов: второй выбор того же самого сбивает с толку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -119,3 +119,23 @@ export const mockPayOutcome = async (
|
||||
) => PaymentStateSchema.parse(
|
||||
await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(),
|
||||
);
|
||||
|
||||
export const StatementSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
date: z.string().nullable(),
|
||||
title: z.string(),
|
||||
description: z.string().default(""),
|
||||
amount: z.number(),
|
||||
kind: z.enum(["charges", "payments", "fines"]),
|
||||
})),
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
kind: z.string(),
|
||||
});
|
||||
export type StatementKind = "all" | "charges" | "payments" | "fines";
|
||||
|
||||
/** Выписка по счёту за период. Даты — ISO (ГГГГ-ММ-ДД), как их понимает бэкенд. */
|
||||
export const getStatement = async (from: string, to: string, kind: StatementKind) =>
|
||||
StatementSchema.parse(await api.get("driver/statement", {
|
||||
searchParams: { from, to, kind },
|
||||
}).json());
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Шторка снизу — выбор, который не уводит с экрана.
|
||||
*
|
||||
* Отдельным экраном такой выбор стоит дороже, чем кажется: человек теряет из
|
||||
* виду то, ради чего пришёл (баланс), и возвращается кнопкой «назад», которой на
|
||||
* телефоне ещё надо попасть. Здесь выбор происходит поверх экрана, а закрывается
|
||||
* тапом мимо, кнопкой и клавишей Escape.
|
||||
*/
|
||||
export function Sheet({ open, onClose, title, children }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
// Фон не должен уезжать под шторкой: иначе список категорий листает страницу.
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center">
|
||||
<button
|
||||
aria-label="Закрыть"
|
||||
className="absolute inset-0 bg-ink/40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="relative w-full max-w-md bg-surface rounded-t-2xl pb-6 pt-2 max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
{/* Полоска-ручка: на телефоне она объясняет, что панель тянется, лучше любой подписи. */}
|
||||
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
|
||||
<p className="px-5 pb-2 text-sm font-bold">{title}</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getStatement, type StatementKind } from "@/api/driver";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
const FILTERS: { key: StatementKind; label: string }[] = [
|
||||
{ key: "all", label: "Все операции" },
|
||||
{ key: "charges", label: "Начисления" },
|
||||
{ key: "payments", label: "Пополнения" },
|
||||
{ key: "fines", label: "Штрафы" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Выписка по счёту за период — нижняя половина главного экрана.
|
||||
*
|
||||
* Это ответ на вопрос «за что списано», который иначе задают звонком в парк.
|
||||
* Строки те же, по которым 1С считает долг: аренда, пени, штрафы, пополнения.
|
||||
*/
|
||||
export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
||||
from: string;
|
||||
to: string;
|
||||
kind: StatementKind;
|
||||
onFrom: (v: string) => void;
|
||||
onTo: (v: string) => void;
|
||||
onKind: (k: StatementKind) => void;
|
||||
}) {
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["statement", from, to, kind],
|
||||
queryFn: () => getStatement(from, to, kind),
|
||||
});
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{/* Нативный date-input: календарь телефона знакомее любого нашего. */}
|
||||
<input type="date" value={from} max={to} onChange={(e) => onFrom(e.target.value)}
|
||||
aria-label="Начало периода"
|
||||
className="card flex-1 px-3 py-2 text-xs bg-white" />
|
||||
<span className="text-muted text-xs">—</span>
|
||||
<input type="date" value={to} min={from} onChange={(e) => onTo(e.target.value)}
|
||||
aria-label="Конец периода"
|
||||
className="card flex-1 px-3 py-2 text-xs bg-white" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 mb-3 overflow-x-auto">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => onKind(f.key)}
|
||||
className={`shrink-0 px-3 py-2 rounded-xl2 text-xs border ${
|
||||
f.key === kind ? "border-line bg-surface font-semibold" : "border-line text-muted"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : isError ? (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-muted text-sm mb-3">Не удалось загрузить операции</p>
|
||||
<button className="btn-ghost" onClick={() => refetch()}>Повторить</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted text-sm text-center py-8">Нет операций за выбранный период</p>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((op, idx) => (
|
||||
<div key={`${op.date}-${idx}`}
|
||||
className="flex justify-between items-start gap-3 py-3 border-b border-line last:border-0">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm">{op.title}</span>
|
||||
<span className="block text-muted text-xs">
|
||||
{formatDate(op.date)}
|
||||
{op.description ? ` · ${op.description}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
|
||||
{formatMoney(op.amount)}
|
||||
</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,3 +30,21 @@ export function formatPhone(national10: string): string {
|
||||
if (d.length > 8) p.push("-" + d.slice(8, 10));
|
||||
return p.join("");
|
||||
}
|
||||
|
||||
/** Дата человеку — ДД.ММ.ГГГГ. На проводе и в <input type="date"> остаётся ISO. */
|
||||
export function formatDate(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const [y, m, d] = iso.split("-");
|
||||
return y && m && d ? `${d}.${m}.${y}` : iso;
|
||||
}
|
||||
|
||||
/** ISO-дата на N дней назад от сегодня — для периода выписки по умолчанию. */
|
||||
export function isoDaysAgo(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function isoToday(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Ответ как у настоящего водителя: шесть статей 1С, итог отдельным числом.
|
||||
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
|
||||
vi.mock("@/api/driver", () => ({
|
||||
getBalance: vi.fn(async () => ({
|
||||
accounts: [
|
||||
{ bucket: "Долг аренда", balance: -3050, payable: true },
|
||||
{ bucket: "Долг по штрафам", balance: -5156, payable: true },
|
||||
{ bucket: "Прочее", balance: -770, payable: true },
|
||||
{ bucket: "Страховка", balance: 0, payable: false },
|
||||
{ bucket: "Страховка", balance: 200, payable: false },
|
||||
],
|
||||
total: -8976.25,
|
||||
synced_at: null,
|
||||
pending_topup: 0,
|
||||
pending_topup: 1500,
|
||||
})),
|
||||
getStatement: vi.fn(async () => ({
|
||||
items: [
|
||||
{ date: "2026-08-17", title: "Аренда автомобиля", description: "ТС А947УТ125",
|
||||
amount: -2600, kind: "charges" },
|
||||
{ date: "2026-08-17", title: "Пополнение баланса", description: "", amount: 1000,
|
||||
kind: "payments" },
|
||||
],
|
||||
from: "2026-08-11", to: "2026-08-18", kind: "all",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { BalancePage } from "./BalancePage";
|
||||
import * as driver from "@/api/driver";
|
||||
|
||||
function wrap() {
|
||||
const qc = new QueryClient();
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter><BalancePage /></MemoryRouter>
|
||||
@@ -29,32 +39,53 @@ function wrap() {
|
||||
);
|
||||
}
|
||||
|
||||
describe("BalancePage", () => {
|
||||
it("итог берётся с сервера, а не складывается из строк", async () => {
|
||||
describe("главный экран", () => {
|
||||
it("сверху деньги: итог с сервера и строка «зачисляется»", async () => {
|
||||
wrap();
|
||||
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
|
||||
// Сумма статей дала бы −8 976, а в CRM у водителя −8 976,25. Показываем её.
|
||||
expect(screen.getByText("−8 976,25 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument();
|
||||
// Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её.
|
||||
expect(await screen.findByText("−8 976,25 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByText("Зачисляется")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("статья, в которую платить нечем, не притворяется кнопкой", async () => {
|
||||
it("снизу выписка за период", async () => {
|
||||
wrap();
|
||||
await screen.findByText("Страховка");
|
||||
const buckets = screen.getAllByRole("button").map((b) => b.textContent ?? "");
|
||||
expect(buckets.some((t) => t.includes("Долг аренда"))).toBe(true);
|
||||
expect(buckets.some((t) => t.includes("Страховка"))).toBe(false);
|
||||
expect(await screen.findByText("Аренда автомобиля")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/17\.08\.2026/).length).toBe(2); // обе операции того дня
|
||||
expect(screen.getByRole("button", { name: "Все операции" })).toBeInTheDocument();
|
||||
expect(driver.getStatement).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an error state when balance fails to load", async () => {
|
||||
const driver = await import("@/api/driver");
|
||||
(driver.getBalance as any).mockRejectedValueOnce(new Error("boom"));
|
||||
// re-render fresh
|
||||
const { QueryClient, QueryClientProvider } = await import("@tanstack/react-query");
|
||||
const { MemoryRouter } = await import("react-router-dom");
|
||||
const { render, screen } = await import("@testing-library/react");
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={qc}><MemoryRouter><BalancePage /></MemoryRouter></QueryClientProvider>);
|
||||
expect(await screen.findByText(/Не удалось загрузить/i)).toBeInTheDocument();
|
||||
it("фильтр перезапрашивает операции выбранного вида", async () => {
|
||||
wrap();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Штрафы" }));
|
||||
const calls = vi.mocked(driver.getStatement).mock.calls;
|
||||
expect(calls[calls.length - 1][2]).toBe("fines");
|
||||
});
|
||||
|
||||
it("категории живут в шторке «Пополнить», а не на экране", async () => {
|
||||
wrap();
|
||||
await screen.findByText("−8 976,25 ₽");
|
||||
expect(screen.queryByText("Долг аренда")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Пополнить/ }));
|
||||
expect(await screen.findByRole("dialog", { name: "Куда пополнить" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Долг аренда")).toBeInTheDocument();
|
||||
// «Страховка» платежом не закрывается — в списке выбора ей делать нечего.
|
||||
expect(screen.queryByText("Страховка")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("шторка закрывается тапом мимо", async () => {
|
||||
wrap();
|
||||
await screen.findByText("−8 976,25 ₽");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Пополнить/ }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Закрыть" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("баланс не загрузился — экран говорит об этом, а не рисует ноль", async () => {
|
||||
vi.mocked(driver.getBalance).mockRejectedValueOnce(new Error("boom"));
|
||||
wrap();
|
||||
expect(await screen.findByRole("button", { name: /Обновить баланс/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,92 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { History, LogOut } from "lucide-react";
|
||||
import { getBalance } from "@/api/driver";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import { ArrowUp, ChevronRight, History, LogOut } from "lucide-react";
|
||||
import { getBalance, type StatementKind } from "@/api/driver";
|
||||
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { Sheet } from "@/components/Sheet";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { Statement } from "@/components/Statement";
|
||||
|
||||
/**
|
||||
* Главный экран из двух половин: сверху деньги, снизу — за что они списаны.
|
||||
*
|
||||
* Разбивка по статьям живёт в шторке «Пополнить», а не на экране: водителю она
|
||||
* нужна ровно в момент, когда он решает, куда платить, а всё остальное время
|
||||
* это шесть строк, отодвигающих выписку за нижний край телефона.
|
||||
*/
|
||||
export function BalancePage() {
|
||||
const nav = useNavigate();
|
||||
const clear = useAuth((s) => s.clear);
|
||||
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
|
||||
const [sheet, setSheet] = useState(false);
|
||||
// Неделя — тот же период, что у вендора: столько человек помнит свои поездки.
|
||||
const [from, setFrom] = useState(isoDaysAgo(7));
|
||||
const [to, setTo] = useState(isoToday());
|
||||
const [kind, setKind] = useState<StatementKind>("all");
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
// Итог — с сервера (это ровно то число, что видит сотрудник в CRM). Сумма строк
|
||||
// тут не годится: 1С округляет статьи до рублей, а итог держит с копейками.
|
||||
const total = data?.total ?? accounts.reduce((s, a) => s + a.balance, 0);
|
||||
const pending = data?.pending_topup ?? 0;
|
||||
const noData = !isLoading && !isError && accounts.length === 0;
|
||||
const payable = accounts.filter((a) => a.payable);
|
||||
|
||||
function pick(bucket?: string) {
|
||||
setSheet(false);
|
||||
nav("/topup", { state: bucket ? { bucket } : undefined });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AppHeader />
|
||||
<div className="card p-4 mb-4 text-center">
|
||||
<p className="text-muted text-xs mb-1">Баланс</p>
|
||||
{noData ? (
|
||||
<p className="text-sm font-semibold">Уточняется</p>
|
||||
) : (
|
||||
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>
|
||||
{formatMoney(total)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Деньги ─────────────────────────────────────────────── */}
|
||||
<div className="card p-4 mb-3">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-muted text-xs">Баланс в парке</span>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : noData ? (
|
||||
<span className="text-sm font-semibold">уточняется</span>
|
||||
) : (
|
||||
<b className={`text-2xl ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</b>
|
||||
)}
|
||||
</div>
|
||||
{pending > 0 && (
|
||||
// Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не
|
||||
// входит намеренно: сложить оба источника значило бы показать
|
||||
// пополнение дважды, как только расчётный лист принесёт тот же платёж.
|
||||
<p className="text-muted text-xs mt-2">
|
||||
{formatMoney(pending)} зачисляется — учтётся после проведения
|
||||
</p>
|
||||
// Платёж принят нами, но 1С его ещё не провела. В сумму не входит: сложить
|
||||
// оба источника значило бы показать пополнение дважды после проведения.
|
||||
<div className="flex justify-between items-baseline mt-2">
|
||||
<span className="text-muted text-xs">Зачисляется</span>
|
||||
<span className="text-sm">{formatMoney(pending)}</span>
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<button className="btn-ghost mt-3" onClick={() => refetch()}>Обновить баланс</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : isError ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted text-sm mb-3">Не удалось загрузить баланс</p>
|
||||
<button className="btn-ghost" onClick={() => refetch()}>Повторить</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4">
|
||||
{noData && (
|
||||
<p className="text-muted text-sm text-center py-6">
|
||||
Баланс ещё не пришёл из бухгалтерии. Если он нужен прямо сейчас — спросите в парке.
|
||||
</p>
|
||||
)}
|
||||
{accounts.map((a) => {
|
||||
const row = (
|
||||
<>
|
||||
<span className="text-sm">{a.bucket}</span>
|
||||
<b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b>
|
||||
</>
|
||||
);
|
||||
const cls = "w-full flex justify-between items-center py-3 border-b border-line last:border-0 text-left";
|
||||
// В «Страховку» платёж провести нечем — строку показываем, нажатие не
|
||||
// предлагаем: кнопка, ведущая в отказ, хуже отсутствующей кнопки.
|
||||
return a.payable ? (
|
||||
<button key={a.bucket} className={cls}
|
||||
onClick={() => nav("/topup", { state: { bucket: a.bucket } })}>
|
||||
{row}
|
||||
</button>
|
||||
) : (
|
||||
<div key={a.bucket} className={cls}>{row}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn-primary flex items-center justify-center gap-2 mb-5"
|
||||
onClick={() => setSheet(true)}>
|
||||
<ArrowUp size={18} /> Пополнить
|
||||
</button>
|
||||
|
||||
<button className="btn-primary" onClick={() => nav("/topup")}>Пополнить</button>
|
||||
{/* ── За что списано ─────────────────────────────────────── */}
|
||||
<Statement from={from} to={to} kind={kind}
|
||||
onFrom={setFrom} onTo={setTo} onKind={setKind} />
|
||||
|
||||
<div className="flex justify-center gap-6 mt-6 text-muted text-xs">
|
||||
<button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
|
||||
<History size={15} /> История
|
||||
<History size={15} /> Мои пополнения
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
|
||||
<LogOut size={15} /> Выйти
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Sheet open={sheet} onClose={() => setSheet(false)} title="Куда пополнить">
|
||||
{payable.length === 0 ? (
|
||||
<p className="text-muted text-sm px-5 py-4">
|
||||
Категории пока не загрузились. Попробуйте ещё раз через минуту.
|
||||
</p>
|
||||
) : (
|
||||
payable.map((a) => (
|
||||
<button
|
||||
key={a.bucket}
|
||||
onClick={() => pick(a.bucket)}
|
||||
className="w-full flex justify-between items-center gap-3 px-5 py-3.5 border-b border-line last:border-0 text-left"
|
||||
>
|
||||
<span className="text-sm">{a.bucket}</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
<b className={a.balance < 0 ? "text-neg" : a.balance > 0 ? "text-pos" : "text-muted"}>
|
||||
{formatMoney(a.balance)}
|
||||
</b>
|
||||
<ChevronRight size={16} className="text-muted" />
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,13 @@ export function TopupPage() {
|
||||
<div className="pt-2">
|
||||
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ Назад</button>
|
||||
|
||||
{options.length > 1 && (
|
||||
{/* Категорию выбирают в шторке на главном экране. Пришли оттуда — показываем
|
||||
выбранное строкой, а не рядом чипов: второй выбор того же самого сбивает
|
||||
с толку. Чипы остаются для входа сюда напрямую (кнопка «Пополнить» без
|
||||
категории, возврат «пополнить ещё раз» с экрана оплаты). */}
|
||||
{fromBalance ? (
|
||||
<p className="text-muted text-xs mb-3">Пополняем: <b className="text-ink">{bucket}</b></p>
|
||||
) : options.length > 1 ? (
|
||||
<div className="flex gap-1.5 mb-3 overflow-x-auto">
|
||||
{options.map((b) => (
|
||||
<button
|
||||
@@ -75,7 +81,7 @@ export function TopupPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
|
||||
<p data-testid="total" className="text-muted text-xs text-center mb-3">
|
||||
|
||||
Reference in New Issue
Block a user