feat(pwa): Telegram login primary + code fallback + TgWaitPage
This commit is contained in:
@@ -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>} />
|
||||||
|
|||||||
@@ -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,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,23 @@
|
|||||||
|
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 (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