Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ab4e9e74e | ||
|
|
01c36ce935 | ||
|
|
0ec8f0b540 | ||
|
|
bd1a4a31bd | ||
|
|
b419a456fe | ||
|
|
08ebc09814 | ||
|
|
e5a2874ba7 | ||
|
|
adad929769 | ||
|
|
479b9699ea | ||
|
|
b7dc826f0f | ||
|
|
15d7f34689 | ||
|
|
0fba967cd8 | ||
|
|
66ef403bc0 | ||
|
|
0a164634f8 | ||
|
|
372dc0ede7 |
@@ -536,7 +536,8 @@ const MAX_DIGITS = 6;
|
|||||||
export function keypadReduce(current: string, key: string): string {
|
export function keypadReduce(current: string, key: string): string {
|
||||||
if (key === "back") return current.slice(0, -1);
|
if (key === "back") return current.slice(0, -1);
|
||||||
if (!/^\d$/.test(key)) return current;
|
if (!/^\d$/.test(key)) return current;
|
||||||
if (current === "" && key === "0") return ""; // no leading zero
|
if (current === "0") return key === "0" ? "0" : key; // replace leading zero
|
||||||
|
if (current === "" && key === "0") return ""; // no leading zero from empty
|
||||||
if (current.length >= MAX_DIGITS) return current;
|
if (current.length >= MAX_DIGITS) return current;
|
||||||
return current + key;
|
return current + key;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 909 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "Премиум Водитель",
|
||||||
|
"short_name": "Премиум",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#F3EEE3",
|
||||||
|
"theme_color": "#F3EEE3",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Генерация PWA-иконок «Премиум Водитель» без сторонних либ (zlib PNG-энкодер).
|
||||||
|
// Дизайн: кремовый фон #F3EEE3, тёмная скруглённая плитка #1C1B19,
|
||||||
|
// внутри белая «карта» #F6F2E9 с тёмной полосой (финансовый мотив). Без жёлтого/текста.
|
||||||
|
import zlib from "node:zlib";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const CREAM = [243, 238, 227];
|
||||||
|
const INK = [28, 27, 25];
|
||||||
|
const CARD = [246, 242, 233];
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = ~0;
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
c ^= buf[i];
|
||||||
|
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
||||||
|
}
|
||||||
|
return (~c) >>> 0;
|
||||||
|
}
|
||||||
|
function chunk(type, data) {
|
||||||
|
const t = Buffer.from(type, "ascii");
|
||||||
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
||||||
|
const body = Buffer.concat([t, data]);
|
||||||
|
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0);
|
||||||
|
return Buffer.concat([len, body, crc]);
|
||||||
|
}
|
||||||
|
function encodePng(N, rgba) {
|
||||||
|
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(N, 0); ihdr.writeUInt32BE(N, 4); ihdr[8] = 8; ihdr[9] = 6;
|
||||||
|
const stride = N * 4;
|
||||||
|
const raw = Buffer.alloc(N * (stride + 1));
|
||||||
|
for (let y = 0; y < N; y++) {
|
||||||
|
raw[y * (stride + 1)] = 0;
|
||||||
|
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
||||||
|
}
|
||||||
|
return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
|
||||||
|
}
|
||||||
|
function inRoundRect(px, py, x0, y0, x1, y1, r) {
|
||||||
|
if (px < x0 || px >= x1 || py < y0 || py >= y1) return false;
|
||||||
|
const cx = px < x0 + r ? x0 + r : px >= x1 - r ? x1 - r : px;
|
||||||
|
const cy = py < y0 + r ? y0 + r : py >= y1 - r ? y1 - r : py;
|
||||||
|
const dx = px - cx, dy = py - cy;
|
||||||
|
return dx * dx + dy * dy <= r * r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(N) {
|
||||||
|
const buf = Buffer.alloc(N * N * 4);
|
||||||
|
// tile (centered ~64%)
|
||||||
|
const tile = Math.round(N * 0.64), tx0 = Math.round((N - tile) / 2), ty0 = tx0;
|
||||||
|
const tx1 = tx0 + tile, ty1 = ty0 + tile, tr = Math.round(tile * 0.24);
|
||||||
|
// card inside tile
|
||||||
|
const cw = Math.round(tile * 0.62), ch = Math.round(tile * 0.42);
|
||||||
|
const cx0 = Math.round((N - cw) / 2), cy0 = Math.round((N - ch) / 2);
|
||||||
|
const cx1 = cx0 + cw, cy1 = cy0 + ch, cr = Math.round(ch * 0.18);
|
||||||
|
// stripe near top of card
|
||||||
|
const sy0 = cy0 + Math.round(ch * 0.22), sy1 = sy0 + Math.round(ch * 0.14);
|
||||||
|
for (let y = 0; y < N; y++) {
|
||||||
|
for (let x = 0; x < N; x++) {
|
||||||
|
let col = CREAM;
|
||||||
|
if (inRoundRect(x, y, tx0, ty0, tx1, ty1, tr)) col = INK;
|
||||||
|
if (inRoundRect(x, y, cx0, cy0, cx1, cy1, cr)) col = CARD;
|
||||||
|
if (x >= cx0 && x < cx1 && y >= sy0 && y < sy1) col = INK;
|
||||||
|
const o = (y * N + x) * 4;
|
||||||
|
buf[o] = col[0]; buf[o + 1] = col[1]; buf[o + 2] = col[2]; buf[o + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return encodePng(N, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.resolve("public/icons");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
for (const N of [192, 512]) {
|
||||||
|
fs.writeFileSync(path.join(dir, `icon-${N}.png`), draw(N));
|
||||||
|
console.log(`wrote icon-${N}.png`);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { LoginPage } from "@/pages/LoginPage";
|
import { LoginPage } from "@/pages/LoginPage";
|
||||||
|
import { TgWaitPage } from "@/pages/TgWaitPage";
|
||||||
import { OnboardingPage } from "@/pages/OnboardingPage";
|
import { OnboardingPage } from "@/pages/OnboardingPage";
|
||||||
import { BalancePage } from "@/pages/BalancePage";
|
import { BalancePage } from "@/pages/BalancePage";
|
||||||
import { TopupPage } from "@/pages/TopupPage";
|
import { TopupPage } from "@/pages/TopupPage";
|
||||||
@@ -18,6 +19,7 @@ export default function App() {
|
|||||||
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
|
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/tg-wait" element={<TgWaitPage />} />
|
||||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
<Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
|
<Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
|
||||||
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { BalanceSchema, TopupSchema, PaymentsSchema, AuthRequestSchema } from "./driver";
|
import { BalanceSchema, TopupSchema, PaymentsSchema, AuthRequestSchema, TgSessionSchema, TgPollSchema } from "./driver";
|
||||||
|
|
||||||
describe("driver api schemas", () => {
|
describe("driver api schemas", () => {
|
||||||
it("parses balance", () => {
|
it("parses balance", () => {
|
||||||
@@ -14,6 +14,9 @@ describe("driver api schemas", () => {
|
|||||||
expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("code_sent");
|
expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("code_sent");
|
||||||
const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" } });
|
const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" } });
|
||||||
expect(o.status === "onboarding" && o.deep_links.tg).toBe("t");
|
expect(o.status === "onboarding" && o.deep_links.tg).toBe("t");
|
||||||
|
// prod: MAX bot not configured → backend omits `max`, must still parse (regression guard)
|
||||||
|
const tgOnly = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t" } });
|
||||||
|
expect(tgOnly.status === "onboarding" && tgOnly.deep_links.tg).toBe("t");
|
||||||
});
|
});
|
||||||
it("parses payments history", () => {
|
it("parses payments history", () => {
|
||||||
const v = PaymentsSchema.parse({ payments: [{ order_id: "o1", bucket: "b", amount: 100,
|
const v = PaymentsSchema.parse({ payments: [{ order_id: "o1", bucket: "b", amount: 100,
|
||||||
@@ -21,3 +24,16 @@ describe("driver api schemas", () => {
|
|||||||
expect(v.payments[0].status).toBe("PAID");
|
expect(v.payments[0].status).toBe("PAID");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("tg verified-login schemas", () => {
|
||||||
|
it("parses tg session", () => {
|
||||||
|
const v = TgSessionSchema.parse({ pair_session: "S", deep_link: "https://t.me/x?start=S" });
|
||||||
|
expect(v.pair_session).toBe("S");
|
||||||
|
});
|
||||||
|
it("parses tg poll states", () => {
|
||||||
|
expect(TgPollSchema.parse({ status: "pending" }).status).toBe("pending");
|
||||||
|
expect(TgPollSchema.parse({ status: "expired" }).status).toBe("expired");
|
||||||
|
const a = TgPollSchema.parse({ status: "authenticated", token: "T", driver_id: 7 });
|
||||||
|
expect(a.status === "authenticated" && a.token).toBe("T");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { api } from "./client";
|
|||||||
|
|
||||||
export const AuthRequestSchema = z.union([
|
export const AuthRequestSchema = z.union([
|
||||||
z.object({ status: z.literal("code_sent"), channel: z.string() }),
|
z.object({ status: z.literal("code_sent"), channel: z.string() }),
|
||||||
z.object({ status: z.literal("onboarding"), deep_links: z.object({ tg: z.string(), max: z.string() }) }),
|
z.object({ status: z.literal("onboarding"), deep_links: z.object({ tg: z.string().optional(), max: z.string().optional() }) }),
|
||||||
]);
|
]);
|
||||||
export type AuthRequest = z.infer<typeof AuthRequestSchema>;
|
export type AuthRequest = z.infer<typeof AuthRequestSchema>;
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ export const TopupSchema = z.object({
|
|||||||
order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(),
|
order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(),
|
||||||
});
|
});
|
||||||
export const PaymentStateSchema = z.object({ order_id: z.string(), status: z.string() });
|
export const PaymentStateSchema = z.object({ order_id: z.string(), status: z.string() });
|
||||||
|
export const CommissionSchema = z.object({ rate: z.number() });
|
||||||
export const PaymentsSchema = z.object({
|
export const PaymentsSchema = z.object({
|
||||||
payments: z.array(z.object({
|
payments: z.array(z.object({
|
||||||
order_id: z.string(), bucket: z.string(), amount: z.number(), commission: z.number(),
|
order_id: z.string(), bucket: z.string(), amount: z.number(), commission: z.number(),
|
||||||
@@ -23,6 +24,21 @@ export const PaymentsSchema = z.object({
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const TgSessionSchema = z.object({
|
||||||
|
pair_session: z.string(),
|
||||||
|
deep_link: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export const TgPollSchema = z.union([
|
||||||
|
z.object({ status: z.literal("pending") }),
|
||||||
|
z.object({ status: z.literal("expired") }),
|
||||||
|
z.object({ status: z.literal("authenticated"), token: z.string(), driver_id: z.number() }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const tgSession = async () =>
|
||||||
|
TgSessionSchema.parse(await api.post("driver/auth/tg/session").json());
|
||||||
|
export const tgPoll = async (pairSession: string) =>
|
||||||
|
TgPollSchema.parse(await api.get("driver/auth/tg/poll", { searchParams: { pair_session: pairSession } }).json());
|
||||||
|
|
||||||
export const authRequest = async (phone: string) =>
|
export const authRequest = async (phone: string) =>
|
||||||
AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json());
|
AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json());
|
||||||
export const authVerify = async (phone: string, code: string) =>
|
export const authVerify = async (phone: string, code: string) =>
|
||||||
@@ -35,3 +51,5 @@ export const getPaymentState = async (orderId: string) =>
|
|||||||
PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json());
|
PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json());
|
||||||
export const getPayments = async () =>
|
export const getPayments = async () =>
|
||||||
PaymentsSchema.parse(await api.get("driver/payments").json());
|
PaymentsSchema.parse(await api.get("driver/payments").json());
|
||||||
|
export const getCommission = async () =>
|
||||||
|
CommissionSchema.parse(await api.get("driver/commission").json());
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
export function AppHeader({ initials }: { initials?: string }) {
|
export function AppHeader() {
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center justify-between mb-4">
|
<header className="mb-4">
|
||||||
<span className="text-sm font-bold">Премиум Водитель</span>
|
<span className="text-sm font-bold">Премиум Водитель</span>
|
||||||
{initials && (
|
|
||||||
<span className="w-7 h-7 rounded-full bg-ink text-cream text-[10px] font-bold flex items-center justify-center">
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// src/hooks/usePairPoll.test.tsx
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { usePairPoll } from "./usePairPoll";
|
||||||
|
import * as driver from "@/api/driver";
|
||||||
|
import { useAuth } from "@/store/auth";
|
||||||
|
|
||||||
|
beforeEach(() => { useAuth.getState().clear(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
describe("usePairPoll", () => {
|
||||||
|
it("sets session on authenticated", async () => {
|
||||||
|
vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "authenticated", token: "T", driver_id: 7 } as any);
|
||||||
|
const { result } = renderHook(() => usePairPoll("SESS"));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("authenticated"));
|
||||||
|
expect(useAuth.getState().token).toBe("T");
|
||||||
|
expect(useAuth.getState().driverId).toBe(7);
|
||||||
|
});
|
||||||
|
it("stays pending then expired", async () => {
|
||||||
|
vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "expired" } as any);
|
||||||
|
const { result } = renderHook(() => usePairPoll("SESS"));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("expired"));
|
||||||
|
});
|
||||||
|
it("does nothing when session is null", () => {
|
||||||
|
const spy = vi.spyOn(driver, "tgPoll");
|
||||||
|
const { result } = renderHook(() => usePairPoll(null));
|
||||||
|
expect(result.current.status).toBe("pending");
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// src/hooks/usePairPoll.ts
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { tgPoll } from "@/api/driver";
|
||||||
|
import { useAuth } from "@/store/auth";
|
||||||
|
|
||||||
|
type Status = "pending" | "authenticated" | "expired";
|
||||||
|
const INTERVAL_MS = 2000;
|
||||||
|
const TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export function usePairPoll(pairSession: string | null): { status: Status } {
|
||||||
|
const [status, setStatus] = useState<Status>("pending");
|
||||||
|
const setSession = useAuth((s) => s.setSession);
|
||||||
|
const startedAt = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pairSession) return;
|
||||||
|
let alive = true;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
startedAt.current = Date.now();
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (!alive) return;
|
||||||
|
try {
|
||||||
|
const r = await tgPoll(pairSession);
|
||||||
|
if (!alive) return;
|
||||||
|
if (r.status === "authenticated") {
|
||||||
|
setSession(r.token, r.driver_id);
|
||||||
|
setStatus("authenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.status === "expired") { setStatus("expired"); return; }
|
||||||
|
} catch {
|
||||||
|
// transient — keep polling until timeout
|
||||||
|
}
|
||||||
|
if (Date.now() - startedAt.current > TIMEOUT_MS) { setStatus("expired"); return; }
|
||||||
|
timer = setTimeout(tick, INTERVAL_MS);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
return () => { alive = false; clearTimeout(timer); };
|
||||||
|
}, [pairSession, setSession]);
|
||||||
|
|
||||||
|
return { status };
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getPaymentState } from "@/api/driver";
|
||||||
|
|
||||||
|
const TERMINAL = new Set(["PAID", "CONFIRMED", "REJECTED", "REVERSED", "REFUNDED", "CANCELED", "AUTH_FAIL"]);
|
||||||
|
|
||||||
|
export function usePaymentStatus(orderId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["payment", orderId],
|
||||||
|
queryFn: () => getPaymentState(orderId),
|
||||||
|
refetchInterval: (q) => (q.state.data && TERMINAL.has(q.state.data.status) ? false : 1500),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,4 +32,16 @@ describe("BalancePage", () => {
|
|||||||
expect(screen.getAllByText("−3 200 ₽").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("−3 200 ₽").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import { Spinner } from "@/components/Spinner";
|
|||||||
export function BalancePage() {
|
export function BalancePage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const clear = useAuth((s) => s.clear);
|
const clear = useAuth((s) => s.clear);
|
||||||
const { data, isLoading } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
|
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
|
||||||
|
|
||||||
const accounts = data?.accounts ?? [];
|
const accounts = data?.accounts ?? [];
|
||||||
const totalDebt = accounts.filter((a) => a.balance < 0).reduce((s, a) => s + a.balance, 0);
|
const totalDebt = accounts.filter((a) => a.balance < 0).reduce((s, a) => s + a.balance, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AppHeader initials="" />
|
<AppHeader />
|
||||||
<div className="card p-4 mb-4 text-center">
|
<div className="card p-4 mb-4 text-center">
|
||||||
<p className="text-muted text-xs mb-1">Общий долг</p>
|
<p className="text-muted text-xs mb-1">Общий долг</p>
|
||||||
<p className={`text-2xl font-extrabold ${totalDebt < 0 ? "text-neg" : ""}`}>{formatMoney(totalDebt)}</p>
|
<p className={`text-2xl font-extrabold ${totalDebt < 0 ? "text-neg" : ""}`}>{formatMoney(totalDebt)}</p>
|
||||||
@@ -25,6 +25,11 @@ export function BalancePage() {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-10"><Spinner /></div>
|
<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">
|
<div className="mb-4">
|
||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("@/api/driver", () => ({
|
||||||
|
getPayments: vi.fn(async () => ({ payments: [
|
||||||
|
{ order_id: "o1", bucket: "Долг аренда", amount: 1000, commission: 52, method: "qr", status: "PAID", created_at: "2026-06-18T09:00:00Z", paid_at: "2026-06-18T09:01:00Z" },
|
||||||
|
] })),
|
||||||
|
}));
|
||||||
|
import { HistoryPage } from "./HistoryPage";
|
||||||
|
|
||||||
|
function wrap() {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
return render(<QueryClientProvider client={qc}><MemoryRouter><HistoryPage /></MemoryRouter></QueryClientProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HistoryPage", () => {
|
||||||
|
it("lists payments with amount + status", async () => {
|
||||||
|
wrap();
|
||||||
|
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 000 ₽")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Оплачено/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1,41 @@
|
|||||||
export function HistoryPage() { return null; }
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getPayments } from "@/api/driver";
|
||||||
|
import { formatMoney } from "@/lib/format";
|
||||||
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
|
||||||
|
const STATUS_RU: Record<string, string> = {
|
||||||
|
PAID: "Оплачено", NEW: "Ожидает", FORM: "Ожидает", CONFIRMED: "Оплачено", REJECTED: "Отклонён",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HistoryPage() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const { data, isLoading, isError } = useQuery({ queryKey: ["payments"], queryFn: getPayments });
|
||||||
|
const rows = data?.payments ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-2">
|
||||||
|
<button className="text-muted text-xs mb-3" onClick={() => nav("/")}>‹ Назад</button>
|
||||||
|
<h1 className="text-lg font-extrabold mb-4">История пополнений</h1>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-10"><Spinner /></div>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-muted text-sm">Не удалось загрузить историю</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm">Пока нет пополнений</p>
|
||||||
|
) : (
|
||||||
|
rows.map((p) => (
|
||||||
|
<div key={p.order_id} className="flex justify-between items-center py-3 border-b border-line">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">{p.bucket}</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
{p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} · {STATUS_RU[p.status] ?? p.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<b>{formatMoney(p.amount)}</b>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router-dom";
|
|||||||
vi.mock("@/api/driver", () => ({
|
vi.mock("@/api/driver", () => ({
|
||||||
authRequest: vi.fn(async () => ({ status: "code_sent", channel: "tg" })),
|
authRequest: vi.fn(async () => ({ status: "code_sent", channel: "tg" })),
|
||||||
authVerify: vi.fn(async () => ({ token: "T", driver_id: 7 })),
|
authVerify: vi.fn(async () => ({ token: "T", driver_id: 7 })),
|
||||||
|
tgSession: vi.fn(async () => ({ pair_session: "sess123", deep_link: null })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { LoginPage } from "./LoginPage";
|
import { LoginPage } from "./LoginPage";
|
||||||
@@ -19,6 +20,7 @@ describe("LoginPage", () => {
|
|||||||
it("requests code then verifies and stores session", async () => {
|
it("requests code then verifies and stores session", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
expect(screen.getByText(/Вход в приложение/i)).toBeInTheDocument();
|
expect(screen.getByText(/Вход в приложение/i)).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Войти по коду/i }));
|
||||||
await userEvent.type(screen.getByPlaceholderText(/телефон/i), "9143054400");
|
await userEvent.type(screen.getByPlaceholderText(/телефон/i), "9143054400");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /Получить код/i }));
|
await userEvent.click(screen.getByRole("button", { name: /Получить код/i }));
|
||||||
expect(driver.authRequest).toHaveBeenCalledWith("79143054400");
|
expect(driver.authRequest).toHaveBeenCalledWith("79143054400");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { authRequest, authVerify } from "@/api/driver";
|
import { authRequest, authVerify, tgSession } from "@/api/driver";
|
||||||
import { digitsOnly, formatPhone } from "@/lib/format";
|
import { digitsOnly, formatPhone } from "@/lib/format";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
@@ -14,8 +14,22 @@ export function LoginPage() {
|
|||||||
const [step, setStep] = useState<"phone" | "code">("phone");
|
const [step, setStep] = useState<"phone" | "code">("phone");
|
||||||
const [channel, setChannel] = useState<string>("");
|
const [channel, setChannel] = useState<string>("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [mode, setMode] = useState<"choose" | "code">("choose");
|
||||||
const e164 = "7" + phone;
|
const e164 = "7" + phone;
|
||||||
|
|
||||||
|
async function loginViaTelegram() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await tgSession();
|
||||||
|
sessionStorage.setItem("pp-pair-session", r.pair_session);
|
||||||
|
if (r.deep_link) { window.location.href = r.deep_link; }
|
||||||
|
nav("/tg-wait");
|
||||||
|
} catch {
|
||||||
|
toast.error("Не удалось начать вход. Попробуйте «по коду».");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function requestCode() {
|
async function requestCode() {
|
||||||
if (phone.length < 10) return;
|
if (phone.length < 10) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -28,8 +42,13 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
setChannel(r.channel);
|
setChannel(r.channel);
|
||||||
setStep("code");
|
setStep("code");
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
toast.error(err?.response?.status === 404 ? "Номер не найден. Обратитесь в парк." : "Не удалось отправить код");
|
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||||
|
const msg =
|
||||||
|
status === 404 ? "Номер не найден. Обратитесь в парк." :
|
||||||
|
status === 429 ? "Слишком часто. Попробуйте позже." :
|
||||||
|
"Не удалось отправить код";
|
||||||
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -54,6 +73,16 @@ export function LoginPage() {
|
|||||||
<h1 className="text-xl font-extrabold mb-1">Премиум Водитель</h1>
|
<h1 className="text-xl font-extrabold mb-1">Премиум Водитель</h1>
|
||||||
<p className="text-muted text-sm mb-8">Вход в приложение</p>
|
<p className="text-muted text-sm mb-8">Вход в приложение</p>
|
||||||
|
|
||||||
|
{mode === "choose" ? (
|
||||||
|
<>
|
||||||
|
<button className="btn-primary" disabled={busy} onClick={loginViaTelegram}>
|
||||||
|
{busy ? <Spinner /> : "Войти через Telegram"}
|
||||||
|
</button>
|
||||||
|
<p className="text-muted text-xs mt-3">Telegram подтвердит ваш номер автоматически.</p>
|
||||||
|
<button className="btn-ghost mt-4" onClick={() => setMode("code")}>Войти по коду</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{step === "phone" ? (
|
{step === "phone" ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -84,6 +113,8 @@ export function LoginPage() {
|
|||||||
<button className="btn-ghost mt-2" onClick={() => setStep("phone")}>Изменить номер</button>
|
<button className="btn-ghost mt-2" onClick={() => setStep("phone")}>Изменить номер</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,10 @@ describe("OnboardingPage", () => {
|
|||||||
expect(screen.getByRole("link", { name: /MAX/i })).toHaveAttribute("href", "https://max.ru/b?start=x");
|
expect(screen.getByRole("link", { name: /MAX/i })).toHaveAttribute("href", "https://max.ru/b?start=x");
|
||||||
expect(screen.getByRole("button", { name: /запросить код/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /запросить код/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it("renders only Telegram when max is absent", () => {
|
||||||
|
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/ppdriver_bot?start=x", phone: "9143054400" }));
|
||||||
|
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||||||
|
expect(screen.getByRole("link", { name: /Telegram/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("link", { name: /MAX/i })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
export function OnboardingPage() {
|
export function OnboardingPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const raw = sessionStorage.getItem("pp-onboarding");
|
const raw = sessionStorage.getItem("pp-onboarding");
|
||||||
const data = raw ? (JSON.parse(raw) as { tg: string; max: string; phone: string }) : null;
|
const data = raw ? (JSON.parse(raw) as { tg?: string; max?: string; phone: string }) : null;
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -12,12 +12,16 @@ export function OnboardingPage() {
|
|||||||
<p className="text-muted text-sm mb-8">
|
<p className="text-muted text-sm mb-8">
|
||||||
Чтобы получать код входа, откройте бот и нажмите «Старт». Затем вернитесь и запросите код снова.
|
Чтобы получать код входа, откройте бот и нажмите «Старт». Затем вернитесь и запросите код снова.
|
||||||
</p>
|
</p>
|
||||||
|
{data.tg && (
|
||||||
<a href={data.tg} target="_blank" rel="noopener" className="btn-primary mb-3 no-underline block">
|
<a href={data.tg} target="_blank" rel="noopener" className="btn-primary mb-3 no-underline block">
|
||||||
Подключить Telegram
|
Подключить Telegram
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
|
{data.max && (
|
||||||
<a href={data.max} target="_blank" rel="noopener" className="btn-ghost mb-6 no-underline block">
|
<a href={data.max} target="_blank" rel="noopener" className="btn-ghost mb-6 no-underline block">
|
||||||
Подключить MAX
|
Подключить MAX
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
<button className="btn-ghost" onClick={() => nav("/login")}>
|
<button className="btn-ghost" onClick={() => nav("/login")}>
|
||||||
Я подключил — запросить код
|
Я подключил — запросить код
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||||
|
|
||||||
|
const states = ["FORM", "PAID"];
|
||||||
|
vi.mock("@/api/driver", () => ({
|
||||||
|
getPaymentState: vi.fn(async () => ({ order_id: "o9", status: states.shift() ?? "PAID" })),
|
||||||
|
}));
|
||||||
|
import { PaymentPage } from "./PaymentPage";
|
||||||
|
|
||||||
|
function wrap() {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={[{ pathname: "/pay/o9", state: { payUrl: "https://qr.nspk.ru/x", total: 1052, bucket: "Долг аренда", base: 1000 } }]}>
|
||||||
|
<Routes><Route path="/pay/:orderId" element={<PaymentPage />} /></Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PaymentPage", () => {
|
||||||
|
it("shows pay button and flips to success on PAID", async () => {
|
||||||
|
wrap();
|
||||||
|
expect(screen.getByRole("link", { name: /Оплатить/i })).toHaveAttribute("href", "https://qr.nspk.ru/x");
|
||||||
|
await waitFor(() => expect(screen.getByText(/Оплачено/i)).toBeInTheDocument(), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1,42 @@
|
|||||||
export function PaymentPage() { return null; }
|
import { useEffect } from "react";
|
||||||
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { formatMoney } from "@/lib/format";
|
||||||
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
import { usePaymentStatus } from "@/hooks/usePaymentStatus";
|
||||||
|
|
||||||
|
export function PaymentPage() {
|
||||||
|
const { orderId = "" } = useParams();
|
||||||
|
const nav = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const st = (useLocation().state as { payUrl?: string; total?: number; bucket?: string; base?: number } | null) ?? {};
|
||||||
|
const { data } = usePaymentStatus(orderId);
|
||||||
|
const paid = data?.status === "PAID";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paid) qc.invalidateQueries({ queryKey: ["balance"] });
|
||||||
|
}, [paid, qc]);
|
||||||
|
|
||||||
|
if (paid) {
|
||||||
|
return (
|
||||||
|
<div className="pt-24 flex flex-col items-center text-center">
|
||||||
|
<span className="w-14 h-14 rounded-full bg-pos text-white flex items-center justify-center mb-3"><Check size={28} /></span>
|
||||||
|
<p className="text-lg font-extrabold">Оплачено</p>
|
||||||
|
<p className="text-muted text-xs mt-1">{st.bucket} пополнен на {formatMoney(st.base ?? 0)}</p>
|
||||||
|
<button className="btn-primary mt-8" onClick={() => nav("/")}>На главную</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-10 flex flex-col items-center text-center">
|
||||||
|
<span className="w-12 h-12 rounded-xl2 bg-ink text-cream flex items-center justify-center font-extrabold mb-4">СБП</span>
|
||||||
|
<p className="text-lg font-extrabold">{formatMoney(st.total ?? 0)}</p>
|
||||||
|
<p className="text-muted text-xs mb-6">{st.bucket}</p>
|
||||||
|
<a className="btn-primary no-underline" href={st.payUrl} target="_blank" rel="noopener">Оплатить</a>
|
||||||
|
<p className="text-muted text-xs mt-2">Откроется ваше приложение банка</p>
|
||||||
|
<div className="flex items-center gap-2 mt-6 text-muted text-xs"><Spinner /> Ожидаем оплату…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { usePairPoll } from "@/hooks/usePairPoll";
|
||||||
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
|
||||||
|
export function TgWaitPage() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const pairSession = sessionStorage.getItem("pp-pair-session");
|
||||||
|
const { status } = usePairPoll(pairSession);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pairSession) { nav("/login", { replace: true }); }
|
||||||
|
}, [pairSession, nav]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "authenticated") { sessionStorage.removeItem("pp-pair-session"); nav("/", { replace: true }); }
|
||||||
|
if (status === "expired") { sessionStorage.removeItem("pp-pair-session"); nav("/login", { replace: true, state: { expired: true } }); }
|
||||||
|
}, [status, nav]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-16 text-center">
|
||||||
|
<h1 className="text-xl font-extrabold mb-2">Премиум Водитель</h1>
|
||||||
|
<p className="text-muted text-sm mb-8">Подтвердите номер в Telegram — нажмите «Поделиться номером», затем вернитесь сюда.</p>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
vi.mock("@/api/driver", () => ({
|
||||||
|
topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52, total: 1052 })),
|
||||||
|
getCommission: vi.fn(async () => ({ rate: 0.052 })),
|
||||||
|
}));
|
||||||
|
import { TopupPage } from "./TopupPage";
|
||||||
|
import * as driver from "@/api/driver";
|
||||||
|
|
||||||
|
const wrap = () => {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter><TopupPage /></MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,84 @@
|
|||||||
export function TopupPage() { return null; }
|
import { useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Delete } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { topup, getCommission } 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 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 { data: commission } = useQuery({ queryKey: ["commission"], queryFn: getCommission });
|
||||||
|
const rate = commission?.rate ?? 0.052; // fallback only while loading
|
||||||
|
|
||||||
|
const base = raw ? parseInt(raw, 10) : 0;
|
||||||
|
const { commission: fee, 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(fee)})
|
||||||
|
</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