feat(pwa): предложение включить уведомления после установки

Карточка появляется в установленном приложении (и сразу по событию appinstalled),
объясняет пользу — новый штраф, дошедшее пополнение — и только по кнопке вызывает
системное окно.

Порядок именно такой, потому что системное окно показывается ОДИН раз за
установку: отказ браузер запоминает навсегда, и переспросить нечем. Своё
предложение отказа не стоит — «не сейчас» откладывает разговор на неделю.

Во вкладке браузера карточку не показываем: на iPhone уведомления существуют
только в установленном приложении, а человеку, ещё не решившему, нужен ли ему
сайт, вопрос про уведомления мешает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:50:23 +10:00
co-authored by Claude Opus 5
parent 562a6d35fe
commit 4b30b4bbbe
4 changed files with 149 additions and 0 deletions
@@ -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(<NotificationsPrompt />);
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(<NotificationsPrompt />);
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
});
it("«не сейчас» откладывает на неделю, а не отказывает навсегда", async () => {
render(<NotificationsPrompt />);
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(<NotificationsPrompt />);
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
});
it("уже включено — карточки нет", async () => {
vi.mocked(push.currentState).mockResolvedValue("on");
render(<NotificationsPrompt />);
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
});
});
@@ -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<PushState | null>(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 (
<div className="card p-4 mb-3">
<div className="flex items-start gap-3">
<Bell size={18} strokeWidth={1.5} className="text-muted shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-semibold mb-1">Сообщать о новом?</p>
<p className="text-muted text-xs">
Пришлём уведомление, когда появится новый штраф или когда пополнение
дойдёт до баланса.
</p>
</div>
</div>
<div className="flex gap-2 mt-3">
<button className="btn-primary" disabled={busy} onClick={turnOn}>Включить</button>
<button className="btn-ghost" disabled={busy} onClick={later}>Не сейчас</button>
</div>
</div>
);
}
+5
View File
@@ -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
@@ -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() {
<div>
<AppHeader />
<NotificationsPrompt />
{/* ── Деньги ─────────────────────────────────────────────── */}
<div className="card p-4 mb-3">
<div className="flex justify-between items-baseline">