From 9a116ad94e0d3b763b3c692b0eaa0915be749fec Mon Sep 17 00:00:00 2001 From: vladtechno Date: Thu, 18 Jun 2026 19:24:37 +1000 Subject: [PATCH] feat(driver-pwa): login (phone -> TG/MAX code) Co-Authored-By: claude-flow --- .../frontend/src/pages/LoginPage.test.tsx | 30 +++++++ driver-pwa/frontend/src/pages/LoginPage.tsx | 88 ++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 driver-pwa/frontend/src/pages/LoginPage.test.tsx diff --git a/driver-pwa/frontend/src/pages/LoginPage.test.tsx b/driver-pwa/frontend/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..f0d3434 --- /dev/null +++ b/driver-pwa/frontend/src/pages/LoginPage.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } 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", () => ({ + authRequest: vi.fn(async () => ({ status: "code_sent", channel: "tg" })), + authVerify: vi.fn(async () => ({ token: "T", driver_id: 7 })), +})); + +import { LoginPage } from "./LoginPage"; +import * as driver from "@/api/driver"; +import { useAuth } from "@/store/auth"; + +const wrap = () => render(); + +describe("LoginPage", () => { + beforeEach(() => useAuth.getState().clear()); + it("requests code then verifies and stores session", async () => { + wrap(); + expect(screen.getByText(/Вход в приложение/i)).toBeInTheDocument(); + await userEvent.type(screen.getByPlaceholderText(/телефон/i), "9143054400"); + await userEvent.click(screen.getByRole("button", { name: /Получить код/i })); + expect(driver.authRequest).toHaveBeenCalledWith("79143054400"); + const codeInput = await screen.findByPlaceholderText(/код/i); + await userEvent.type(codeInput, "123456"); + await userEvent.click(screen.getByRole("button", { name: /Войти/i })); + expect(driver.authVerify).toHaveBeenCalledWith("79143054400", "123456"); + }); +}); diff --git a/driver-pwa/frontend/src/pages/LoginPage.tsx b/driver-pwa/frontend/src/pages/LoginPage.tsx index 363dac5..919e80b 100644 --- a/driver-pwa/frontend/src/pages/LoginPage.tsx +++ b/driver-pwa/frontend/src/pages/LoginPage.tsx @@ -1,3 +1,89 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { authRequest, authVerify } from "@/api/driver"; +import { digitsOnly, formatPhone } from "@/lib/format"; +import { useAuth } from "@/store/auth"; +import { Spinner } from "@/components/Spinner"; + export function LoginPage() { - return
Вход в приложение
; + const nav = useNavigate(); + const setSession = useAuth((s) => s.setSession); + const [phone, setPhone] = useState(""); // national 10 digits + const [code, setCode] = useState(""); + const [step, setStep] = useState<"phone" | "code">("phone"); + const [channel, setChannel] = useState(""); + const [busy, setBusy] = useState(false); + const e164 = "7" + phone; + + async function requestCode() { + if (phone.length < 10) return; + setBusy(true); + try { + const r = await authRequest(e164); + if (r.status === "onboarding") { + sessionStorage.setItem("pp-onboarding", JSON.stringify({ ...r.deep_links, phone })); + nav("/onboarding"); + return; + } + setChannel(r.channel); + setStep("code"); + } catch (err: any) { + toast.error(err?.response?.status === 404 ? "Номер не найден. Обратитесь в парк." : "Не удалось отправить код"); + } finally { + setBusy(false); + } + } + + async function verify() { + if (code.length < 4) return; + setBusy(true); + try { + const r = await authVerify(e164, code); + setSession(r.token, r.driver_id); + nav("/"); + } catch { + toast.error("Неверный или просроченный код"); + } finally { + setBusy(false); + } + } + + return ( +
+

Премиум Водитель

+

Вход в приложение

+ + {step === "phone" ? ( + <> + setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))} + className="card w-full px-4 py-3 mb-3 text-base bg-white" + /> + +

Код придёт в Telegram или MAX.

+ + ) : ( + <> +

Код отправлен в {channel === "max" ? "MAX" : "Telegram"}.

+ setCode(digitsOnly(e.target.value).slice(0, 6))} + className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white" + /> + + + + )} +
+ ); }