Compare 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");
+50 -24
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,34 +73,46 @@ 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>
{step === "phone" ? ( {mode === "choose" ? (
<> <>
<input <button className="btn-primary" disabled={busy} onClick={loginViaTelegram}>
inputMode="numeric" {busy ? <Spinner /> : "Войти через Telegram"}
placeholder="Номер телефона"
value={phone ? formatPhone(phone) : ""}
onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))}
className="card w-full px-4 py-3 mb-3 text-base bg-white"
/>
<button className="btn-primary" disabled={busy || phone.length < 10} onClick={requestCode}>
{busy ? <Spinner /> : "Получить код"}
</button> </button>
<p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p> <p className="text-muted text-xs mt-3">Telegram подтвердит ваш номер автоматически.</p>
<button className="btn-ghost mt-4" onClick={() => setMode("code")}>Войти по коду</button>
</> </>
) : ( ) : (
<> <>
<p className="text-sm mb-2">Код отправлен в {channel === "max" ? "MAX" : "Telegram"}.</p> {step === "phone" ? (
<input <>
inputMode="numeric" <input
placeholder="Код из сообщения" inputMode="numeric"
value={code} placeholder="Номер телефона"
onChange={(e) => setCode(digitsOnly(e.target.value).slice(0, 6))} value={phone ? formatPhone(phone) : ""}
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white" onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))}
/> className="card w-full px-4 py-3 mb-3 text-base bg-white"
<button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}> />
{busy ? <Spinner /> : "Войти"} <button className="btn-primary" disabled={busy || phone.length < 10} onClick={requestCode}>
</button> {busy ? <Spinner /> : "Получить код"}
<button className="btn-ghost mt-2" onClick={() => setStep("phone")}>Изменить номер</button> </button>
<p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p>
</>
) : (
<>
<p className="text-sm mb-2">Код отправлен в {channel === "max" ? "MAX" : "Telegram"}.</p>
<input
inputMode="numeric"
placeholder="Код из сообщения"
value={code}
onChange={(e) => setCode(digitsOnly(e.target.value).slice(0, 6))}
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white"
/>
<button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}>
{busy ? <Spinner /> : "Войти"}
</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>
);
}