From 1641b14f293c3f1eb9ec56a298b712cfb2e7c08e Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 15 Apr 2026 12:14:03 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20PWA=20push=20notifications=20=E2=80=94?= =?UTF-8?q?=20service=20worker,=20VAPID,=20subscription=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add web-push VAPID backend with push_subscriptions table (migration 078) - Backend push module with sendPushToUser/sendPushToRoom helpers - Push routes: GET vapid-key, POST subscribe, DELETE unsubscribe - Trigger push on new chat messages in direct/group rooms - PWA manifest, service worker, and icons (72–512px) - Frontend push lib: SW registration, subscribe/unsubscribe helpers - Push API in api.ts, SW registered in main.tsx - usePushSubscription hook for auto-subscribe on permission grant - PushPermissionRow component in ChatWidget settings view Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/078_push_subscriptions.sql | 9 + backend/package-lock.json | 101 ++- backend/package.json | 4 +- backend/src/app.ts | 4 + backend/src/push.ts | 67 ++ backend/src/routes/chat.ts | 27 +- backend/src/routes/push.ts | 39 ++ index.html | 7 + package-lock.json | 578 ++++++++++++++++++ package.json | 1 + public/icons/icon-128.png | Bin 0 -> 2475 bytes public/icons/icon-144.png | Bin 0 -> 2835 bytes public/icons/icon-152.png | Bin 0 -> 2954 bytes public/icons/icon-180.png | Bin 0 -> 3533 bytes public/icons/icon-192.png | Bin 0 -> 3822 bytes public/icons/icon-384.png | Bin 0 -> 9495 bytes public/icons/icon-512.png | Bin 0 -> 14614 bytes public/icons/icon-72.png | Bin 0 -> 1321 bytes public/icons/icon-96.png | Bin 0 -> 1741 bytes public/manifest.json | 20 + public/sw.js | 35 ++ scripts/generate-icons.mjs | 22 + src/components/chat/ChatWidget.tsx | 57 ++ src/hooks/usePushSubscription.ts | 37 ++ src/lib/api.ts | 9 + src/lib/push.ts | 42 ++ src/main.tsx | 4 + 27 files changed, 1056 insertions(+), 7 deletions(-) create mode 100644 backend/migrations/078_push_subscriptions.sql create mode 100644 backend/src/push.ts create mode 100644 backend/src/routes/push.ts create mode 100644 public/icons/icon-128.png create mode 100644 public/icons/icon-144.png create mode 100644 public/icons/icon-152.png create mode 100644 public/icons/icon-180.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-384.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon-72.png create mode 100644 public/icons/icon-96.png create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 scripts/generate-icons.mjs create mode 100644 src/hooks/usePushSubscription.ts create mode 100644 src/lib/push.ts diff --git a/backend/migrations/078_push_subscriptions.sql b/backend/migrations/078_push_subscriptions.sql new file mode 100644 index 0000000..1d93800 --- /dev/null +++ b/backend/migrations/078_push_subscriptions.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id); diff --git a/backend/package-lock.json b/backend/package-lock.json index 9a3aec7..9c1f5f3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,11 +16,13 @@ "@fastify/rate-limit": "^9.1.0", "@fastify/static": "^7.0.4", "@fastify/websocket": "^8.3.1", + "@types/web-push": "^3.6.4", "bcryptjs": "^2.4.3", "fastify": "^4.28.1", "ioredis": "^5.3.2", "nodemailer": "^8.0.2", - "pg": "^8.12.0" + "pg": "^8.12.0", + "web-push": "^3.6.7" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -720,7 +722,6 @@ "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -748,6 +749,15 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -764,6 +774,15 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -895,6 +914,12 @@ "balanced-match": "^1.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -1328,6 +1353,15 @@ "node": ">=16.0.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -1344,6 +1378,19 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1428,6 +1475,27 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/light-my-request": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", @@ -1490,6 +1558,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2172,9 +2249,27 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index f56c86e..434bca5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,11 +17,13 @@ "@fastify/rate-limit": "^9.1.0", "@fastify/static": "^7.0.4", "@fastify/websocket": "^8.3.1", + "@types/web-push": "^3.6.4", "bcryptjs": "^2.4.3", "fastify": "^4.28.1", "ioredis": "^5.3.2", "nodemailer": "^8.0.2", - "pg": "^8.12.0" + "pg": "^8.12.0", + "web-push": "^3.6.7" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/backend/src/app.ts b/backend/src/app.ts index b3f0f49..e5d3738 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -34,6 +34,7 @@ import notificationsRoutes from './routes/notifications' import scheduleRoutes from './routes/schedule' import loyaltyRoutes from './routes/loyalty' import chatRoutes from './routes/chat' +import pushRoutes from './routes/push' import workstationRoutes from './routes/workstations' import agentReleaseRoutes from './routes/agent-release' import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings' @@ -48,6 +49,7 @@ import publicWidgetRoutes from './routes/publicWidget' import yookassaWebhookRoutes from './routes/yookassaWebhook' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' +import { initWebPush } from './push' export async function buildApp() { const fastify = Fastify({ @@ -131,6 +133,7 @@ export async function buildApp() { await fastify.register(scheduleRoutes) await fastify.register(loyaltyRoutes) await fastify.register(chatRoutes) + await fastify.register(pushRoutes) await fastify.register(workstationRoutes) await fastify.register(agentReleaseRoutes) await fastify.register(wifiSettingsRoutes) @@ -147,6 +150,7 @@ export async function buildApp() { await fastify.register(setupAgentWsRoute) startJobs() + initWebPush() return fastify } diff --git a/backend/src/push.ts b/backend/src/push.ts new file mode 100644 index 0000000..b4d63ee --- /dev/null +++ b/backend/src/push.ts @@ -0,0 +1,67 @@ +import webpush from 'web-push' +import { db } from './db' + +export function initWebPush() { + webpush.setVapidDetails( + 'mailto:noreply@hotelsync.ru', + process.env.VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY!, + ) +} + +export interface PushPayload { + title: string + body: string + url?: string + tag?: string + icon?: string +} + +export async function sendPushToUser(userId: string, payload: PushPayload): Promise { + if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return + try { + const { rows } = await db.query( + 'SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1', + [userId], + ) + for (const sub of rows) { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify(payload), + ) + } catch (err) { + const statusCode = (err as { statusCode?: number }).statusCode + if (statusCode === 410 || statusCode === 404) { + await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint]) + } + } + } + } catch { /**/ } +} + +export async function sendPushToRoom(roomId: string, senderUserId: string, payload: PushPayload): Promise { + if (!process.env.VAPID_PUBLIC_KEY) return + try { + const { rows } = await db.query( + `SELECT DISTINCT ps.user_id, ps.endpoint, ps.p256dh, ps.auth + FROM push_subscriptions ps + JOIN chat_room_members crm ON crm.user_id = ps.user_id + WHERE crm.room_id = $1 AND ps.user_id != $2`, + [roomId, senderUserId], + ) + for (const sub of rows) { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify(payload), + ) + } catch (err) { + const statusCode = (err as { statusCode?: number }).statusCode + if (statusCode === 410 || statusCode === 404) { + await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint]) + } + } + } + } catch { /**/ } +} diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 349ae76..1cb3fd8 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify' import { db } from '../db' +import { sendPushToRoom } from '../push' type SlugParam = { Params: { slug: string } } type RoomParam = { Params: { slug: string; roomId: string } } @@ -183,19 +184,39 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { rows: roomRows } = await db.query('SELECT type FROM chat_rooms WHERE id = $1', [roomId]) + const { rows: roomRows } = await db.query('SELECT type, name FROM chat_rooms WHERE id = $1', [roomId]) if (roomRows[0]?.type === 'notifications') return reply.code(403).send({ error: 'Cannot post to notifications room' }) + const activeRoom = roomRows[0] as { type: string; name: string | null } | undefined const { text, attachment_url } = request.body if (!text?.trim() && !attachment_url) return reply.code(400).send({ error: 'Text or attachment required' }) + const userId = request.user.sub const { rows } = await db.query( `INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, attachment_url) VALUES ($1, $2, $3, $4, $5) RETURNING id`, - [roomId, hotelId, request.user.sub, text?.trim() ?? '', attachment_url ?? null], + [roomId, hotelId, userId, text?.trim() ?? '', attachment_url ?? null], ) - const msg = await fetchMessage(rows[0].id, request.user.sub) + const msg = await fetchMessage(rows[0].id, userId) + + // Send push notifications to room members (non-blocking, only for direct/group rooms) + if (activeRoom?.type === 'direct' || activeRoom?.type === 'group') { + void (async () => { + try { + const senderName = (msg as { senderName?: string }).senderName ?? 'Сотрудник' + const roomName = activeRoom?.name ?? (activeRoom?.type === 'group' ? 'Групповой чат' : 'Чат') + void sendPushToRoom(roomId, userId, { + title: roomName ?? senderName, + body: `${senderName.split(' ')[0]}: ${(msg as { text?: string }).text?.slice(0, 100) ?? ''}`, + url: `/${slug}`, + tag: roomId, + icon: '/icons/icon-192.png', + }) + } catch { /**/ } + })() + } + return reply.code(201).send(msg) }, ) diff --git a/backend/src/routes/push.ts b/backend/src/routes/push.ts new file mode 100644 index 0000000..cc4f8eb --- /dev/null +++ b/backend/src/routes/push.ts @@ -0,0 +1,39 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +const pushRoutes: FastifyPluginAsync = async (fastify) => { + // GET VAPID public key + fastify.get('/api/push/vapid-key', async () => { + return { publicKey: process.env.VAPID_PUBLIC_KEY ?? null } + }) + + // Subscribe + fastify.post<{ Body: { endpoint: string; p256dh: string; auth: string } }>( + '/api/push/subscribe', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { endpoint, p256dh, auth } = request.body + if (!endpoint || !p256dh || !auth) return reply.code(400).send({ error: 'Missing fields' }) + await db.query( + `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) + VALUES ($1, $2, $3, $4) + ON CONFLICT (endpoint) DO UPDATE SET user_id = $1, p256dh = $3, auth = $4`, + [request.user.sub, endpoint, p256dh, auth], + ) + return { ok: true } + }, + ) + + // Unsubscribe + fastify.delete<{ Body: { endpoint: string } }>( + '/api/push/unsubscribe', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { endpoint } = request.body + await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2', [endpoint, request.user.sub]) + return { ok: true } + }, + ) +} + +export default pushRoutes diff --git a/index.html b/index.html index 1074a2e..8555ea5 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,13 @@ HotelSync + + + + + + + diff --git a/package-lock.json b/package-lock.json index aca87a0..59ed990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "postcss": "^8.4.45", + "sharp": "^0.34.5", "tailwindcss": "^3.4.10", "typescript": "^5.5.3", "vite": "^5.4.1" @@ -324,6 +325,17 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -715,6 +727,496 @@ "node": ">=12" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1594,6 +2096,16 @@ } } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2505,6 +3017,64 @@ "semver": "bin/semver.js" } }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2702,6 +3272,14 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 67cce95..0591071 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "postcss": "^8.4.45", + "sharp": "^0.34.5", "tailwindcss": "^3.4.10", "typescript": "^5.5.3", "vite": "^5.4.1" diff --git a/public/icons/icon-128.png b/public/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..812ae5faa272c7964332f931f22bb2a6fff3d9ce GIT binary patch literal 2475 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 EA291AS^xk5 literal 0 HcmV?d00001 diff --git a/public/icons/icon-152.png b/public/icons/icon-152.png new file mode 100644 index 0000000000000000000000000000000000000000..984447a2f7a2fc691ebece714dd84a380131a201 GIT binary patch literal 2954 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 P*Gy){)<&-lk#YY4Rr_r! literal 0 HcmV?d00001 diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..5e9b25ca06aad01131625aa0fc02880e40ded94d GIT binary patch literal 3533 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 literal 0 HcmV?d00001 diff --git a/public/icons/icon-384.png b/public/icons/icon-384.png new file mode 100644 index 0000000000000000000000000000000000000000..2c21cf3f896d4c948e9325f10fde81eead9fc73f GIT binary patch literal 9495 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

