diff --git a/driver-pwa/frontend/src/components/NotificationsPrompt.test.tsx b/driver-pwa/frontend/src/components/NotificationsPrompt.test.tsx new file mode 100644 index 0000000..2619437 --- /dev/null +++ b/driver-pwa/frontend/src/components/NotificationsPrompt.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("@/lib/push", () => ({ + currentState: vi.fn(async () => "off"), + enable: vi.fn(async () => "on"), + installed: vi.fn(() => true), +})); + +import { NotificationsPrompt } from "./NotificationsPrompt"; +import * as push from "@/lib/push"; + +describe("предложение включить уведомления", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.mocked(push.installed).mockReturnValue(true); + vi.mocked(push.currentState).mockResolvedValue("off"); + }); + + it("установленному приложению предлагаем — своей карточкой, а не системным окном", async () => { + render(); + expect(await screen.findByText("Сообщать о новом?")).toBeInTheDocument(); + // Системное окно вызывается только по кнопке: оно показывается один раз за + // установку, и отказ браузер запоминает навсегда. + expect(push.enable).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: "Включить" })); + expect(push.enable).toHaveBeenCalled(); + }); + + it("во вкладке браузера не предлагаем: на iPhone это всё равно не работает", async () => { + vi.mocked(push.installed).mockReturnValue(false); + render(); + expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument(); + }); + + it("«не сейчас» откладывает на неделю, а не отказывает навсегда", async () => { + render(); + await userEvent.click(await screen.findByRole("button", { name: "Не сейчас" })); + expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument(); + const until = Number(localStorage.getItem("pp-push-prompt-later")); + const days = (until - Date.now()) / 86400_000; + expect(days).toBeGreaterThan(6.5); + expect(days).toBeLessThan(7.5); + + // Пока срок не вышел — не показываем повторно. + render(); + expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument(); + }); + + it("уже включено — карточки нет", async () => { + vi.mocked(push.currentState).mockResolvedValue("on"); + render(); + expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument(); + }); +}); diff --git a/driver-pwa/frontend/src/components/NotificationsPrompt.tsx b/driver-pwa/frontend/src/components/NotificationsPrompt.tsx new file mode 100644 index 0000000..9f0cea8 --- /dev/null +++ b/driver-pwa/frontend/src/components/NotificationsPrompt.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Bell } from "lucide-react"; +import { toast } from "sonner"; +import { currentState, enable, installed, type PushState } from "@/lib/push"; + +/** + * Предложение включить уведомления — после установки приложения. + * + * Почему своя карточка, а не сразу системное окно. Системное показывается ОДИН + * раз за установку: отказ браузер запоминает навсегда, и переспросить нельзя. + * Своё предложение отказа не стоит — «не сейчас» просто откладывает его на + * неделю, и вернуться к разговору можно. + * + * Почему после установки, а не на входе. На iPhone уведомления работают только в + * установленном приложении, а человеку, который ещё не решил, нужен ли ему этот + * сайт вообще, вопрос про уведомления мешает. + */ +const DELAY_KEY = "pp-push-prompt-later"; +const REMIND_AFTER_DAYS = 7; + +function postponed(): boolean { + const raw = localStorage.getItem(DELAY_KEY); + if (!raw) return false; + const until = Number(raw); + return Number.isFinite(until) && Date.now() < until; +} + +export function NotificationsPrompt() { + const [state, setState] = useState(null); + const [hidden, setHidden] = useState(postponed()); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let alive = true; + const refresh = () => currentState().then((s) => { if (alive) setState(s); }); + refresh(); + // Приложение только что поставили на домашний экран — самое время спросить, + // человек как раз занят настройкой. + const onInstalled = () => { setHidden(false); refresh(); }; + window.addEventListener("appinstalled", onInstalled); + return () => { alive = false; window.removeEventListener("appinstalled", onInstalled); }; + }, []); + + // Показываем только установленному приложению и только пока разрешение не + // спрашивали: «включить» для остальных живёт строкой внизу экрана. + if (hidden || state !== "off" || !installed()) return null; + + async function turnOn() { + setBusy(true); + try { + const next = await enable(); + setState(next); + if (next !== "on") toast.error("Уведомления не включились"); + } catch { + toast.error("Не удалось включить уведомления"); + } finally { + setBusy(false); + } + } + + function later() { + localStorage.setItem(DELAY_KEY, String(Date.now() + REMIND_AFTER_DAYS * 86400_000)); + setHidden(true); + } + + return ( +
+
+ +
+

Сообщать о новом?

+

+ Пришлём уведомление, когда появится новый штраф или когда пополнение + дойдёт до баланса. +

+
+
+
+ + +
+
+ ); +} diff --git a/driver-pwa/frontend/src/lib/push.ts b/driver-pwa/frontend/src/lib/push.ts index 452c32b..5dd653d 100644 --- a/driver-pwa/frontend/src/lib/push.ts +++ b/driver-pwa/frontend/src/lib/push.ts @@ -15,6 +15,11 @@ export type PushState = | "off" // можно включить | "on"; // подписка есть +/** Приложение открыто как установленное (с домашнего экрана), а не вкладкой. */ +export function installed(): boolean { + return isStandalone(); +} + function isStandalone(): boolean { return window.matchMedia("(display-mode: standalone)").matches || // iOS до 17 не поддерживает display-mode: standalone в matchMedia diff --git a/driver-pwa/frontend/src/pages/BalancePage.tsx b/driver-pwa/frontend/src/pages/BalancePage.tsx index e999721..c53d20b 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.tsx @@ -6,6 +6,7 @@ import { getBalance, type StatementKind } from "@/api/driver"; import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format"; import { useAuth } from "@/store/auth"; import { AppHeader } from "@/components/AppHeader"; +import { NotificationsPrompt } from "@/components/NotificationsPrompt"; import { NotificationsRow } from "@/components/NotificationsRow"; import { Sheet } from "@/components/Sheet"; import { Spinner } from "@/components/Spinner"; @@ -51,6 +52,8 @@ export function BalancePage() {
+ + {/* ── Деньги ─────────────────────────────────────────────── */}