From 163ae46468bcdd0c8f0e2bef68678d733829186b Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Mon, 14 Sep 2026 16:26:18 +0200 Subject: [PATCH] added pwa infrastructure and push notifcation --- AGENTS.md | 9 + CLAUDE.md | 1 + next.config.ts | 3 +- package-lock.json | 875 ++++++++++++++++-- package.json | 7 +- .../migration.sql | 19 + prisma/schema.prisma | 19 + public/icons/apple-touch-icon.png | Bin 0 -> 3335 bytes public/icons/icon-192.png | Bin 0 -> 3592 bytes public/icons/icon-512.png | Bin 0 -> 16595 bytes public/icons/icon-maskable-192.png | Bin 0 -> 2957 bytes public/icons/icon-maskable-512.png | Bin 0 -> 12425 bytes public/manifest.json | 36 + src/app/layout.tsx | 46 +- src/app/serwist/[path]/route.ts | 13 + src/app/sw.ts | 72 ++ src/components/InstallPrompt.tsx | 109 +++ src/components/NotificationPrompt.tsx | 154 +++ src/lib/push/send-push.ts | 76 ++ src/lib/push/web-push.ts | 43 + src/lib/pwa.ts | 33 + .../actions/push-subscription-actions.ts | 76 ++ .../notifications/lib/create-notification.ts | 13 + .../ui/components/DiscountCodeList.tsx | 486 +++++++--- .../ui/views/settings-discounts-page-view.tsx | 31 + 25 files changed, 1916 insertions(+), 205 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 prisma/migrations/20260914140853_add_push_subscriptions/migration.sql create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon-maskable-192.png create mode 100644 public/icons/icon-maskable-512.png create mode 100644 public/manifest.json create mode 100644 src/app/serwist/[path]/route.ts create mode 100644 src/app/sw.ts create mode 100644 src/components/InstallPrompt.tsx create mode 100644 src/components/NotificationPrompt.tsx create mode 100644 src/lib/push/send-push.ts create mode 100644 src/lib/push/web-push.ts create mode 100644 src/lib/pwa.ts create mode 100644 src/modules/notifications/actions/push-subscription-actions.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/next.config.ts b/next.config.ts index df9017d..29681bc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; +import { withSerwist } from "@serwist/turbopack"; const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); @@ -28,7 +29,7 @@ const nextConfig: NextConfig = { }, }; -export default withSentryConfig(withNextIntl(nextConfig), { +export default withSentryConfig(withNextIntl(withSerwist(nextConfig)), { // For all available options, see: // https://www.npmjs.com/package/@sentry/webpack-plugin#options diff --git a/package-lock.json b/package-lock.json index 9dabaa7..0339679 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-pdf/renderer": "^4.5.1", "@sentry/nextjs": "^10.69.0", + "@serwist/turbopack": "^9.5.12", "@stripe/stripe-js": "^9.5.0", "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.116.0", @@ -52,9 +53,11 @@ "react-phone-number-input": "^3.4.14", "recharts": "^3.10.1", "resend": "^6.18.0", + "serwist": "^9.5.12", "sonner": "^2.0.7", "stripe": "^22.1.1", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "zod": "^4.1.12", "zustand": "^5.0.8" }, @@ -69,8 +72,10 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.1.0", "@vitest/coverage-v8": "^4.1.11", + "esbuild": "^0.28.2", "eslint": "^9.39.0", "eslint-config-next": "16.3.3", "husky": "^9.1.7", @@ -726,6 +731,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -5397,6 +5844,123 @@ "webpack": ">=5.0.0" } }, + "node_modules/@serwist/build": { + "version": "9.5.12", + "resolved": "https://registry.npmjs.org/@serwist/build/-/build-9.5.12.tgz", + "integrity": "sha512-U2UkA9BjdpniZkXDIG6NQRBEbXccRvqewzL3hfNEQERvM2bL6ezKvsVF9359akUWP8BxV950Kkq9LKGFmOR8Uw==", + "license": "MIT", + "dependencies": { + "@serwist/utils": "9.5.12", + "common-tags": "1.8.2", + "glob": "13.0.6", + "pretty-bytes": "6.1.1", + "source-map": "0.8.0", + "type-fest": "5.8.0", + "zod": "4.4.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@serwist/build/node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@serwist/turbopack": { + "version": "9.5.12", + "resolved": "https://registry.npmjs.org/@serwist/turbopack/-/turbopack-9.5.12.tgz", + "integrity": "sha512-mQuk5dxYxnf7c2ZYZpyOfFj3kaTn04z9rj/en6SXvTIMyOCONKi8ITEmaplNMJbno3Tw3cPkNIAyByeD+QnR+g==", + "license": "MIT", + "dependencies": { + "@serwist/build": "9.5.12", + "@serwist/utils": "9.5.12", + "@serwist/window": "9.5.12", + "@swc/core": "1.15.46", + "browserslist": "4.28.6", + "kolorist": "1.8.0", + "semver": "7.8.5", + "serwist": "9.5.12", + "zod": "4.4.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.25.0 <1.0.0", + "esbuild-wasm": ">=0.25.0 <1.0.0", + "next": ">=14.0.0", + "react": ">=18.0.0", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + }, + "esbuild-wasm": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@serwist/turbopack/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@serwist/utils": { + "version": "9.5.12", + "resolved": "https://registry.npmjs.org/@serwist/utils/-/utils-9.5.12.tgz", + "integrity": "sha512-BHDwiGL7H7JS7wFvVlAWTFHGt0ffHuPgKtdkaYP0lEWJFl6fKJRCnTDZhwhusFvBhicf37XP8Y6PouTZbcLmgg==", + "license": "MIT", + "peerDependencies": { + "browserslist": ">=4" + }, + "peerDependenciesMeta": { + "browserslist": { + "optional": true + } + } + }, + "node_modules/@serwist/window": { + "version": "9.5.12", + "resolved": "https://registry.npmjs.org/@serwist/window/-/window-9.5.12.tgz", + "integrity": "sha512-+fjApJme34qfwdGE2kLT8sFx+xWO+a15477MN/B/b1v4HOWBmARuA9C2xGFBCT77XzCrzQ/2DxcDePZsqEYOlw==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "2.0.7", + "serwist": "9.5.12" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -5529,9 +6093,9 @@ } }, "node_modules/@swc/core": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.43.tgz", - "integrity": "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.46.tgz", + "integrity": "sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -5546,18 +6110,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.43", - "@swc/core-darwin-x64": "1.15.43", - "@swc/core-linux-arm-gnueabihf": "1.15.43", - "@swc/core-linux-arm64-gnu": "1.15.43", - "@swc/core-linux-arm64-musl": "1.15.43", - "@swc/core-linux-ppc64-gnu": "1.15.43", - "@swc/core-linux-s390x-gnu": "1.15.43", - "@swc/core-linux-x64-gnu": "1.15.43", - "@swc/core-linux-x64-musl": "1.15.43", - "@swc/core-win32-arm64-msvc": "1.15.43", - "@swc/core-win32-ia32-msvc": "1.15.43", - "@swc/core-win32-x64-msvc": "1.15.43" + "@swc/core-darwin-arm64": "1.15.46", + "@swc/core-darwin-x64": "1.15.46", + "@swc/core-linux-arm-gnueabihf": "1.15.46", + "@swc/core-linux-arm64-gnu": "1.15.46", + "@swc/core-linux-arm64-musl": "1.15.46", + "@swc/core-linux-ppc64-gnu": "1.15.46", + "@swc/core-linux-s390x-gnu": "1.15.46", + "@swc/core-linux-x64-gnu": "1.15.46", + "@swc/core-linux-x64-musl": "1.15.46", + "@swc/core-win32-arm64-msvc": "1.15.46", + "@swc/core-win32-ia32-msvc": "1.15.46", + "@swc/core-win32-x64-msvc": "1.15.46" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -5569,9 +6133,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz", - "integrity": "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.46.tgz", + "integrity": "sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==", "cpu": [ "arm64" ], @@ -5585,9 +6149,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.43.tgz", - "integrity": "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.46.tgz", + "integrity": "sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==", "cpu": [ "x64" ], @@ -5601,9 +6165,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.43.tgz", - "integrity": "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.46.tgz", + "integrity": "sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==", "cpu": [ "arm" ], @@ -5617,9 +6181,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.43.tgz", - "integrity": "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.46.tgz", + "integrity": "sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==", "cpu": [ "arm64" ], @@ -5633,9 +6197,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.43.tgz", - "integrity": "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.46.tgz", + "integrity": "sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==", "cpu": [ "arm64" ], @@ -5649,9 +6213,9 @@ } }, "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.43.tgz", - "integrity": "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.46.tgz", + "integrity": "sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==", "cpu": [ "ppc64" ], @@ -5665,9 +6229,9 @@ } }, "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.43.tgz", - "integrity": "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.46.tgz", + "integrity": "sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==", "cpu": [ "s390x" ], @@ -5681,9 +6245,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.43.tgz", - "integrity": "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.46.tgz", + "integrity": "sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==", "cpu": [ "x64" ], @@ -5697,9 +6261,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.43.tgz", - "integrity": "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.46.tgz", + "integrity": "sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==", "cpu": [ "x64" ], @@ -5713,9 +6277,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.43.tgz", - "integrity": "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.46.tgz", + "integrity": "sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==", "cpu": [ "arm64" ], @@ -5729,9 +6293,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.43.tgz", - "integrity": "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.46.tgz", + "integrity": "sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==", "cpu": [ "ia32" ], @@ -5745,9 +6309,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.43", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.43.tgz", - "integrity": "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==", + "version": "1.15.46", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.46.tgz", + "integrity": "sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==", "cpu": [ "x64" ], @@ -6842,7 +7406,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/use-sync-external-store": { @@ -6851,6 +7414,16 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", @@ -7641,7 +8214,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -7915,6 +8487,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -8156,6 +8740,12 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -8265,6 +8855,12 @@ "node": ">=8.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/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -8623,6 +9219,15 @@ "node": ">=20" } }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -9198,6 +9803,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/effect": { "version": "3.21.4", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.4.tgz", @@ -9492,6 +10106,48 @@ "tests/browser-compat" ] }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -10679,6 +11335,15 @@ "dev": true, "license": "MIT" }, + "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-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -10697,7 +11362,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -10753,6 +11417,12 @@ "@formatjs/icu-messageformat-parser": "^3.4.0" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -11615,6 +12285,27 @@ "node": ">=4.0" } }, + "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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11625,6 +12316,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "license": "MIT" + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -12295,6 +12992,12 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -12314,7 +13017,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13270,6 +13972,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -14319,6 +15033,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -14353,6 +15073,24 @@ "semver": "bin/semver.js" } }, + "node_modules/serwist": { + "version": "9.5.12", + "resolved": "https://registry.npmjs.org/serwist/-/serwist-9.5.12.tgz", + "integrity": "sha512-PwREKJrSb3ja40XJyBntPOm7okcYZJCJPd++jUVz8tzxY1VYO9dyKSm0MimZ8LtUk0pIUTN0q2cfGe7R0wQsaQ==", + "license": "MIT", + "dependencies": { + "@serwist/utils": "9.5.12", + "idb": "8.0.3" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -15050,7 +15788,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -15303,10 +16040,9 @@ } }, "node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", - "dev": true, + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -15876,6 +16612,25 @@ "node": ">=18" } }, + "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/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index cda148e..20a1c7e 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-pdf/renderer": "^4.5.1", "@sentry/nextjs": "^10.69.0", + "@serwist/turbopack": "^9.5.12", "@stripe/stripe-js": "^9.5.0", "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.116.0", @@ -60,9 +61,11 @@ "react-phone-number-input": "^3.4.14", "recharts": "^3.10.1", "resend": "^6.18.0", + "serwist": "^9.5.12", "sonner": "^2.0.7", "stripe": "^22.1.1", "tailwind-merge": "^3.3.1", + "web-push": "^3.6.7", "zod": "^4.1.12", "zustand": "^5.0.8" }, @@ -77,8 +80,10 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.1.0", "@vitest/coverage-v8": "^4.1.11", + "esbuild": "^0.28.2", "eslint": "^9.39.0", "eslint-config-next": "16.3.3", "husky": "^9.1.7", @@ -112,7 +117,7 @@ "markdown-it": ">=14.1.1", "postcss": ">=8.5.23", "defu": ">=6.1.5", - "esbuild": ">=0.28.1", + "esbuild": "$esbuild", "vite": ">=7.3.2", "brace-expansion": ">=5.0.9", "ws": ">=8.21.0", diff --git a/prisma/migrations/20260914140853_add_push_subscriptions/migration.sql b/prisma/migrations/20260914140853_add_push_subscriptions/migration.sql new file mode 100644 index 0000000..9dd21c0 --- /dev/null +++ b/prisma/migrations/20260914140853_add_push_subscriptions/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "PushSubscription" ( + "id" TEXT NOT NULL, + "recipientId" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "p256dh" TEXT NOT NULL, + "auth" TEXT NOT NULL, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint"); + +-- CreateIndex +CREATE INDEX "PushSubscription_recipientId_idx" ON "PushSubscription"("recipientId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c34e0fa..8c91773 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -266,6 +266,25 @@ model Notification { @@index([recipientId, createdAt]) } +// Web-Push-Subscriptions (ein Datensatz pro Browser/Gerät). recipientId ist +// polymorph wie bei Notification (Manager-, Employee- oder User-ID) - Push ist +// ein zweiter Zustellkanal für createNotification(), kein eigenes System. +// endpoint ist pro Push-Dienst (FCM/Mozilla/...) global eindeutig und dient +// als natürlicher Unique-Key, um dasselbe Gerät bei erneutem Subscribe zu +// aktualisieren statt zu duplizieren. +model PushSubscription { + id String @id @default(uuid()) + recipientId String + endpoint String @unique + p256dh String // Public Key des Browsers für die Payload-Verschlüsselung + auth String // Auth-Secret des Browsers für die Payload-Verschlüsselung + userAgent String? // Nur zur Anzeige in einer künftigen "Geräte verwalten"-UI + createdAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert + + @@index([recipientId]) +} + // Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit // über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport — // hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig). diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5c8ffb907d26009c31fffcf1179ab0976d72e82e GIT binary patch literal 3335 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 Xy%M6Jxkxxi+5zXzm>Ikw+28&@xdufo literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..bcb3903e84eed72395a33972a042d5ded83f7332 GIT binary patch literal 16595 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 g|C8@`Sz-n6bh~TwJ7vKCEr8DI=$*_yZgcCu0C - - - -
{children}
- -
+ + + + +
{children}
+ + + +
+
); diff --git a/src/app/serwist/[path]/route.ts b/src/app/serwist/[path]/route.ts new file mode 100644 index 0000000..bd64a00 --- /dev/null +++ b/src/app/serwist/[path]/route.ts @@ -0,0 +1,13 @@ +import { spawnSync } from "node:child_process"; +import { createSerwistRoute } from "@serwist/turbopack"; + +const revision = + spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8" }).stdout?.trim() || + crypto.randomUUID(); + +export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute( + { + swSrc: "src/app/sw.ts", + additionalPrecacheEntries: [{ url: "/manifest.json", revision }], + } +); diff --git a/src/app/sw.ts b/src/app/sw.ts new file mode 100644 index 0000000..ff3e960 --- /dev/null +++ b/src/app/sw.ts @@ -0,0 +1,72 @@ +/// +/// +import { defaultCache } from "@serwist/turbopack/worker"; +import type { PrecacheEntry, SerwistGlobalConfig } from "serwist"; +import { Serwist } from "serwist"; + +declare global { + interface WorkerGlobalScope extends SerwistGlobalConfig { + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; + } +} + +declare const self: ServiceWorkerGlobalScope; + +const serwist = new Serwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: defaultCache, +}); + +serwist.addEventListeners(); + +interface PushPayload { + title: string; + body: string; + url?: string; +} + +self.addEventListener("push", (event: PushEvent) => { + if (!event.data) return; + + let payload: PushPayload; + try { + payload = event.data.json(); + } catch { + // Server sendet immer JSON (siehe send-push.ts) - ein Parse-Fehler + // deutet auf eine fremde/kaputte Payload hin, dann lieber gar nichts + // anzeigen als eine leere/verstümmelte Notification. + return; + } + + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: "/icons/icon-192.png", + badge: "/icons/icon-192.png", + data: { url: payload.url ?? "/" }, + }) + ); +}); + +self.addEventListener("notificationclick", (event: NotificationEvent) => { + event.notification.close(); + const url = (event.notification.data as { url?: string } | undefined)?.url ?? "/"; + + event.waitUntil( + (async () => { + const clientsList = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + const existing = clientsList.find((client) => new URL(client.url).pathname === url); + if (existing) { + await existing.focus(); + return; + } + await self.clients.openWindow(url); + })() + ); +}); diff --git a/src/components/InstallPrompt.tsx b/src/components/InstallPrompt.tsx new file mode 100644 index 0000000..aab8d9a --- /dev/null +++ b/src/components/InstallPrompt.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { X, Share, Plus, Download } from "lucide-react"; +import { Button } from "@/src/components/ui/button"; +import { + INSTALL_DISMISSED_KEY, + INSTALL_PROMPT_SETTLED_EVENT, + isInstallPromptPending, +} from "@/src/lib/pwa"; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +function isIos(): boolean { + return /iphone|ipad|ipod/i.test(window.navigator.userAgent); +} + +// Lazy-Initializer statt Effect: eligible/showIosHint hängen nur von +// Browser-APIs ab, nicht von React-Zustand, der synchronisiert werden müsste. +// Serverseitig (kein window) liefert das konsistent false, identisch zum +// allerersten Client-Render - kein Hydration-Mismatch. +function computeEligible(): boolean { + if (typeof window === "undefined") return false; + return isInstallPromptPending(); +} + +export function InstallPrompt() { + const [eligible, setEligible] = useState(computeEligible); + const [showIosHint] = useState(() => eligible && isIos()); + const [deferredPrompt, setDeferredPrompt] = useState(null); + + useEffect(() => { + if (!eligible || showIosHint) return; + + const handleBeforeInstallPrompt = (event: Event) => { + event.preventDefault(); + setDeferredPrompt(event as BeforeInstallPromptEvent); + }; + window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + return () => window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt); + }, [eligible, showIosHint]); + + const dismiss = () => { + localStorage.setItem(INSTALL_DISMISSED_KEY, "1"); + setEligible(false); + window.dispatchEvent(new Event(INSTALL_PROMPT_SETTLED_EVENT)); + }; + + const handleInstall = async () => { + if (!deferredPrompt) return; + await deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + setDeferredPrompt(null); + if (outcome === "accepted") dismiss(); + }; + + if (!eligible || (!deferredPrompt && !showIosHint)) return null; + + return ( +
+
+
+
+ +
+
+

Kumasy installieren

+ {showIosHint ? ( +

+ Tippe auf und dann auf{" "} + + Zum Home-Bildschirm + + . +

+ ) : ( +

+ Für schnelleren Zugriff und Benachrichtigungen zum Startbildschirm hinzufügen. +

+ )} +
+ +
+ + {!showIosHint && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/components/NotificationPrompt.tsx b/src/components/NotificationPrompt.tsx new file mode 100644 index 0000000..68255af --- /dev/null +++ b/src/components/NotificationPrompt.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { X, BellRing } from "lucide-react"; +import { Button } from "@/src/components/ui/button"; +import { INSTALL_PROMPT_SETTLED_EVENT, isInstallPromptPending } from "@/src/lib/pwa"; +import { + subscribeToPush, + unsubscribeFromPush, +} from "@/src/modules/notifications/actions/push-subscription-actions"; + +const DISMISSED_KEY = "kumasy-notification-prompt-dismissed"; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = window.atob(base64); + const output = new Uint8Array(new ArrayBuffer(rawData.length)); + for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i); + return output; +} + +function isPushSupported(): boolean { + return ( + typeof window !== "undefined" && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window + ); +} + +// Lazy-Initializer statt Effect (siehe InstallPrompt.tsx): Browser-API-Werte +// werden serverseitig identisch mit dem allerersten Client-Render (false) +// beantwortet, kein Hydration-Mismatch möglich. +function computeEligible(): boolean { + if (typeof window === "undefined") return false; + if (!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY) return false; + if (!isPushSupported()) return false; + if (Notification.permission !== "default") return false; // schon erlaubt oder blockiert + // Wartet, bis InstallPrompt nicht mehr sichtbar ist (installiert oder + // weggeklickt) - sonst überlappen sich beide "fixed bottom-4"-Banner. + if (isInstallPromptPending()) return false; + return localStorage.getItem(DISMISSED_KEY) !== "1"; +} + +export function NotificationPrompt() { + const [eligible, setEligible] = useState(computeEligible); + const [isSubscribing, setIsSubscribing] = useState(false); + + // Reagiert auf InstallPrompt, das erst nach unserem Mount weggeklickt wird + // (siehe computeEligible) - ansonsten bliebe eligible dauerhaft false. + useEffect(() => { + const handleSettled = () => setEligible(computeEligible()); + window.addEventListener(INSTALL_PROMPT_SETTLED_EVENT, handleSettled); + return () => window.removeEventListener(INSTALL_PROMPT_SETTLED_EVENT, handleSettled); + }, []); + + const dismiss = () => { + localStorage.setItem(DISMISSED_KEY, "1"); + setEligible(false); + }; + + const handleEnable = async () => { + setIsSubscribing(true); + try { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + dismiss(); + return; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!), + }); + + const json = subscription.toJSON(); + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { + throw new Error("Unvollständige Push-Subscription vom Browser erhalten."); + } + + const result = await subscribeToPush( + { endpoint: json.endpoint, keys: { p256dh: json.keys.p256dh, auth: json.keys.auth } }, + navigator.userAgent + ); + if (!result.success) { + // Serverseitig nicht gespeichert - Subscription auf dem Gerät nicht + // verwaist zurücklassen, sonst denkt der Browser dauerhaft, er sei + // angemeldet, ohne dass Push je zugestellt werden könnte. + await subscription.unsubscribe().catch(() => {}); + throw new Error(result.error); + } + + dismiss(); + } catch (error) { + console.error("Push-Aktivierung fehlgeschlagen:", error); + dismiss(); + } finally { + setIsSubscribing(false); + } + }; + + if (!eligible) return null; + + return ( +
+
+
+
+ +
+
+

Benachrichtigungen aktivieren

+

+ Verpasse keine Buchungsbestätigung oder freien Wartelisten-Plätze mehr. +

+
+ +
+ +
+ + +
+
+
+ ); +} + +// Wird von einer Einstellungen-UI importiert, um Push wieder zu deaktivieren +// (z.B. künftige "Geräte verwalten"-Seite) - hier co-located, weil sie +// denselben Browser-API-Zugriff (serviceWorker.ready) braucht wie handleEnable. +export async function disablePushOnThisDevice(): Promise { + if (!isPushSupported()) return; + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (!subscription) return; + + await unsubscribeFromPush(subscription.endpoint).catch(() => {}); + await subscription.unsubscribe().catch(() => {}); +} diff --git a/src/lib/push/send-push.ts b/src/lib/push/send-push.ts new file mode 100644 index 0000000..de258a1 --- /dev/null +++ b/src/lib/push/send-push.ts @@ -0,0 +1,76 @@ +/** + * Versand von Web-Push-Benachrichtigungen an einen Empfänger (alle seine + * Geräte). Analog zu sendMail: wirft NIE, ein Push-Fehler darf keinen + * aufrufenden Vorgang (Buchung, Storno, ...) kippen. + */ + +import { WebPushError } from "web-push"; +import { prisma } from "@/src/lib/prisma"; +import { getWebPush, isPushConfigured } from "./web-push"; + +export interface PushPayload { + title: string; + body: string; + /** Ziel-URL, die beim Klick auf die Benachrichtigung geöffnet wird. */ + url?: string; +} + +/** + * Sendet eine Push-Benachrichtigung an alle registrierten Geräte eines + * Empfängers (Manager-, Employee- oder User-ID). Tote Subscriptions (Browser + * hat sie serverseitig verworfen, HTTP 404/410) werden dabei aus der DB + * entfernt. + */ +export async function sendPushToRecipient( + recipientId: string, + payload: PushPayload +): Promise { + if (!isPushConfigured()) return; + + try { + const subscriptions = await prisma.pushSubscription.findMany({ + where: { recipientId }, + }); + if (subscriptions.length === 0) return; + + const webpush = getWebPush(); + const body = JSON.stringify(payload); + + const results = await Promise.allSettled( + subscriptions.map((sub) => + webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { p256dh: sub.p256dh, auth: sub.auth }, + }, + body + ) + ) + ); + + const deadEndpoints: string[] = []; + results.forEach((result, i) => { + if (result.status === "rejected") { + const error = result.reason; + // 404/410 = Subscription existiert clientseitig nicht mehr + // (Browser-Daten gelöscht, App deinstalliert, ...) - dauerhaft tot. + if ( + error instanceof WebPushError && + (error.statusCode === 404 || error.statusCode === 410) + ) { + deadEndpoints.push(subscriptions[i].endpoint); + } else { + console.error("sendPushToRecipient: Zustellung fehlgeschlagen:", error); + } + } + }); + + if (deadEndpoints.length > 0) { + await prisma.pushSubscription + .deleteMany({ where: { endpoint: { in: deadEndpoints } } }) + .catch((error) => console.error("sendPushToRecipient: Aufräumen fehlgeschlagen:", error)); + } + } catch (error) { + console.error("sendPushToRecipient fehlgeschlagen:", error); + } +} diff --git a/src/lib/push/web-push.ts b/src/lib/push/web-push.ts new file mode 100644 index 0000000..47ac75c --- /dev/null +++ b/src/lib/push/web-push.ts @@ -0,0 +1,43 @@ +/** + * Web-Push (VAPID) Konfiguration. + * + * Analog zu src/lib/mail/resend.ts: lazy initialisiert, damit ein fehlender + * Schlüssel den Build nicht zum Absturz bringt, nur den tatsächlichen Versand. + */ + +import webpush from "web-push"; + +let configured = false; + +function ensureConfigured() { + if (configured) return; + + const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; + const privateKey = process.env.VAPID_PRIVATE_KEY; + const subject = process.env.VAPID_SUBJECT; + + if (!publicKey || !privateKey || !subject) { + throw new Error( + "Web-Push ist nicht konfiguriert: NEXT_PUBLIC_VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY oder VAPID_SUBJECT fehlt." + ); + } + + webpush.setVapidDetails(subject, publicKey, privateKey); + configured = true; +} + +export function getWebPush(): typeof webpush { + ensureConfigured(); + return webpush; +} + +/** + * Prüft, ob die Web-Push-Konfiguration vollständig ist, ohne zu werfen. + */ +export function isPushConfigured(): boolean { + return Boolean( + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && + process.env.VAPID_PRIVATE_KEY && + process.env.VAPID_SUBJECT + ); +} diff --git a/src/lib/pwa.ts b/src/lib/pwa.ts new file mode 100644 index 0000000..a67be68 --- /dev/null +++ b/src/lib/pwa.ts @@ -0,0 +1,33 @@ +/** + * Geteilte Browser-Checks für InstallPrompt und NotificationPrompt. + */ + +export const INSTALL_DISMISSED_KEY = "kumasy-install-prompt-dismissed"; + +export function isStandalone(): boolean { + return ( + window.matchMedia("(display-mode: standalone)").matches || + // Safari-spezifisches Flag, matchMedia erkennt den PWA-Modus auf iOS nicht zuverlässig. + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +/** + * true, solange der Install-Prompt dem Nutzer noch angezeigt werden könnte + * (weder installiert noch weggeklickt). NotificationPrompt wartet darauf, + * dass das false wird, damit sich beide "fixed bottom-4"-Banner nicht + * überlappen und Push als bewusster zweiter Schritt nach der Installation + * wirkt statt beide gleichzeitig um Aufmerksamkeit konkurrieren zu lassen. + */ +export function isInstallPromptPending(): boolean { + return !isStandalone() && localStorage.getItem(INSTALL_DISMISSED_KEY) !== "1"; +} + +/** + * Event-Name, den InstallPrompt beim Wegklicken/Installieren feuert, damit + * NotificationPrompt (bereits gemountet, eligible ggf. noch false) neu prüfen + * kann, ob es jetzt an der Reihe ist - beide Komponenten sind unabhängig + * in layout.tsx gemountet, ein Custom Event entkoppelt sie ohne Prop-Drilling + * oder einen gemeinsamen State-Container nur für diesen einen Handoff. + */ +export const INSTALL_PROMPT_SETTLED_EVENT = "kumasy:install-prompt-settled"; diff --git a/src/modules/notifications/actions/push-subscription-actions.ts b/src/modules/notifications/actions/push-subscription-actions.ts new file mode 100644 index 0000000..f5a858e --- /dev/null +++ b/src/modules/notifications/actions/push-subscription-actions.ts @@ -0,0 +1,76 @@ +"use server"; + +import { prisma } from "@/src/lib/prisma"; +import { requireAuthWithData } from "@/src/lib/auth/getUser"; + +export interface PushSubscriptionInput { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +/** + * Speichert (oder aktualisiert) die Push-Subscription des eingeloggten + * Nutzers. Gilt für alle Rollen (User, Manager, Employee) - anders als das + * Notification-Center ist Push nicht auf die Studio-Seite beschränkt. + * + * Ein Browser kann seine Subscription verändern (z.B. nach Ablauf) und + * sendet dann denselben Endpoint erneut - upsert auf endpoint aktualisiert + * dabei sowohl die Keys als auch den Empfänger, falls sich zwischenzeitlich + * ein anderer Nutzer auf demselben Gerät angemeldet hat. + */ +export async function subscribeToPush( + input: PushSubscriptionInput, + userAgent?: string +): Promise<{ success: true } | { success: false; error: string }> { + let userData; + try { + userData = await requireAuthWithData(); + } catch { + return { success: false, error: "Nicht angemeldet." }; + } + + try { + await prisma.pushSubscription.upsert({ + where: { endpoint: input.endpoint }, + create: { + recipientId: userData.id, + endpoint: input.endpoint, + p256dh: input.keys.p256dh, + auth: input.keys.auth, + userAgent: userAgent ?? null, + }, + update: { + recipientId: userData.id, + p256dh: input.keys.p256dh, + auth: input.keys.auth, + userAgent: userAgent ?? null, + lastSeenAt: new Date(), + }, + }); + return { success: true }; + } catch (error) { + console.error("subscribeToPush fehlgeschlagen:", error); + return { success: false, error: "Push-Registrierung fehlgeschlagen." }; + } +} + +/** + * Entfernt eine Push-Subscription (z.B. wenn der Nutzer Benachrichtigungen + * im Browser deaktiviert). Kein Auth-Check auf recipientId nötig - der + * Endpoint ist ein unrateables, gerätespezifisches Secret, das der Client + * nur kennt, wenn er es selbst erzeugt hat. + */ +export async function unsubscribeFromPush( + endpoint: string +): Promise<{ success: true } | { success: false; error: string }> { + try { + await prisma.pushSubscription.deleteMany({ where: { endpoint } }); + return { success: true }; + } catch (error) { + console.error("unsubscribeFromPush fehlgeschlagen:", error); + return { success: false, error: "Abmelden fehlgeschlagen." }; + } +} diff --git a/src/modules/notifications/lib/create-notification.ts b/src/modules/notifications/lib/create-notification.ts index a162adb..c3b90d5 100644 --- a/src/modules/notifications/lib/create-notification.ts +++ b/src/modules/notifications/lib/create-notification.ts @@ -9,9 +9,13 @@ * wenn ein businessId übergeben wird. Ohne businessId (kein Business-Kontext) * wird immer zugestellt. * - Empfänger ist die Manager-ID des Business (kein Employee-Fanout im MVP). + * - Sendet zusätzlich Web-Push an alle Geräte des Empfängers (zweiter + * Zustellkanal für dieselbe Benachrichtigung, kein eigenes Opt-out - wer + * Push aktiviert hat, bekommt dieselben Typen wie in-app). */ import { prisma } from "@/src/lib/prisma"; +import { sendPushToRecipient } from "@/src/lib/push/send-push"; import { NOTIFICATION_TYPES, resolveNotificationSettings, @@ -64,6 +68,15 @@ export async function createNotification(input: CreateNotificationInput): Promis linkUrl: input.linkUrl ?? null, }, }); + + // Fire-and-forget: Push-Fehler dürfen das erfolgreiche In-App-Insert + // oben nicht rückgängig machen oder den Aufrufer blockieren. + void sendPushToRecipient(input.recipientId, { + title: input.title, + body: input.body, + url: input.linkUrl, + }); + return true; } catch (error) { console.error("createNotification fehlgeschlagen:", error); diff --git a/src/modules/settings/ui/components/DiscountCodeList.tsx b/src/modules/settings/ui/components/DiscountCodeList.tsx index ebfd4ca..573643d 100644 --- a/src/modules/settings/ui/components/DiscountCodeList.tsx +++ b/src/modules/settings/ui/components/DiscountCodeList.tsx @@ -1,7 +1,19 @@ "use client"; import { useMemo, useState } from "react"; -import { Plus, Trash2, Tag, Search, Calendar, Percent, Coins, Layers } from "lucide-react"; +import { + Plus, + Trash2, + Pencil, + Tag, + Search, + Calendar, + Percent, + Coins, + Layers, + X, + SlidersHorizontal, +} from "lucide-react"; import { Button } from "@/src/components/ui/button"; import { Input } from "@/src/components/ui/input"; import { @@ -28,6 +40,15 @@ interface DiscountCodeListProps { expiresAt: Date | null; isActive: boolean; }) => Promise; + onUpdate: ( + discountCodeId: string, + input: { + maxRedemptions: number | null; + startsAt: Date | null; + expiresAt: Date | null; + isActive: boolean; + } + ) => Promise; onDeactivate: (discountCodeId: string) => Promise; isSaving: boolean; } @@ -43,6 +64,24 @@ const emptyForm = { expiresAt: "" as string, }; +function toDateInputValue(date: Date | null): string { + if (!date) return ""; + const d = new Date(date); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function toEditForm(code: DiscountCodeDTO) { + return { + maxRedemptions: code.maxRedemptions !== null ? String(code.maxRedemptions) : "", + startsAt: toDateInputValue(code.startsAt), + expiresAt: toDateInputValue(code.expiresAt), + isActive: code.isActive, + }; +} + type DisplayStatus = "active" | "inactive" | "expired" | "scheduled" | "exhausted"; type StatusFilter = "all" | DisplayStatus; @@ -94,6 +133,7 @@ const DiscountCodeList = ({ codes, courses, onCreate, + onUpdate, onDeactivate, isSaving, }: DiscountCodeListProps) => { @@ -101,6 +141,8 @@ const DiscountCodeList = ({ const [isCreating, setIsCreating] = useState(false); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState | null>(null); const courseOptions = courses.map((c) => ({ value: c.id, label: c.name })); const courseNameById = useMemo(() => new Map(courses.map((c) => [c.id, c.name])), [courses]); @@ -140,6 +182,37 @@ const DiscountCodeList = ({ } }; + const startEditing = (code: DiscountCodeDTO) => { + setEditingId(code.id); + setEditForm(toEditForm(code)); + }; + + const cancelEditing = () => { + setEditingId(null); + setEditForm(null); + }; + + const handleUpdate = async (discountCodeId: string) => { + if (!editForm) return; + // onUpdate kann rejecten (Netzwerk-/Serverfehler) - der Aufrufer meldet das + // bereits per Toast, hier nur verhindern, dass das Formular bei einem + // Fehler trotzdem als "fertig" geschlossen wird. + let success = false; + try { + success = await onUpdate(discountCodeId, { + maxRedemptions: editForm.maxRedemptions.trim() ? Number(editForm.maxRedemptions) : null, + startsAt: editForm.startsAt ? parseLocalStartOfDay(editForm.startsAt) : null, + expiresAt: editForm.expiresAt ? parseLocalEndOfDay(editForm.expiresAt) : null, + isActive: editForm.isActive, + }); + } catch { + return; + } + if (success) { + cancelEditing(); + } + }; + return (
@@ -181,132 +254,172 @@ const DiscountCodeList = ({
{isCreating && ( -
-
- - setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))} - placeholder="z.B. SOMMER25" - /> -
- -
-
- - +
+
+
+
+ +
+

Neuer Rabattcode

-
- - { - const raw = Number(e.target.value); - const cents = form.type === "percent" ? raw : Math.round(raw * 100); - setForm((f) => ({ ...f, value: Number.isFinite(cents) ? cents : 0 })); - }} - /> -
-
- -
- -
- - {form.scope === "specific_courses" && ( - setForm((f) => ({ ...f, courseIds: values }))} - options={courseOptions} - /> - )} - -
- - setForm((f) => ({ ...f, maxRedemptions: e.target.value }))} - placeholder="z.B. 50" - /> -
- -
-
- - setForm((f) => ({ ...f, startsAt: e.target.value }))} - /> -
-
- - setForm((f) => ({ ...f, expiresAt: e.target.value }))} - /> -
-
- -
- -
+ +
+
+ + setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))} + placeholder="z.B. SOMMER25" + /> +
+ +
+
+ + +
+
+ + { + const raw = Number(e.target.value); + const cents = form.type === "percent" ? raw : Math.round(raw * 100); + setForm((f) => ({ ...f, value: Number.isFinite(cents) ? cents : 0 })); + }} + /> +
+
+ +
+ +
+ + +
+
+ + {form.scope === "specific_courses" && ( + setForm((f) => ({ ...f, courseIds: values }))} + options={courseOptions} + /> + )} + +
+
+ + Limits & Laufzeit (optional) +
+ +
+ + setForm((f) => ({ ...f, maxRedemptions: e.target.value }))} + placeholder="z.B. 50" + /> +
+ +
+
+ + setForm((f) => ({ ...f, startsAt: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, expiresAt: e.target.value }))} + /> +
+
+
+ +
+ + +
+
)} @@ -351,25 +464,140 @@ const DiscountCodeList = ({
- {c.isActive && ( +
- )} + {c.isActive && ( + + )} +
+ {editingId === c.id && editForm && ( +
+
+
+ + Limits & Laufzeit bearbeiten +
+ +
+ +
+
+ + + setEditForm((f) => (f ? { ...f, maxRedemptions: e.target.value } : f)) + } + placeholder="z.B. 50" + /> +
+ +
+
+ + + setEditForm((f) => (f ? { ...f, startsAt: e.target.value } : f)) + } + /> +
+
+ + + setEditForm((f) => (f ? { ...f, expiresAt: e.target.value } : f)) + } + /> +
+
+ +
+ + +
+ +
+ + +
+
+
+ )} +
{c.type === "percent" ? ( diff --git a/src/modules/settings/ui/views/settings-discounts-page-view.tsx b/src/modules/settings/ui/views/settings-discounts-page-view.tsx index 3c2dca7..9cdd174 100644 --- a/src/modules/settings/ui/views/settings-discounts-page-view.tsx +++ b/src/modules/settings/ui/views/settings-discounts-page-view.tsx @@ -14,6 +14,7 @@ import DiscountCodeList from "../components/DiscountCodeList"; import { getDiscountCodes, createDiscountCode, + updateDiscountCodeLimits, deactivateDiscountCode, getBusinessCoursesForDiscountPicker, type DiscountCodeDTO, @@ -111,6 +112,35 @@ const SettingsDiscountsPageView = ({ }); }; + const handleUpdate = ( + discountCodeId: string, + input: { + maxRedemptions: number | null; + startsAt: Date | null; + expiresAt: Date | null; + isActive: boolean; + } + ): Promise => { + return new Promise((resolve, reject) => { + startSaving(async () => { + try { + const result = await updateDiscountCodeLimits(selectedBusinessId, discountCodeId, input); + if (result.success) { + toast.success("Rabattcode aktualisiert."); + setCodes((prev) => prev.map((c) => (c.id === discountCodeId ? { ...c, ...input } : c))); + resolve(true); + } else { + toast.error(result.error); + resolve(false); + } + } catch (error) { + toast.error("Rabattcode konnte nicht aktualisiert werden."); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + }); + }; + const handleDeactivate = (discountCodeId: string): Promise => { return new Promise((resolve, reject) => { startSaving(async () => { @@ -188,6 +218,7 @@ const SettingsDiscountsPageView = ({ codes={codes} courses={courses} onCreate={handleCreate} + onUpdate={handleUpdate} onDeactivate={handleDeactivate} isSaving={isSaving} />