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"
+ />
+
+
+ >
+ )}
+
+ );
}