diff --git a/driver-pwa/frontend/src/App.tsx b/driver-pwa/frontend/src/App.tsx index 7d60ffa..baddb76 100644 --- a/driver-pwa/frontend/src/App.tsx +++ b/driver-pwa/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@/store/auth"; import { useStaff } from "@/store/staff"; +import { setReturnTo } from "@/lib/returnTo"; import { ImpersonationBar } from "@/components/ImpersonationBar"; import { LoginPage } from "@/pages/LoginPage"; import { TgWaitPage } from "@/pages/TgWaitPage"; @@ -19,7 +20,12 @@ import { markNotificationsRead } from "@/api/driver"; function RequireAuth({ children }: { children: ReactNode }) { const token = useAuth((s) => s.token); - return token ? <>{children}> : ; + const loc = useLocation(); + if (token) return <>{children}>; + // Запоминаем, куда шёл водитель (напр. ссылку на оплату), чтобы вернуть его + // туда после входа, а не на баланс. + setReturnTo(loc.pathname + loc.search); + return ; } /** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts index a6cdb53..f9247ca 100644 --- a/driver-pwa/frontend/src/api/driver.ts +++ b/driver-pwa/frontend/src/api/driver.ts @@ -86,6 +86,19 @@ export async function apiErrorText(err: unknown, fallback: string): Promise 0) return n; + } + return null; +} + export const tgSession = async () => TgSessionSchema.parse(await api.post("driver/auth/tg/session").json()); /** POST, а не GET: в query-параметре pair_session оседал в access-логах. */ diff --git a/driver-pwa/frontend/src/lib/returnTo.ts b/driver-pwa/frontend/src/lib/returnTo.ts new file mode 100644 index 0000000..1e704b2 --- /dev/null +++ b/driver-pwa/frontend/src/lib/returnTo.ts @@ -0,0 +1,32 @@ +/** + * Куда вернуть водителя после входа. + * + * Ссылки на оплату (`/pay/:id`) открываются под RequireAuth: незалогиненного + * бросает на /login. Раньше после входа он попадал на баланс, а не на оплату, + * ради которой пришёл. Запоминаем нужный путь тут (переживает переход через + * /tg-wait и онбординг), после любого успешного входа — забираем и очищаем. + */ +const KEY = "pp-return-to"; + +/** Путь для сохранения — только внутренние маршруты приложения. */ +export function setReturnTo(path: string | null | undefined): void { + try { + if (path && path.startsWith("/") && !path.startsWith("//") && path !== "/login") { + sessionStorage.setItem(KEY, path); + } + } catch { + /* приватный режим/недоступный storage — не критично */ + } +} + +/** Забрать сохранённый путь (и очистить). По умолчанию — на главную. */ +export function takeReturnTo(): string { + try { + const v = sessionStorage.getItem(KEY); + sessionStorage.removeItem(KEY); + if (v && v.startsWith("/") && !v.startsWith("//")) return v; + } catch { + /* см. выше */ + } + return "/"; +} diff --git a/driver-pwa/frontend/src/pages/LoginPage.tsx b/driver-pwa/frontend/src/pages/LoginPage.tsx index a14521c..df60c82 100644 --- a/driver-pwa/frontend/src/pages/LoginPage.tsx +++ b/driver-pwa/frontend/src/pages/LoginPage.tsx @@ -1,12 +1,23 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import { apiErrorText, authRequest, authVerify, tgSession } from "@/api/driver"; +import { apiErrorText, authRequest, authVerify, retryAfterSeconds, tgSession } from "@/api/driver"; import { digitsOnly, formatPhone } from "@/lib/format"; import { savePairing } from "@/lib/pairing"; +import { takeReturnTo } from "@/lib/returnTo"; import { useAuth } from "@/store/auth"; import { Spinner } from "@/components/Spinner"; +// Кулдаун повторной отправки кода — синхронно с бэкендом (60с). Точное значение +// приходит в Retry-After на 429; это дефолт после успешной отправки. +const RESEND_COOLDOWN_SEC = 60; + +function fmtMMSS(sec: number): string { + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + export function LoginPage() { const nav = useNavigate(); const setSession = useAuth((s) => s.setSession); @@ -16,6 +27,8 @@ export function LoginPage() { const [channel, setChannel] = useState(""); const [busy, setBusy] = useState(false); const [mode, setMode] = useState<"choose" | "code">("choose"); + // Секунды до разрешённой повторной отправки кода (0 — можно отправлять). + const [cooldown, setCooldown] = useState(0); // Prefetched pairing session so the «Войти через Telegram» control is a real // the OS opens in Telegram (a synchronous user gesture) WITHOUT // navigating the PWA away — the app stays mounted on /tg-wait and polls. @@ -29,6 +42,13 @@ export function LoginPage() { return () => { alive = false; }; }, []); + // Тик обратного отсчёта повторной отправки. + useEffect(() => { + if (cooldown <= 0) return; + const t = setTimeout(() => setCooldown((s) => Math.max(0, s - 1)), 1000); + return () => clearTimeout(t); + }, [cooldown]); + function startTg() { if (!tg) return; savePairing(tg); @@ -36,7 +56,7 @@ export function LoginPage() { } async function requestCode() { - if (phone.length < 10) return; + if (phone.length < 10 || cooldown > 0) return; setBusy(true); try { const r = await authRequest(e164); @@ -51,13 +71,23 @@ export function LoginPage() { } setChannel(r.channel); setStep("code"); + // Запускаем отсчёт до повторной отправки — чтобы водитель не жал повторно + // раньше времени и не упирался в «слишком часто». + setCooldown(RESEND_COOLDOWN_SEC); } catch (err: unknown) { const status = (err as { response?: { status?: number } })?.response?.status; - const fallback = - status === 404 ? "Номер не найден. Обратитесь в парк." : - status === 429 ? "Слишком часто. Попробуйте позже." : - "Не удалось отправить код"; - toast.error(await apiErrorText(err, fallback)); + if (status === 429) { + // Точный остаток — из Retry-After; ведём по нему отсчёт и не пугаем. + const wait = retryAfterSeconds(err) ?? RESEND_COOLDOWN_SEC; + setCooldown(wait); + if (step === "phone") setStep("code"); // код мог уйти ранее — дать ввести + toast.info(await apiErrorText(err, `Код уже отправляли. Повторить можно через ${wait} сек.`)); + } else { + const fallback = + status === 404 ? "Номер не найден. Обратитесь в парк." : + "Не удалось отправить код"; + toast.error(await apiErrorText(err, fallback)); + } } finally { setBusy(false); } @@ -69,7 +99,8 @@ export function LoginPage() { try { const r = await authVerify(e164, code); setSession(r.token, r.driver_id); - nav("/"); + // Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс. + nav(takeReturnTo(), { replace: true }); } catch (err: unknown) { toast.error(await apiErrorText(err, "Неверный или просроченный код")); } finally { @@ -118,8 +149,8 @@ export function LoginPage() { 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-surface" /> - - {busy ? : "Получить код"} + 0} onClick={requestCode}> + {busy ? : cooldown > 0 ? `Повторить через ${fmtMMSS(cooldown)}` : "Получить код"} Код придёт в Telegram или MAX. > @@ -136,7 +167,17 @@ export function LoginPage() { {busy ? : "Войти"} - setStep("phone")}>Изменить номер + {/* Повторная отправка прямо здесь: не пришёл код — не надо + возвращаться к номеру и упираться в «слишком часто». Кнопка + ждёт кулдаун и показывает, сколько осталось. */} + 0} + onClick={requestCode} + > + {cooldown > 0 ? `Отправить ещё раз через ${fmtMMSS(cooldown)}` : "Отправить код ещё раз"} + + setStep("phone")}>Изменить номер > )} > diff --git a/driver-pwa/frontend/src/pages/TgWaitPage.tsx b/driver-pwa/frontend/src/pages/TgWaitPage.tsx index ed87a9c..069b5fe 100644 --- a/driver-pwa/frontend/src/pages/TgWaitPage.tsx +++ b/driver-pwa/frontend/src/pages/TgWaitPage.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { usePairPoll } from "@/hooks/usePairPoll"; import { clearPairing, loadPairing } from "@/lib/pairing"; +import { takeReturnTo } from "@/lib/returnTo"; import { Spinner } from "@/components/Spinner"; export function TgWaitPage() { @@ -16,7 +17,8 @@ export function TgWaitPage() { useEffect(() => { if (status === "authenticated") { clearPairing(); - nav("/", { replace: true }); + // Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс. + nav(takeReturnTo(), { replace: true }); } if (status === "expired") { clearPairing();
Код придёт в Telegram или MAX.