Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ab4e9e74e | ||
|
|
01c36ce935 | ||
|
|
0ec8f0b540 | ||
|
|
bd1a4a31bd | ||
|
|
b419a456fe |
@@ -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>} />
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
@@ -14,6 +14,9 @@ describe("driver api schemas", () => {
|
|||||||
expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("code_sent");
|
expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("code_sent");
|
||||||
const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" } });
|
const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" } });
|
||||||
expect(o.status === "onboarding" && o.deep_links.tg).toBe("t");
|
expect(o.status === "onboarding" && o.deep_links.tg).toBe("t");
|
||||||
|
// prod: MAX bot not configured → backend omits `max`, must still parse (regression guard)
|
||||||
|
const tgOnly = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t" } });
|
||||||
|
expect(tgOnly.status === "onboarding" && tgOnly.deep_links.tg).toBe("t");
|
||||||
});
|
});
|
||||||
it("parses payments history", () => {
|
it("parses payments history", () => {
|
||||||
const v = PaymentsSchema.parse({ payments: [{ order_id: "o1", bucket: "b", amount: 100,
|
const v = PaymentsSchema.parse({ payments: [{ order_id: "o1", bucket: "b", amount: 100,
|
||||||
@@ -21,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { api } from "./client";
|
|||||||
|
|
||||||
export const AuthRequestSchema = z.union([
|
export const AuthRequestSchema = z.union([
|
||||||
z.object({ status: z.literal("code_sent"), channel: z.string() }),
|
z.object({ status: z.literal("code_sent"), channel: z.string() }),
|
||||||
z.object({ status: z.literal("onboarding"), deep_links: z.object({ tg: z.string(), max: z.string() }) }),
|
z.object({ status: z.literal("onboarding"), deep_links: z.object({ tg: z.string().optional(), max: z.string().optional() }) }),
|
||||||
]);
|
]);
|
||||||
export type AuthRequest = z.infer<typeof AuthRequestSchema>;
|
export type AuthRequest = z.infer<typeof AuthRequestSchema>;
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user