feat(pwa): свой сервис-воркер и включение уведомлений
Воркер теперь собирается через injectManifest: generateSW умеет только кэш, а уведомление показывает именно воркер — страница при этом может быть закрыта. Нажатие на уведомление переиспользует уже открытое окно, а не плодит второе. Включение — строкой на главном экране, по нажатию. Не всплывающим окном на входе: разрешение спрашивается один раз за установку, отказ браузер запоминает навсегда, и потраченная впустую попытка закрывает канал. Если сервер уведомления не настроил — разрешение не запрашиваем вовсе. Отдельные тексты вместо молчания: на iPhone без установки на домашний экран уведомления невозможны в принципе, и об этом сказано прямо. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+3
-1
@@ -33,7 +33,9 @@
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.0.5"
|
||||
"vitest": "^2.0.5",
|
||||
"workbox-core": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.0.5"
|
||||
"vitest": "^2.0.5",
|
||||
"workbox-core": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,3 +183,17 @@ export const getFines = async (status: "all" | "open") =>
|
||||
|
||||
export const getFine = async (id: number) =>
|
||||
FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json());
|
||||
|
||||
export const PushKeySchema = z.object({ key: z.string(), enabled: z.boolean() });
|
||||
|
||||
export const getPushKey = async () =>
|
||||
PushKeySchema.parse(await api.get("driver/push/key").json());
|
||||
|
||||
export const subscribePush = async (sub: { endpoint: string; p256dh: string; auth: string }) =>
|
||||
api.post("driver/push/subscribe", { json: sub }).json();
|
||||
|
||||
export const unsubscribePush = async (endpoint: string) =>
|
||||
api.post("driver/push/unsubscribe", { json: { endpoint } }).json();
|
||||
|
||||
/** Пробное уведомление себе. Доступно только при просмотре сотрудником. */
|
||||
export const sendTestPush = async () => api.post("driver/push/test").json();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bell, BellOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { currentState, disable, enable, type PushState } from "@/lib/push";
|
||||
|
||||
/**
|
||||
* Включение уведомлений — одной строкой на главном экране.
|
||||
*
|
||||
* Не всплывающим окном на входе: разрешение спрашивается один раз за установку,
|
||||
* и потраченная впустую попытка закрывает канал навсегда. Здесь водитель жмёт
|
||||
* сам, увидев, ради чего.
|
||||
*/
|
||||
export function NotificationsRow() {
|
||||
const [state, setState] = useState<PushState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
currentState().then((s) => { if (alive) setState(s); });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
if (state === null || state === "unsupported") return null;
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(state === "on" ? await disable() : await enable());
|
||||
} catch {
|
||||
toast.error("Не удалось изменить настройку уведомлений");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (state === "needs-install") {
|
||||
return (
|
||||
<p className="text-muted text-xs text-center mt-6">
|
||||
Чтобы получать уведомления о штрафах и платежах, добавьте приложение на домашний экран.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "denied") {
|
||||
return (
|
||||
<p className="text-muted text-xs text-center mt-6">
|
||||
Уведомления запрещены в настройках браузера — включить их можно только там.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 text-muted text-xs mt-6 w-full"
|
||||
disabled={busy}
|
||||
onClick={toggle}
|
||||
>
|
||||
{state === "on" ? <BellOff size={15} /> : <Bell size={15} />}
|
||||
{state === "on" ? "Выключить уведомления" : "Включить уведомления"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/api/driver", () => ({
|
||||
getPushKey: vi.fn(async () => ({ key: "BIIh_RELLY", enabled: true })),
|
||||
subscribePush: vi.fn(async () => ({ ok: true })),
|
||||
unsubscribePush: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
import { currentState, enable, supported } from "./push";
|
||||
import * as api from "@/api/driver";
|
||||
|
||||
const SUB = {
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
|
||||
toJSON: () => ({
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
|
||||
keys: { p256dh: "p256", auth: "auth" },
|
||||
}),
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
};
|
||||
|
||||
function stubBrowser(opts: {
|
||||
permission?: NotificationPermission;
|
||||
existing?: unknown;
|
||||
ios?: boolean;
|
||||
standalone?: boolean;
|
||||
} = {}) {
|
||||
const perm = opts.permission ?? "default";
|
||||
const registration = {
|
||||
pushManager: {
|
||||
getSubscription: vi.fn(async () => opts.existing ?? null),
|
||||
subscribe: vi.fn(async () => SUB),
|
||||
},
|
||||
};
|
||||
vi.stubGlobal("navigator", {
|
||||
serviceWorker: {
|
||||
getRegistration: vi.fn(async () => registration),
|
||||
ready: Promise.resolve(registration),
|
||||
},
|
||||
userAgent: opts.ios ? "iPhone" : "Android Chrome",
|
||||
standalone: opts.standalone,
|
||||
});
|
||||
const notification = {
|
||||
permission: perm,
|
||||
requestPermission: vi.fn(async () => (opts.permission === "denied" ? "denied" : "granted")),
|
||||
};
|
||||
// Notification обязан лежать И в window: supported() проверяет именно его —
|
||||
// так же, как это делает браузер.
|
||||
vi.stubGlobal("window", {
|
||||
PushManager: function () {},
|
||||
Notification: notification,
|
||||
matchMedia: () => ({ matches: !!opts.standalone }),
|
||||
});
|
||||
vi.stubGlobal("Notification", notification);
|
||||
return registration;
|
||||
}
|
||||
|
||||
describe("уведомления в приложении", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("iPhone без установки на экран: объясняем, а не молчим", async () => {
|
||||
vi.stubGlobal("navigator", { userAgent: "iPhone", standalone: false });
|
||||
vi.stubGlobal("window", { matchMedia: () => ({ matches: false }) });
|
||||
expect(supported()).toBe(false);
|
||||
expect(await currentState()).toBe("needs-install");
|
||||
});
|
||||
|
||||
it("разрешение отклонено — переспросить нечем", async () => {
|
||||
stubBrowser({ permission: "denied" });
|
||||
expect(await currentState()).toBe("denied");
|
||||
});
|
||||
|
||||
it("подписка уходит на сервер с ключами из браузера", async () => {
|
||||
stubBrowser();
|
||||
expect(await enable()).toBe("on");
|
||||
expect(api.subscribePush).toHaveBeenCalledWith({
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
|
||||
p256dh: "p256",
|
||||
auth: "auth",
|
||||
});
|
||||
});
|
||||
|
||||
it("сервер не настроен — разрешение НЕ спрашиваем", async () => {
|
||||
// Единственная попытка на установку: потратить её впустую нельзя.
|
||||
vi.mocked(api.getPushKey).mockResolvedValueOnce({ key: "", enabled: false });
|
||||
stubBrowser();
|
||||
expect(await enable()).toBe("off");
|
||||
expect(Notification.requestPermission).not.toHaveBeenCalled();
|
||||
expect(api.subscribePush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getPushKey, subscribePush, unsubscribePush } from "@/api/driver";
|
||||
|
||||
/**
|
||||
* Включение уведомлений в приложении.
|
||||
*
|
||||
* Главное правило экрана вокруг этого кода: разрешение спрашивается ОДИН раз за
|
||||
* всю жизнь установки. Отказ браузер запоминает, переспросить нельзя — только
|
||||
* руками в настройках сайта, куда водитель не пойдёт. Поэтому запрос идёт
|
||||
* строго по нажатию и только когда сервер подтвердил, что уведомления настроены.
|
||||
*/
|
||||
export type PushState =
|
||||
| "unsupported" // браузер не умеет (или iOS без установки на домашний экран)
|
||||
| "needs-install" // iOS: умеет, но только когда приложение добавлено на экран
|
||||
| "denied" // разрешение отклонено — переспросить нечем
|
||||
| "off" // можно включить
|
||||
| "on"; // подписка есть
|
||||
|
||||
function isStandalone(): boolean {
|
||||
return window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS до 17 не поддерживает display-mode: standalone в matchMedia
|
||||
(navigator as { standalone?: boolean }).standalone === true;
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function supported(): boolean {
|
||||
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
}
|
||||
|
||||
export async function currentState(): Promise<PushState> {
|
||||
if (!supported()) return isIos() && !isStandalone() ? "needs-install" : "unsupported";
|
||||
if (Notification.permission === "denied") return "denied";
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
return sub ? "on" : "off";
|
||||
}
|
||||
|
||||
/** base64url → байты: ключ VAPID браузер принимает только так.
|
||||
* Возвращаем ArrayBuffer, а не Uint8Array: типы DOM ждут BufferSource. */
|
||||
function keyToBytes(base64: string): ArrayBuffer {
|
||||
const padded = base64.replace(/-/g, "+").replace(/_/g, "/") +
|
||||
"=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const raw = atob(padded);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/** Включить уведомления. Возвращает новое состояние. */
|
||||
export async function enable(): Promise<PushState> {
|
||||
if (!supported()) return currentState();
|
||||
const { key, enabled } = await getPushKey();
|
||||
// Ключа нет — сервер уведомления не отправляет. Разрешение не спрашиваем:
|
||||
// потратить единственную попытку впустую нельзя.
|
||||
if (!enabled || !key) return "off";
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return permission === "denied" ? "denied" : "off";
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: keyToBytes(key),
|
||||
});
|
||||
const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
|
||||
await subscribePush({
|
||||
endpoint: json.endpoint ?? sub.endpoint,
|
||||
p256dh: json.keys?.p256dh ?? "",
|
||||
auth: json.keys?.auth ?? "",
|
||||
});
|
||||
return "on";
|
||||
}
|
||||
|
||||
/** Выключить: снимаем подписку и в браузере, и на сервере. */
|
||||
export async function disable(): Promise<PushState> {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
// Сначала сервер: не сумеем достучаться — подписка останется живой, и
|
||||
// уведомления продолжат приходить, а это лучше «выключил, а они идут».
|
||||
await unsubscribePush(sub.endpoint);
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
@@ -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 { NotificationsRow } from "@/components/NotificationsRow";
|
||||
import { Sheet } from "@/components/Sheet";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { Statement } from "@/components/Statement";
|
||||
@@ -109,6 +110,8 @@ export function BalancePage() {
|
||||
<Statement from={from} to={to} kind={kind}
|
||||
onFrom={setFrom} onTo={setTo} onKind={setKind} />
|
||||
|
||||
<NotificationsRow />
|
||||
|
||||
<div className="flex justify-center mt-6 text-muted text-xs">
|
||||
{/* Отдельного раздела «Мои пополнения» нет намеренно: пополнения видны в
|
||||
выписке выше по фильтру, а дублирующий пустой экран сбивал с толку. */}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
||||
import { clientsClaim } from "workbox-core";
|
||||
|
||||
/**
|
||||
* Сервис-воркер приложения.
|
||||
*
|
||||
* Свой, а не сгенерированный: `generateSW` умеет только кэш, а нам нужны
|
||||
* обработчики `push` и `notificationclick` — уведомления показывает именно
|
||||
* воркер, страница для этого может быть закрыта.
|
||||
*
|
||||
* Кэш остаётся прежним (precache от workbox), поведение обновления тоже:
|
||||
* `skipWaiting` + `clientsClaim`, как было при registerType: "autoUpdate".
|
||||
*/
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
cleanupOutdatedCaches();
|
||||
self.skipWaiting();
|
||||
clientsClaim();
|
||||
|
||||
interface PushPayload {
|
||||
title?: string;
|
||||
body?: string;
|
||||
url?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event: PushEvent) => {
|
||||
let data: PushPayload = {};
|
||||
try {
|
||||
data = event.data ? (event.data.json() as PushPayload) : {};
|
||||
} catch {
|
||||
// Пуш без разбираемой нагрузки: показать что-то всё равно надо — молчание
|
||||
// выглядит как «уведомления не работают».
|
||||
data = {};
|
||||
}
|
||||
const title = data.title || "Премиум Водитель";
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: data.body || "",
|
||||
icon: "/icons/icon-192.png",
|
||||
badge: "/icons/icon-192.png",
|
||||
// tag схлопывает однотипные: пять штрафов подряд не завалят шторку.
|
||||
tag: data.tag,
|
||||
data: { url: data.url || "/" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
||||
event.notification.close();
|
||||
const target = (event.notification.data?.url as string) || "/";
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
// Уже открытое окно приложения переиспользуем: второй экземпляр рядом с
|
||||
// первым сбивает с толку, и водитель теряет то, что было на экране.
|
||||
for (const client of all) {
|
||||
if ("focus" in client) {
|
||||
await client.focus();
|
||||
if ("navigate" in client) await client.navigate(target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await self.clients.openWindow(target);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true,
|
||||
"noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true,
|
||||
"noUnusedParameters": true, "noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
"baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vite-plugin-pwa/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -54,7 +54,17 @@ export default defineConfig(({ command, mode }) => {
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }),
|
||||
// injectManifest, а не generateSW: воркер свой, потому что уведомления
|
||||
// показывает он (см. src/sw.ts), а сгенерированный умеет только кэш.
|
||||
VitePWA({
|
||||
strategies: "injectManifest",
|
||||
srcDir: "src",
|
||||
filename: "sw.ts",
|
||||
registerType: "autoUpdate",
|
||||
manifest: false,
|
||||
includeAssets: ["icons/*.png"],
|
||||
injectManifest: { globPatterns: ["**/*.{js,css,html,png,svg,webmanifest}"] },
|
||||
}),
|
||||
],
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user