Compare commits

..
4 Commits
8 changed files with 182 additions and 25 deletions
+2
View File
@@ -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>} />
+14 -1
View File
@@ -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", () => {
@@ -24,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");
});
});
+15
View File
@@ -24,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) =>
@@ -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 };
}
@@ -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");
+27 -1
View File
@@ -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);
@@ -59,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
@@ -89,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>
); );
} }
@@ -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>
);
}