feat(driver-pwa): top-up with custom keypad
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
vi.mock("@/api/driver", () => ({
|
||||||
|
topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52, total: 1052 })),
|
||||||
|
}));
|
||||||
|
import { TopupPage } from "./TopupPage";
|
||||||
|
import * as driver from "@/api/driver";
|
||||||
|
|
||||||
|
const wrap = () => render(<MemoryRouter><TopupPage /></MemoryRouter>);
|
||||||
|
|
||||||
|
describe("TopupPage", () => {
|
||||||
|
it("keypad builds amount, shows total, and submits topup", async () => {
|
||||||
|
wrap();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "1" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||||||
|
expect(screen.getByTestId("amount").textContent).toBe("1 000 ₽");
|
||||||
|
expect(screen.getByTestId("total").textContent).toContain("1 052");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
|
||||||
|
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1,82 @@
|
|||||||
export function TopupPage() { return null; }
|
import { useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Delete } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { topup } from "@/api/driver";
|
||||||
|
import { keypadReduce, withCommission } from "@/lib/amount";
|
||||||
|
import { formatMoney } from "@/lib/format";
|
||||||
|
import { Segment } from "@/components/Segment";
|
||||||
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
|
||||||
|
const RATE = 0.052;
|
||||||
|
const QUICK = ["500", "1000", "2000"];
|
||||||
|
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "·", "0", "back"];
|
||||||
|
|
||||||
|
export function TopupPage() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const loc = useLocation();
|
||||||
|
const bucket = (loc.state as { bucket?: string } | null)?.bucket ?? "Долг аренда";
|
||||||
|
const [raw, setRaw] = useState("");
|
||||||
|
const [method, setMethod] = useState("qr");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const base = raw ? parseInt(raw, 10) : 0;
|
||||||
|
const { commission, total } = withCommission(base, RATE);
|
||||||
|
|
||||||
|
function press(k: string) {
|
||||||
|
if (k === "·") return;
|
||||||
|
setRaw((r) => keypadReduce(r, k));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pay() {
|
||||||
|
if (base <= 0) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await topup(bucket, base, method as "qr" | "card");
|
||||||
|
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base } });
|
||||||
|
} catch {
|
||||||
|
toast.error("Не удалось создать платёж");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-2">
|
||||||
|
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ {bucket}</button>
|
||||||
|
<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">
|
||||||
|
к оплате {formatMoney(total)} (комиссия {formatMoney(commission)})
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 mb-3">
|
||||||
|
{QUICK.map((q) => (
|
||||||
|
<button key={q} className="flex-1 py-2 rounded-xl2 text-xs bg-surface border border-line" onClick={() => setRaw(q)}>
|
||||||
|
{q}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||||
|
{KEYS.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
aria-label={k}
|
||||||
|
className="bg-surface border border-line rounded-xl2 py-3 text-lg font-semibold flex items-center justify-center"
|
||||||
|
onClick={() => press(k)}
|
||||||
|
>
|
||||||
|
{k === "back" ? <Delete size={18} /> : k}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-primary" disabled={busy || base <= 0} onClick={pay}>
|
||||||
|
{busy ? <Spinner /> : "Оплатить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user