Dvg~;$th@G-<{Q)Zy;_k#Qgc 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 literal 0 HcmV?d00001 diff --git a/public/icons/icon-72.png b/public/icons/icon-72.png new file mode 100644 index 0000000000000000000000000000000000000000..4b744d72b836ecaabd1f58cd8262cb9ccb341688 GIT binary patch literal 1321 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 literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..e010865 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "HotelSync PMS", + "short_name": "HotelSync", + "description": "Система управления отелем HotelSync", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#4F46E5", + "orientation": "portrait-primary", + "icons": [ + { "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" }, + { "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" }, + { "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" }, + { "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" }, + { "src": "/icons/icon-152.png", "sizes": "152x152", "type": "image/png" }, + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..1ee7c5a --- /dev/null +++ b/public/sw.js @@ -0,0 +1,35 @@ +const CACHE_NAME = 'hotelsync-v1' + +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())) + +self.addEventListener('push', event => { + if (!event.data) return + let data + try { data = event.data.json() } catch { data = { title: 'HotelSync', body: event.data.text() } } + + const title = data.title ?? 'HotelSync' + const options = { + body: data.body ?? '', + icon: data.icon ?? '/icons/icon-192.png', + badge: '/icons/icon-72.png', + tag: data.tag, + renotify: !!data.tag, + data: { url: data.url ?? '/' }, + vibrate: [200, 100, 200], + } + + event.waitUntil(self.registration.showNotification(title, options)) +}) + +self.addEventListener('notificationclick', event => { + event.notification.close() + const url = event.notification.data?.url ?? '/' + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => { + const existing = clients.find(c => c.url.includes(self.location.origin)) + if (existing) { existing.focus(); existing.navigate(url) } + else self.clients.openWindow(url) + }) + ) +}) diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..ecb3c90 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,22 @@ +import sharp from 'sharp' +import { mkdirSync } from 'fs' + +mkdirSync('./public/icons', { recursive: true }) + +const sizes = [72, 96, 128, 144, 152, 180, 192, 384, 512] + +const createSvg = (size) => { + const r = Math.round(size * 0.22) + const fontSize = Math.round(size * 0.36) + return ` + + HS +` +} + +for (const size of sizes) { + const svg = Buffer.from(createSvg(size)) + await sharp(svg).png().toFile(`./public/icons/icon-${size}.png`) + console.log(`✓ icon-${size}.png`) +} +console.log('Done!') diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index 80c68bd..52318fb 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -7,6 +7,7 @@ import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatR import type { User } from '../../types' import { useAuth } from '../../contexts/AuthContext' import { cn } from '../../lib/utils' +import { subscribeToPush, isPushSupported } from '../../lib/push' // ── Helpers ──────────────────────────────────────────────────────────────── @@ -788,6 +789,9 @@ export function ChatWidget() { label="Показывать текст в уведомлении" checked={settings.notifShowText !== false} onChange={v => updateSettings({ notifShowText: v })} /> + + +

