Compare commits
4
Commits
b419a456fe
...
5ab4e9e74e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ab4e9e74e | ||
|
|
01c36ce935 | ||
|
|
0ec8f0b540 | ||
|
|
bd1a4a31bd |
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { LoginPage } from "@/pages/LoginPage";
|
||||
import { TgWaitPage } from "@/pages/TgWaitPage";
|
||||
import { OnboardingPage } from "@/pages/OnboardingPage";
|
||||
import { BalancePage } from "@/pages/BalancePage";
|
||||
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">
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/tg-wait" element={<TgWaitPage />} />
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
<Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
|
||||
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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", () => {
|
||||
it("parses balance", () => {
|
||||
@@ -24,3 +24,16 @@ describe("driver api schemas", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json());
|
||||
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", () => ({
|
||||
authRequest: vi.fn(async () => ({ status: "code_sent", channel: "tg" })),
|
||||
authVerify: vi.fn(async () => ({ token: "T", driver_id: 7 })),
|
||||
tgSession: vi.fn(async () => ({ pair_session: "sess123", deep_link: null })),
|
||||
}));
|
||||
|
||||
import { LoginPage } from "./LoginPage";
|
||||
@@ -19,6 +20,7 @@ describe("LoginPage", () => {
|
||||
it("requests code then verifies and stores session", async () => {
|
||||
wrap();
|
||||
expect(screen.getByText(/Вход в приложение/i)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /Войти по коду/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/телефон/i), "9143054400");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Получить код/i }));
|
||||
expect(driver.authRequest).toHaveBeenCalledWith("79143054400");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { authRequest, authVerify } from "@/api/driver";
|
||||
import { authRequest, authVerify, tgSession } from "@/api/driver";
|
||||
import { digitsOnly, formatPhone } from "@/lib/format";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
@@ -14,8 +14,22 @@ export function LoginPage() {
|
||||
const [step, setStep] = useState<"phone" | "code">("phone");
|
||||
const [channel, setChannel] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [mode, setMode] = useState<"choose" | "code">("choose");
|
||||
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() {
|
||||
if (phone.length < 10) return;
|
||||
setBusy(true);
|
||||
@@ -59,34 +73,46 @@ export function LoginPage() {
|
||||
<h1 className="text-xl font-extrabold mb-1">Премиум Водитель</h1>
|
||||
<p className="text-muted text-sm mb-8">Вход в приложение</p>
|
||||
|
||||
{step === "phone" ? (
|
||||
{mode === "choose" ? (
|
||||
<>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
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 className="btn-primary" disabled={busy} onClick={loginViaTelegram}>
|
||||
{busy ? <Spinner /> : "Войти через Telegram"}
|
||||
</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>
|
||||
<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>
|
||||
{step === "phone" ? (
|
||||
<>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user