feat(pwa): предложение включить уведомления после установки
Карточка появляется в установленном приложении (и сразу по событию appinstalled), объясняет пользу — новый штраф, дошедшее пополнение — и только по кнопке вызывает системное окно. Порядок именно такой, потому что системное окно показывается ОДИН раз за установку: отказ браузер запоминает навсегда, и переспросить нечем. Своё предложение отказа не стоит — «не сейчас» откладывает разговор на неделю. Во вкладке браузера карточку не показываем: на iPhone уведомления существуют только в установленном приложении, а человеку, ещё не решившему, нужен ли ему сайт, вопрос про уведомления мешает. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user