Правый клик на сообщении — реакции, редактирование, удаление.
@@ -1358,6 +1362,59 @@ function ToggleRow({ icon, label, checked, onChange }: { ) } +// ── PushPermissionRow ───────────────────────────────────────────────────── + +function PushPermissionRow() { + const [permission, setPermission] = useState( + typeof Notification !== 'undefined' ? Notification.permission : 'default' + ) + const [loading, setLoading] = useState(false) + + if (!isPushSupported()) { + return ( +

+ Ваш браузер не поддерживает push-уведомления. + {typeof navigator !== 'undefined' && navigator.userAgent.includes('iPhone') ? ' Добавьте сайт на домашний экран (iOS 16.4+).' : ''} +

+ ) + } + + const enable = async () => { + setLoading(true) + try { + const result = await Notification.requestPermission() + setPermission(result) + if (result === 'granted') { + const { publicKey } = await api.push.getVapidKey() + if (!publicKey) return + const sub = await subscribeToPush(publicKey) + if (!sub) return + const json = sub.toJSON() + if (json.endpoint && json.keys?.p256dh && json.keys?.auth) { + await api.push.subscribe({ endpoint: json.endpoint, p256dh: json.keys.p256dh, auth: json.keys.auth }) + } + } + } catch { /**/ } + finally { setLoading(false) } + } + + return ( +
+ + + {permission === 'granted' ? 'Push включены' : permission === 'denied' ? 'Заблокированы в браузере' : 'Push-уведомления'} + + {permission === 'default' && ( + + )} + {permission === 'granted' && } +
+ ) +} + // ── GroupInfoView ────────────────────────────────────────────────────────── function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEditName, onAvatarClick, onSave }: { diff --git a/src/hooks/usePushSubscription.ts b/src/hooks/usePushSubscription.ts new file mode 100644 index 0000000..60c1921 --- /dev/null +++ b/src/hooks/usePushSubscription.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import { useAuth } from '../contexts/AuthContext' +import { registerSW, subscribeToPush, isPushSupported } from '../lib/push' +import { api } from '../lib/api' + +export function usePushSubscription() { + const { user } = useAuth() + + useEffect(() => { + if (!user || !isPushSupported()) return + if (Notification.permission === 'denied') return + + const setup = async () => { + const sw = await registerSW() + if (!sw) return + + const { publicKey } = await api.push.getVapidKey() + if (!publicKey) return + + if (Notification.permission === 'default') return // wait for explicit request + + const sub = await subscribeToPush(publicKey) + if (!sub) return + + const json = sub.toJSON() + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) return + + await api.push.subscribe({ + endpoint: json.endpoint, + p256dh: json.keys.p256dh, + auth: json.keys.auth, + }) + } + + setup().catch(() => { /**/ }) + }, [user]) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 333d153..a363b30 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -897,6 +897,15 @@ export const api = { req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/payment-gateways/${id}`), }, + // ── Push notifications ──────────────────────────────────────────────────── + push: { + getVapidKey: () => req<{ publicKey: string | null }>('GET', '/api/push/vapid-key'), + subscribe: (sub: { endpoint: string; p256dh: string; auth: string }) => + req<{ ok: boolean }>('POST', '/api/push/subscribe', sub), + unsubscribe: (endpoint: string) => + req<{ ok: boolean }>('DELETE', '/api/push/unsubscribe', { endpoint }), + }, + // Public widget API (no auth) widget: { getConfig: (slug: string) => diff --git a/src/lib/push.ts b/src/lib/push.ts new file mode 100644 index 0000000..ddb07fc --- /dev/null +++ b/src/lib/push.ts @@ -0,0 +1,42 @@ +function urlBase64ToUint8Array(base64String: string): ArrayBuffer { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') + const rawData = window.atob(base64) + const arr = new Uint8Array([...rawData].map(char => char.charCodeAt(0))) + return arr.buffer as ArrayBuffer +} + +export async function registerSW(): Promise { + if (!('serviceWorker' in navigator)) return null + try { + const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' }) + return reg + } catch { return null } +} + +export async function subscribeToPush(vapidPublicKey: string): Promise { + if (!('PushManager' in window)) return null + try { + const reg = await navigator.serviceWorker.ready + const existing = await reg.pushManager.getSubscription() + if (existing) return existing + return await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }) + } catch { return null } +} + +export async function unsubscribeFromPush(): Promise { + if (!('serviceWorker' in navigator)) return false + try { + const reg = await navigator.serviceWorker.ready + const sub = await reg.pushManager.getSubscription() + if (sub) return sub.unsubscribe() + return false + } catch { return false } +} + +export function isPushSupported(): boolean { + return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window +} diff --git a/src/main.tsx b/src/main.tsx index db032b7..121ccb6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App' +import { registerSW } from './lib/push' createRoot(document.getElementById('root')!).render( , ) + +// Register service worker for PWA and push notifications +registerSW().catch(() => { /**/ })