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 0000000..5c8ffb9 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..db88204 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..bcb3903 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-maskable-192.png b/public/icons/icon-maskable-192.png new file mode 100644 index 0000000..acc565c Binary files /dev/null and b/public/icons/icon-maskable-192.png differ diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..5f748b4 Binary files /dev/null and b/public/icons/icon-maskable-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..393bcf6 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,36 @@ +{ + "name": "Kumasy", + "short_name": "Kumasy", + "description": "Kumasy – Kurse buchen und verwalten", + "start_url": "/", + "display": "standalone", + "background_color": "#1E293B", + "theme_color": "#F4C00C", + "orientation": "portrait", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d49de18..142a545 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,10 +2,13 @@ import type { Metadata, Viewport } from "next"; import localFont from "next/font/local"; import { NextIntlClientProvider } from "next-intl"; import { getLocale } from "next-intl/server"; +import { SerwistProvider } from "@serwist/turbopack/react"; import "./globals.css"; import { Toaster } from "../components/ui/sonner"; import { AuthInitializer } from "../components/AuthInitializer"; import NavigationProgress from "../components/layout/NavigationProgress"; +import { InstallPrompt } from "../components/InstallPrompt"; +import { NotificationPrompt } from "../components/NotificationPrompt"; const salmondFont = localFont({ src: "../public/font/salmond.woff", @@ -13,12 +16,23 @@ const salmondFont = localFont({ export const metadata: Metadata = { title: "Kumasy", - description: "Generated by create next app", + description: "Kumasy – Kurse buchen und verwalten", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Kumasy", + }, + icons: { + icon: "/icons/icon-192.png", + apple: "/icons/apple-touch-icon.png", + }, }; export const viewport: Viewport = { width: "device-width", initialScale: 1, + themeColor: "#F4C00C", }; export default async function RootLayout({ @@ -31,19 +45,23 @@ export default async function RootLayout({ return ( - - - -
{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} />