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",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.4.2",
|
"vite": "^5.4.2",
|
||||||
"vite-plugin-pwa": "^0.20.5",
|
"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": {
|
"node_modules/@adobe/css-tools": {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.4.2",
|
"vite": "^5.4.2",
|
||||||
"vite-plugin-pwa": "^0.20.5",
|
"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) =>
|
export const getFine = async (id: number) =>
|
||||||
FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json());
|
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 { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
import { AppHeader } from "@/components/AppHeader";
|
||||||
|
import { NotificationsRow } from "@/components/NotificationsRow";
|
||||||
import { Sheet } from "@/components/Sheet";
|
import { Sheet } from "@/components/Sheet";
|
||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
import { Statement } from "@/components/Statement";
|
import { Statement } from "@/components/Statement";
|
||||||
@@ -109,6 +110,8 @@ export function BalancePage() {
|
|||||||
<Statement from={from} to={to} kind={kind}
|
<Statement from={from} to={to} kind={kind}
|
||||||
onFrom={setFrom} onTo={setTo} onKind={setKind} />
|
onFrom={setFrom} onTo={setTo} onKind={setKind} />
|
||||||
|
|
||||||
|
<NotificationsRow />
|
||||||
|
|
||||||
<div className="flex justify-center mt-6 text-muted text-xs">
|
<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": {
|
"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",
|
"module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true,
|
"allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true,
|
||||||
"noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true,
|
"noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true,
|
||||||
"noUnusedParameters": true, "noFallthroughCasesInSwitch": 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"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,17 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
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") } },
|
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user