diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } + } +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } + } +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/node/src/components/ThemeRegistry/theme.ts b/node/src/components/ThemeRegistry/theme.ts index 445f8f9..566a51e 100755 --- a/node/src/components/ThemeRegistry/theme.ts +++ b/node/src/components/ThemeRegistry/theme.ts @@ -15,6 +15,13 @@ fontFamily: roboto.style.fontFamily, }, components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { root: ({ ownerState }) => ({ diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/node/src/components/ThemeRegistry/theme.ts b/node/src/components/ThemeRegistry/theme.ts index 445f8f9..566a51e 100755 --- a/node/src/components/ThemeRegistry/theme.ts +++ b/node/src/components/ThemeRegistry/theme.ts @@ -15,6 +15,13 @@ fontFamily: roboto.style.fontFamily, }, components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { root: ({ ownerState }) => ({ diff --git a/node/src/hooks/index.ts b/node/src/hooks/index.ts index 3fd2095..281dae5 100755 --- a/node/src/hooks/index.ts +++ b/node/src/hooks/index.ts @@ -1 +1,2 @@ -export { useIndexedDB } from './useIndexedDB' \ No newline at end of file +export { useIndexedDB } from './useIndexedDB' +export { useWebRTC } from './useWebRTC' \ No newline at end of file diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/node/src/components/ThemeRegistry/theme.ts b/node/src/components/ThemeRegistry/theme.ts index 445f8f9..566a51e 100755 --- a/node/src/components/ThemeRegistry/theme.ts +++ b/node/src/components/ThemeRegistry/theme.ts @@ -15,6 +15,13 @@ fontFamily: roboto.style.fontFamily, }, components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { root: ({ ownerState }) => ({ diff --git a/node/src/hooks/index.ts b/node/src/hooks/index.ts index 3fd2095..281dae5 100755 --- a/node/src/hooks/index.ts +++ b/node/src/hooks/index.ts @@ -1 +1,2 @@ -export { useIndexedDB } from './useIndexedDB' \ No newline at end of file +export { useIndexedDB } from './useIndexedDB' +export { useWebRTC } from './useWebRTC' \ No newline at end of file diff --git a/node/src/hooks/useWebRTC.tsx b/node/src/hooks/useWebRTC.tsx new file mode 100755 index 0000000..ba8a142 --- /dev/null +++ b/node/src/hooks/useWebRTC.tsx @@ -0,0 +1,105 @@ +// useWebRTC.ts +import { useState, useEffect, useRef, useCallback } from 'react' + +interface useWebRTCReturn { + pc: RTCPeerConnection | null + localStream: MediaStream | null + ws: WebSocket | null + setURL: (wsUrl: string) => () => void +} + +export function useWebRTC(): useWebRTCReturn { + const [pc, setPc] = useState(null) + const [localStream, setLocalStream] = useState(null) + const [ws, setWs] = useState(null) + const iceServers: RTCIceServer[] = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:19302" }, + { urls: "stun:stun3.l.google.com:19302" }, + { urls: "stun:stun4.l.google.com:19302" } + ] + // ICE候補を格納する配列(シグナリング送信用) + const iceCandidatesRef = useRef([]) + + // WebSocket初期化 + const setURL = useCallback((wsUrl: string) => { + const socket = new WebSocket(wsUrl) + socket.onopen = () => { + console.log("WebSocket connected") + // WebSocket接続後にWebRTCの初期化を開始 + initWebRTC(socket) + } + socket.onmessage = (event: MessageEvent) => { + console.log("WebSocket message received:", event.data) + // ここでリモートからのシグナリング(アンサーやICE候補)を処理する + } + socket.onerror = (err) => { + console.error("WebSocket error:", err) + } + socket.onclose = () => { + console.log("WebSocket closed") + } + setWs(socket) + // クリーンアップ + return () => { + socket.close() + pc?.close() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // WebRTC初期化関数 + const initWebRTC = useCallback((socket: WebSocket) => { + // ユーザーに映像・音声の許可をリクエスト + navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + .then(stream => { + setLocalStream(stream) + const connection = new RTCPeerConnection({ iceServers }) + // ローカルストリームの各トラックを追加 + stream.getTracks().forEach(track => connection.addTrack(track, stream)) + // ダミーデータチャネルを作成してICE候補収集を促進 + connection.createDataChannel("dummy") + // ICE候補イベントのハンドラ + connection.onicecandidate = (event) => { + console.log("ICE candidate event:", event) + if (event.candidate) { + iceCandidatesRef.current.push(event.candidate.toJSON()) + // 候補が見つかるたびに送信する(例:ここで個別送信) + socket.send(JSON.stringify({ + type: "ice", + candidate: event.candidate.toJSON() + })) + } else { + // ICE候補の収集が完了(candidateがnull) + const signalingMessage = { + type: "offer", + sdp: connection.localDescription ? connection.localDescription.sdp : null, + iceCandidates: iceCandidatesRef.current + } + console.log("Signaling JSON message:", JSON.stringify(signalingMessage, null, 2)) + socket.send(JSON.stringify(signalingMessage)) + } + } + connection.onicegatheringstatechange = () => { + console.log("ICE gathering state:", connection.iceGatheringState) + } + // SDPオファー生成とローカル記述の設定 + connection.createOffer() + .then(offer => { + console.log("Created SDP offer:", offer) + return connection.setLocalDescription(offer) + }) + .then(() => { + // setLocalDescription()実行後、ICE候補収集が開始される + setPc(connection) + }) + .catch(err => console.error("Error during SDP offer creation:", err)) + }) + .catch(err => { + console.error("Error getting user media:", err) + }) + }, [iceServers]) + + return { pc, localStream, ws, setURL } +} diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/node/src/components/ThemeRegistry/theme.ts b/node/src/components/ThemeRegistry/theme.ts index 445f8f9..566a51e 100755 --- a/node/src/components/ThemeRegistry/theme.ts +++ b/node/src/components/ThemeRegistry/theme.ts @@ -15,6 +15,13 @@ fontFamily: roboto.style.fontFamily, }, components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { root: ({ ownerState }) => ({ diff --git a/node/src/hooks/index.ts b/node/src/hooks/index.ts index 3fd2095..281dae5 100755 --- a/node/src/hooks/index.ts +++ b/node/src/hooks/index.ts @@ -1 +1,2 @@ -export { useIndexedDB } from './useIndexedDB' \ No newline at end of file +export { useIndexedDB } from './useIndexedDB' +export { useWebRTC } from './useWebRTC' \ No newline at end of file diff --git a/node/src/hooks/useWebRTC.tsx b/node/src/hooks/useWebRTC.tsx new file mode 100755 index 0000000..ba8a142 --- /dev/null +++ b/node/src/hooks/useWebRTC.tsx @@ -0,0 +1,105 @@ +// useWebRTC.ts +import { useState, useEffect, useRef, useCallback } from 'react' + +interface useWebRTCReturn { + pc: RTCPeerConnection | null + localStream: MediaStream | null + ws: WebSocket | null + setURL: (wsUrl: string) => () => void +} + +export function useWebRTC(): useWebRTCReturn { + const [pc, setPc] = useState(null) + const [localStream, setLocalStream] = useState(null) + const [ws, setWs] = useState(null) + const iceServers: RTCIceServer[] = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:19302" }, + { urls: "stun:stun3.l.google.com:19302" }, + { urls: "stun:stun4.l.google.com:19302" } + ] + // ICE候補を格納する配列(シグナリング送信用) + const iceCandidatesRef = useRef([]) + + // WebSocket初期化 + const setURL = useCallback((wsUrl: string) => { + const socket = new WebSocket(wsUrl) + socket.onopen = () => { + console.log("WebSocket connected") + // WebSocket接続後にWebRTCの初期化を開始 + initWebRTC(socket) + } + socket.onmessage = (event: MessageEvent) => { + console.log("WebSocket message received:", event.data) + // ここでリモートからのシグナリング(アンサーやICE候補)を処理する + } + socket.onerror = (err) => { + console.error("WebSocket error:", err) + } + socket.onclose = () => { + console.log("WebSocket closed") + } + setWs(socket) + // クリーンアップ + return () => { + socket.close() + pc?.close() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // WebRTC初期化関数 + const initWebRTC = useCallback((socket: WebSocket) => { + // ユーザーに映像・音声の許可をリクエスト + navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + .then(stream => { + setLocalStream(stream) + const connection = new RTCPeerConnection({ iceServers }) + // ローカルストリームの各トラックを追加 + stream.getTracks().forEach(track => connection.addTrack(track, stream)) + // ダミーデータチャネルを作成してICE候補収集を促進 + connection.createDataChannel("dummy") + // ICE候補イベントのハンドラ + connection.onicecandidate = (event) => { + console.log("ICE candidate event:", event) + if (event.candidate) { + iceCandidatesRef.current.push(event.candidate.toJSON()) + // 候補が見つかるたびに送信する(例:ここで個別送信) + socket.send(JSON.stringify({ + type: "ice", + candidate: event.candidate.toJSON() + })) + } else { + // ICE候補の収集が完了(candidateがnull) + const signalingMessage = { + type: "offer", + sdp: connection.localDescription ? connection.localDescription.sdp : null, + iceCandidates: iceCandidatesRef.current + } + console.log("Signaling JSON message:", JSON.stringify(signalingMessage, null, 2)) + socket.send(JSON.stringify(signalingMessage)) + } + } + connection.onicegatheringstatechange = () => { + console.log("ICE gathering state:", connection.iceGatheringState) + } + // SDPオファー生成とローカル記述の設定 + connection.createOffer() + .then(offer => { + console.log("Created SDP offer:", offer) + return connection.setLocalDescription(offer) + }) + .then(() => { + // setLocalDescription()実行後、ICE候補収集が開始される + setPc(connection) + }) + .catch(err => console.error("Error during SDP offer creation:", err)) + }) + .catch(err => { + console.error("Error getting user media:", err) + }) + }, [iceServers]) + + return { pc, localStream, ws, setURL } +} diff --git a/node/src/types/globals.d.ts b/node/src/types/globals.d.ts new file mode 100755 index 0000000..ca54560 --- /dev/null +++ b/node/src/types/globals.d.ts @@ -0,0 +1,34 @@ +export {} + +declare global { + interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> + } + + interface RelatedApplication { + platform: string + url?: string + id?: string + } + + interface Navigator { + getInstalledRelatedApps?(): Promise + } + + interface Window { + webkit?: { + messageHandlers?: Record< + string, + { postMessage: (payload: any) => void } + > + } + __nativeCallback?: (res: NotificationResult) => void + } + + interface NotificationResult { + status: 'authorized' | 'notDetermined' | 'denied' | 'requested' | 'unknown' + token: string | null + id: string | null + } +} diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/node/src/components/ThemeRegistry/theme.ts b/node/src/components/ThemeRegistry/theme.ts index 445f8f9..566a51e 100755 --- a/node/src/components/ThemeRegistry/theme.ts +++ b/node/src/components/ThemeRegistry/theme.ts @@ -15,6 +15,13 @@ fontFamily: roboto.style.fontFamily, }, components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { root: ({ ownerState }) => ({ diff --git a/node/src/hooks/index.ts b/node/src/hooks/index.ts index 3fd2095..281dae5 100755 --- a/node/src/hooks/index.ts +++ b/node/src/hooks/index.ts @@ -1 +1,2 @@ -export { useIndexedDB } from './useIndexedDB' \ No newline at end of file +export { useIndexedDB } from './useIndexedDB' +export { useWebRTC } from './useWebRTC' \ No newline at end of file diff --git a/node/src/hooks/useWebRTC.tsx b/node/src/hooks/useWebRTC.tsx new file mode 100755 index 0000000..ba8a142 --- /dev/null +++ b/node/src/hooks/useWebRTC.tsx @@ -0,0 +1,105 @@ +// useWebRTC.ts +import { useState, useEffect, useRef, useCallback } from 'react' + +interface useWebRTCReturn { + pc: RTCPeerConnection | null + localStream: MediaStream | null + ws: WebSocket | null + setURL: (wsUrl: string) => () => void +} + +export function useWebRTC(): useWebRTCReturn { + const [pc, setPc] = useState(null) + const [localStream, setLocalStream] = useState(null) + const [ws, setWs] = useState(null) + const iceServers: RTCIceServer[] = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:19302" }, + { urls: "stun:stun3.l.google.com:19302" }, + { urls: "stun:stun4.l.google.com:19302" } + ] + // ICE候補を格納する配列(シグナリング送信用) + const iceCandidatesRef = useRef([]) + + // WebSocket初期化 + const setURL = useCallback((wsUrl: string) => { + const socket = new WebSocket(wsUrl) + socket.onopen = () => { + console.log("WebSocket connected") + // WebSocket接続後にWebRTCの初期化を開始 + initWebRTC(socket) + } + socket.onmessage = (event: MessageEvent) => { + console.log("WebSocket message received:", event.data) + // ここでリモートからのシグナリング(アンサーやICE候補)を処理する + } + socket.onerror = (err) => { + console.error("WebSocket error:", err) + } + socket.onclose = () => { + console.log("WebSocket closed") + } + setWs(socket) + // クリーンアップ + return () => { + socket.close() + pc?.close() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // WebRTC初期化関数 + const initWebRTC = useCallback((socket: WebSocket) => { + // ユーザーに映像・音声の許可をリクエスト + navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + .then(stream => { + setLocalStream(stream) + const connection = new RTCPeerConnection({ iceServers }) + // ローカルストリームの各トラックを追加 + stream.getTracks().forEach(track => connection.addTrack(track, stream)) + // ダミーデータチャネルを作成してICE候補収集を促進 + connection.createDataChannel("dummy") + // ICE候補イベントのハンドラ + connection.onicecandidate = (event) => { + console.log("ICE candidate event:", event) + if (event.candidate) { + iceCandidatesRef.current.push(event.candidate.toJSON()) + // 候補が見つかるたびに送信する(例:ここで個別送信) + socket.send(JSON.stringify({ + type: "ice", + candidate: event.candidate.toJSON() + })) + } else { + // ICE候補の収集が完了(candidateがnull) + const signalingMessage = { + type: "offer", + sdp: connection.localDescription ? connection.localDescription.sdp : null, + iceCandidates: iceCandidatesRef.current + } + console.log("Signaling JSON message:", JSON.stringify(signalingMessage, null, 2)) + socket.send(JSON.stringify(signalingMessage)) + } + } + connection.onicegatheringstatechange = () => { + console.log("ICE gathering state:", connection.iceGatheringState) + } + // SDPオファー生成とローカル記述の設定 + connection.createOffer() + .then(offer => { + console.log("Created SDP offer:", offer) + return connection.setLocalDescription(offer) + }) + .then(() => { + // setLocalDescription()実行後、ICE候補収集が開始される + setPc(connection) + }) + .catch(err => console.error("Error during SDP offer creation:", err)) + }) + .catch(err => { + console.error("Error getting user media:", err) + }) + }, [iceServers]) + + return { pc, localStream, ws, setURL } +} diff --git a/node/src/types/globals.d.ts b/node/src/types/globals.d.ts new file mode 100755 index 0000000..ca54560 --- /dev/null +++ b/node/src/types/globals.d.ts @@ -0,0 +1,34 @@ +export {} + +declare global { + interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> + } + + interface RelatedApplication { + platform: string + url?: string + id?: string + } + + interface Navigator { + getInstalledRelatedApps?(): Promise + } + + interface Window { + webkit?: { + messageHandlers?: Record< + string, + { postMessage: (payload: any) => void } + > + } + __nativeCallback?: (res: NotificationResult) => void + } + + interface NotificationResult { + status: 'authorized' | 'notDetermined' | 'denied' | 'requested' | 'unknown' + token: string | null + id: string | null + } +} diff --git a/node/src/utils/index.ts b/node/src/utils/index.ts index 54794c3..cf37f87 100644 --- a/node/src/utils/index.ts +++ b/node/src/utils/index.ts @@ -12,21 +12,39 @@ return "Other" } -export const getBrowser = () => { - if (typeof window === "undefined") return "Other" +export const getBrowser = (): string => { + if (typeof window === 'undefined') return 'Other' + + // 1) iOS WebView判定: window.webkit.messageHandlers.getDevicePushToken があれば true + // 2) Android WebView判定: window.AndroidNative.getDevicePushToken があれば true + if ( + ( + (window as any).webkit + && (window as any).webkit.messageHandlers + && (window as any).webkit.messageHandlers.getDevicePushToken + ) + || ( + (window as any).AndroidNative + && (window as any).AndroidNative.getDevicePushToken + ) + ) { + return 'WebView' + } + const agent = window.navigator.userAgent.toLowerCase() - if (agent.indexOf("msie") != -1 || agent.indexOf("trident") != -1) { + if (agent.indexOf('msie') !== -1 || agent.indexOf('trident') !== -1) { return 'Internet Explorer' - } else if (agent.indexOf("edg") != -1 || agent.indexOf("edge") != -1) { + } else if (agent.indexOf('edg') !== -1 || agent.indexOf('edge') !== -1) { return 'Edge' - } else if (agent.indexOf("opr") != -1 || agent.indexOf("opera") != -1) { + } else if (agent.indexOf('opr') !== -1 || agent.indexOf('opera') !== -1) { return 'Opera' - } else if (agent.indexOf("chrome") != -1 || agent.indexOf("crios") != -1) { // iOSのChromeは 'crios' + } else if (agent.indexOf('chrome') !== -1 || agent.indexOf('crios') !== -1) { + // iOS版Chromeは 'crios' を含む return 'Chrome' - } else if (agent.indexOf("safari") != -1) { + } else if (agent.indexOf('safari') !== -1) { return 'Safari' - } else if (agent.indexOf("firefox") != -1) { + } else if (agent.indexOf('firefox') !== -1) { return 'FireFox' } else { return 'Other' diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/node/src/components/ThemeRegistry/theme.ts b/node/src/components/ThemeRegistry/theme.ts index 445f8f9..566a51e 100755 --- a/node/src/components/ThemeRegistry/theme.ts +++ b/node/src/components/ThemeRegistry/theme.ts @@ -15,6 +15,13 @@ fontFamily: roboto.style.fontFamily, }, components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { root: ({ ownerState }) => ({ diff --git a/node/src/hooks/index.ts b/node/src/hooks/index.ts index 3fd2095..281dae5 100755 --- a/node/src/hooks/index.ts +++ b/node/src/hooks/index.ts @@ -1 +1,2 @@ -export { useIndexedDB } from './useIndexedDB' \ No newline at end of file +export { useIndexedDB } from './useIndexedDB' +export { useWebRTC } from './useWebRTC' \ No newline at end of file diff --git a/node/src/hooks/useWebRTC.tsx b/node/src/hooks/useWebRTC.tsx new file mode 100755 index 0000000..ba8a142 --- /dev/null +++ b/node/src/hooks/useWebRTC.tsx @@ -0,0 +1,105 @@ +// useWebRTC.ts +import { useState, useEffect, useRef, useCallback } from 'react' + +interface useWebRTCReturn { + pc: RTCPeerConnection | null + localStream: MediaStream | null + ws: WebSocket | null + setURL: (wsUrl: string) => () => void +} + +export function useWebRTC(): useWebRTCReturn { + const [pc, setPc] = useState(null) + const [localStream, setLocalStream] = useState(null) + const [ws, setWs] = useState(null) + const iceServers: RTCIceServer[] = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:19302" }, + { urls: "stun:stun3.l.google.com:19302" }, + { urls: "stun:stun4.l.google.com:19302" } + ] + // ICE候補を格納する配列(シグナリング送信用) + const iceCandidatesRef = useRef([]) + + // WebSocket初期化 + const setURL = useCallback((wsUrl: string) => { + const socket = new WebSocket(wsUrl) + socket.onopen = () => { + console.log("WebSocket connected") + // WebSocket接続後にWebRTCの初期化を開始 + initWebRTC(socket) + } + socket.onmessage = (event: MessageEvent) => { + console.log("WebSocket message received:", event.data) + // ここでリモートからのシグナリング(アンサーやICE候補)を処理する + } + socket.onerror = (err) => { + console.error("WebSocket error:", err) + } + socket.onclose = () => { + console.log("WebSocket closed") + } + setWs(socket) + // クリーンアップ + return () => { + socket.close() + pc?.close() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // WebRTC初期化関数 + const initWebRTC = useCallback((socket: WebSocket) => { + // ユーザーに映像・音声の許可をリクエスト + navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + .then(stream => { + setLocalStream(stream) + const connection = new RTCPeerConnection({ iceServers }) + // ローカルストリームの各トラックを追加 + stream.getTracks().forEach(track => connection.addTrack(track, stream)) + // ダミーデータチャネルを作成してICE候補収集を促進 + connection.createDataChannel("dummy") + // ICE候補イベントのハンドラ + connection.onicecandidate = (event) => { + console.log("ICE candidate event:", event) + if (event.candidate) { + iceCandidatesRef.current.push(event.candidate.toJSON()) + // 候補が見つかるたびに送信する(例:ここで個別送信) + socket.send(JSON.stringify({ + type: "ice", + candidate: event.candidate.toJSON() + })) + } else { + // ICE候補の収集が完了(candidateがnull) + const signalingMessage = { + type: "offer", + sdp: connection.localDescription ? connection.localDescription.sdp : null, + iceCandidates: iceCandidatesRef.current + } + console.log("Signaling JSON message:", JSON.stringify(signalingMessage, null, 2)) + socket.send(JSON.stringify(signalingMessage)) + } + } + connection.onicegatheringstatechange = () => { + console.log("ICE gathering state:", connection.iceGatheringState) + } + // SDPオファー生成とローカル記述の設定 + connection.createOffer() + .then(offer => { + console.log("Created SDP offer:", offer) + return connection.setLocalDescription(offer) + }) + .then(() => { + // setLocalDescription()実行後、ICE候補収集が開始される + setPc(connection) + }) + .catch(err => console.error("Error during SDP offer creation:", err)) + }) + .catch(err => { + console.error("Error getting user media:", err) + }) + }, [iceServers]) + + return { pc, localStream, ws, setURL } +} diff --git a/node/src/types/globals.d.ts b/node/src/types/globals.d.ts new file mode 100755 index 0000000..ca54560 --- /dev/null +++ b/node/src/types/globals.d.ts @@ -0,0 +1,34 @@ +export {} + +declare global { + interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> + } + + interface RelatedApplication { + platform: string + url?: string + id?: string + } + + interface Navigator { + getInstalledRelatedApps?(): Promise + } + + interface Window { + webkit?: { + messageHandlers?: Record< + string, + { postMessage: (payload: any) => void } + > + } + __nativeCallback?: (res: NotificationResult) => void + } + + interface NotificationResult { + status: 'authorized' | 'notDetermined' | 'denied' | 'requested' | 'unknown' + token: string | null + id: string | null + } +} diff --git a/node/src/utils/index.ts b/node/src/utils/index.ts index 54794c3..cf37f87 100644 --- a/node/src/utils/index.ts +++ b/node/src/utils/index.ts @@ -12,21 +12,39 @@ return "Other" } -export const getBrowser = () => { - if (typeof window === "undefined") return "Other" +export const getBrowser = (): string => { + if (typeof window === 'undefined') return 'Other' + + // 1) iOS WebView判定: window.webkit.messageHandlers.getDevicePushToken があれば true + // 2) Android WebView判定: window.AndroidNative.getDevicePushToken があれば true + if ( + ( + (window as any).webkit + && (window as any).webkit.messageHandlers + && (window as any).webkit.messageHandlers.getDevicePushToken + ) + || ( + (window as any).AndroidNative + && (window as any).AndroidNative.getDevicePushToken + ) + ) { + return 'WebView' + } + const agent = window.navigator.userAgent.toLowerCase() - if (agent.indexOf("msie") != -1 || agent.indexOf("trident") != -1) { + if (agent.indexOf('msie') !== -1 || agent.indexOf('trident') !== -1) { return 'Internet Explorer' - } else if (agent.indexOf("edg") != -1 || agent.indexOf("edge") != -1) { + } else if (agent.indexOf('edg') !== -1 || agent.indexOf('edge') !== -1) { return 'Edge' - } else if (agent.indexOf("opr") != -1 || agent.indexOf("opera") != -1) { + } else if (agent.indexOf('opr') !== -1 || agent.indexOf('opera') !== -1) { return 'Opera' - } else if (agent.indexOf("chrome") != -1 || agent.indexOf("crios") != -1) { // iOSのChromeは 'crios' + } else if (agent.indexOf('chrome') !== -1 || agent.indexOf('crios') !== -1) { + // iOS版Chromeは 'crios' を含む return 'Chrome' - } else if (agent.indexOf("safari") != -1) { + } else if (agent.indexOf('safari') !== -1) { return 'Safari' - } else if (agent.indexOf("firefox") != -1) { + } else if (agent.indexOf('firefox') !== -1) { return 'FireFox' } else { return 'Other' diff --git a/node/src/utils/ios.ts b/node/src/utils/ios.ts new file mode 100644 index 0000000..40495fb --- /dev/null +++ b/node/src/utils/ios.ts @@ -0,0 +1,37 @@ +// ネイティブ呼び出しを Promise でラップ +let resolverMap = new Map void>() + +function callNative(handlerName: string): Promise { + return new Promise((resolve, reject) => { + if ( + typeof window === 'undefined' || + !window.webkit?.messageHandlers?.[handlerName] + ) { + return reject(new Error('native handler not found')) + } + + // 一意 ID を生成して resolver に登録 + const id = Date.now().toString(36) + Math.random().toString(36).slice(2) + resolverMap.set(id, resolve) + + // コールバック受信口を一度だけ定義 + if (!window.__nativeCallback) { + window.__nativeCallback = (res) => { + const { id } = res + if (id && resolverMap.has(id)) { + resolverMap.get(id)!(res) + resolverMap.delete(id) + } + } + } + + // ネイティブへ送信 + window.webkit!.messageHandlers[handlerName].postMessage(id) + }) +} + +export const checkNotificationStatus = () => + callNative('checkNotificationStatus') + +export const requestNotificationPermission = () => + callNative('requestNotificationPermission') diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/node/src/components/ThemeRegistry/theme.ts b/node/src/components/ThemeRegistry/theme.ts index 445f8f9..566a51e 100755 --- a/node/src/components/ThemeRegistry/theme.ts +++ b/node/src/components/ThemeRegistry/theme.ts @@ -15,6 +15,13 @@ fontFamily: roboto.style.fontFamily, }, components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { root: ({ ownerState }) => ({ diff --git a/node/src/hooks/index.ts b/node/src/hooks/index.ts index 3fd2095..281dae5 100755 --- a/node/src/hooks/index.ts +++ b/node/src/hooks/index.ts @@ -1 +1,2 @@ -export { useIndexedDB } from './useIndexedDB' \ No newline at end of file +export { useIndexedDB } from './useIndexedDB' +export { useWebRTC } from './useWebRTC' \ No newline at end of file diff --git a/node/src/hooks/useWebRTC.tsx b/node/src/hooks/useWebRTC.tsx new file mode 100755 index 0000000..ba8a142 --- /dev/null +++ b/node/src/hooks/useWebRTC.tsx @@ -0,0 +1,105 @@ +// useWebRTC.ts +import { useState, useEffect, useRef, useCallback } from 'react' + +interface useWebRTCReturn { + pc: RTCPeerConnection | null + localStream: MediaStream | null + ws: WebSocket | null + setURL: (wsUrl: string) => () => void +} + +export function useWebRTC(): useWebRTCReturn { + const [pc, setPc] = useState(null) + const [localStream, setLocalStream] = useState(null) + const [ws, setWs] = useState(null) + const iceServers: RTCIceServer[] = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:19302" }, + { urls: "stun:stun3.l.google.com:19302" }, + { urls: "stun:stun4.l.google.com:19302" } + ] + // ICE候補を格納する配列(シグナリング送信用) + const iceCandidatesRef = useRef([]) + + // WebSocket初期化 + const setURL = useCallback((wsUrl: string) => { + const socket = new WebSocket(wsUrl) + socket.onopen = () => { + console.log("WebSocket connected") + // WebSocket接続後にWebRTCの初期化を開始 + initWebRTC(socket) + } + socket.onmessage = (event: MessageEvent) => { + console.log("WebSocket message received:", event.data) + // ここでリモートからのシグナリング(アンサーやICE候補)を処理する + } + socket.onerror = (err) => { + console.error("WebSocket error:", err) + } + socket.onclose = () => { + console.log("WebSocket closed") + } + setWs(socket) + // クリーンアップ + return () => { + socket.close() + pc?.close() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // WebRTC初期化関数 + const initWebRTC = useCallback((socket: WebSocket) => { + // ユーザーに映像・音声の許可をリクエスト + navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + .then(stream => { + setLocalStream(stream) + const connection = new RTCPeerConnection({ iceServers }) + // ローカルストリームの各トラックを追加 + stream.getTracks().forEach(track => connection.addTrack(track, stream)) + // ダミーデータチャネルを作成してICE候補収集を促進 + connection.createDataChannel("dummy") + // ICE候補イベントのハンドラ + connection.onicecandidate = (event) => { + console.log("ICE candidate event:", event) + if (event.candidate) { + iceCandidatesRef.current.push(event.candidate.toJSON()) + // 候補が見つかるたびに送信する(例:ここで個別送信) + socket.send(JSON.stringify({ + type: "ice", + candidate: event.candidate.toJSON() + })) + } else { + // ICE候補の収集が完了(candidateがnull) + const signalingMessage = { + type: "offer", + sdp: connection.localDescription ? connection.localDescription.sdp : null, + iceCandidates: iceCandidatesRef.current + } + console.log("Signaling JSON message:", JSON.stringify(signalingMessage, null, 2)) + socket.send(JSON.stringify(signalingMessage)) + } + } + connection.onicegatheringstatechange = () => { + console.log("ICE gathering state:", connection.iceGatheringState) + } + // SDPオファー生成とローカル記述の設定 + connection.createOffer() + .then(offer => { + console.log("Created SDP offer:", offer) + return connection.setLocalDescription(offer) + }) + .then(() => { + // setLocalDescription()実行後、ICE候補収集が開始される + setPc(connection) + }) + .catch(err => console.error("Error during SDP offer creation:", err)) + }) + .catch(err => { + console.error("Error getting user media:", err) + }) + }, [iceServers]) + + return { pc, localStream, ws, setURL } +} diff --git a/node/src/types/globals.d.ts b/node/src/types/globals.d.ts new file mode 100755 index 0000000..ca54560 --- /dev/null +++ b/node/src/types/globals.d.ts @@ -0,0 +1,34 @@ +export {} + +declare global { + interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> + } + + interface RelatedApplication { + platform: string + url?: string + id?: string + } + + interface Navigator { + getInstalledRelatedApps?(): Promise + } + + interface Window { + webkit?: { + messageHandlers?: Record< + string, + { postMessage: (payload: any) => void } + > + } + __nativeCallback?: (res: NotificationResult) => void + } + + interface NotificationResult { + status: 'authorized' | 'notDetermined' | 'denied' | 'requested' | 'unknown' + token: string | null + id: string | null + } +} diff --git a/node/src/utils/index.ts b/node/src/utils/index.ts index 54794c3..cf37f87 100644 --- a/node/src/utils/index.ts +++ b/node/src/utils/index.ts @@ -12,21 +12,39 @@ return "Other" } -export const getBrowser = () => { - if (typeof window === "undefined") return "Other" +export const getBrowser = (): string => { + if (typeof window === 'undefined') return 'Other' + + // 1) iOS WebView判定: window.webkit.messageHandlers.getDevicePushToken があれば true + // 2) Android WebView判定: window.AndroidNative.getDevicePushToken があれば true + if ( + ( + (window as any).webkit + && (window as any).webkit.messageHandlers + && (window as any).webkit.messageHandlers.getDevicePushToken + ) + || ( + (window as any).AndroidNative + && (window as any).AndroidNative.getDevicePushToken + ) + ) { + return 'WebView' + } + const agent = window.navigator.userAgent.toLowerCase() - if (agent.indexOf("msie") != -1 || agent.indexOf("trident") != -1) { + if (agent.indexOf('msie') !== -1 || agent.indexOf('trident') !== -1) { return 'Internet Explorer' - } else if (agent.indexOf("edg") != -1 || agent.indexOf("edge") != -1) { + } else if (agent.indexOf('edg') !== -1 || agent.indexOf('edge') !== -1) { return 'Edge' - } else if (agent.indexOf("opr") != -1 || agent.indexOf("opera") != -1) { + } else if (agent.indexOf('opr') !== -1 || agent.indexOf('opera') !== -1) { return 'Opera' - } else if (agent.indexOf("chrome") != -1 || agent.indexOf("crios") != -1) { // iOSのChromeは 'crios' + } else if (agent.indexOf('chrome') !== -1 || agent.indexOf('crios') !== -1) { + // iOS版Chromeは 'crios' を含む return 'Chrome' - } else if (agent.indexOf("safari") != -1) { + } else if (agent.indexOf('safari') !== -1) { return 'Safari' - } else if (agent.indexOf("firefox") != -1) { + } else if (agent.indexOf('firefox') !== -1) { return 'FireFox' } else { return 'Other' diff --git a/node/src/utils/ios.ts b/node/src/utils/ios.ts new file mode 100644 index 0000000..40495fb --- /dev/null +++ b/node/src/utils/ios.ts @@ -0,0 +1,37 @@ +// ネイティブ呼び出しを Promise でラップ +let resolverMap = new Map void>() + +function callNative(handlerName: string): Promise { + return new Promise((resolve, reject) => { + if ( + typeof window === 'undefined' || + !window.webkit?.messageHandlers?.[handlerName] + ) { + return reject(new Error('native handler not found')) + } + + // 一意 ID を生成して resolver に登録 + const id = Date.now().toString(36) + Math.random().toString(36).slice(2) + resolverMap.set(id, resolve) + + // コールバック受信口を一度だけ定義 + if (!window.__nativeCallback) { + window.__nativeCallback = (res) => { + const { id } = res + if (id && resolverMap.has(id)) { + resolverMap.get(id)!(res) + resolverMap.delete(id) + } + } + } + + // ネイティブへ送信 + window.webkit!.messageHandlers[handlerName].postMessage(id) + }) +} + +export const checkNotificationStatus = () => + callNative('checkNotificationStatus') + +export const requestNotificationPermission = () => + callNative('requestNotificationPermission') diff --git a/public.tar.gz b/public.tar.gz index 96256e2..dbd0b8a 100644 --- a/public.tar.gz +++ b/public.tar.gz Binary files differ diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/node/src/components/ThemeRegistry/theme.ts b/node/src/components/ThemeRegistry/theme.ts index 445f8f9..566a51e 100755 --- a/node/src/components/ThemeRegistry/theme.ts +++ b/node/src/components/ThemeRegistry/theme.ts @@ -15,6 +15,13 @@ fontFamily: roboto.style.fontFamily, }, components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { root: ({ ownerState }) => ({ diff --git a/node/src/hooks/index.ts b/node/src/hooks/index.ts index 3fd2095..281dae5 100755 --- a/node/src/hooks/index.ts +++ b/node/src/hooks/index.ts @@ -1 +1,2 @@ -export { useIndexedDB } from './useIndexedDB' \ No newline at end of file +export { useIndexedDB } from './useIndexedDB' +export { useWebRTC } from './useWebRTC' \ No newline at end of file diff --git a/node/src/hooks/useWebRTC.tsx b/node/src/hooks/useWebRTC.tsx new file mode 100755 index 0000000..ba8a142 --- /dev/null +++ b/node/src/hooks/useWebRTC.tsx @@ -0,0 +1,105 @@ +// useWebRTC.ts +import { useState, useEffect, useRef, useCallback } from 'react' + +interface useWebRTCReturn { + pc: RTCPeerConnection | null + localStream: MediaStream | null + ws: WebSocket | null + setURL: (wsUrl: string) => () => void +} + +export function useWebRTC(): useWebRTCReturn { + const [pc, setPc] = useState(null) + const [localStream, setLocalStream] = useState(null) + const [ws, setWs] = useState(null) + const iceServers: RTCIceServer[] = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:19302" }, + { urls: "stun:stun3.l.google.com:19302" }, + { urls: "stun:stun4.l.google.com:19302" } + ] + // ICE候補を格納する配列(シグナリング送信用) + const iceCandidatesRef = useRef([]) + + // WebSocket初期化 + const setURL = useCallback((wsUrl: string) => { + const socket = new WebSocket(wsUrl) + socket.onopen = () => { + console.log("WebSocket connected") + // WebSocket接続後にWebRTCの初期化を開始 + initWebRTC(socket) + } + socket.onmessage = (event: MessageEvent) => { + console.log("WebSocket message received:", event.data) + // ここでリモートからのシグナリング(アンサーやICE候補)を処理する + } + socket.onerror = (err) => { + console.error("WebSocket error:", err) + } + socket.onclose = () => { + console.log("WebSocket closed") + } + setWs(socket) + // クリーンアップ + return () => { + socket.close() + pc?.close() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // WebRTC初期化関数 + const initWebRTC = useCallback((socket: WebSocket) => { + // ユーザーに映像・音声の許可をリクエスト + navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + .then(stream => { + setLocalStream(stream) + const connection = new RTCPeerConnection({ iceServers }) + // ローカルストリームの各トラックを追加 + stream.getTracks().forEach(track => connection.addTrack(track, stream)) + // ダミーデータチャネルを作成してICE候補収集を促進 + connection.createDataChannel("dummy") + // ICE候補イベントのハンドラ + connection.onicecandidate = (event) => { + console.log("ICE candidate event:", event) + if (event.candidate) { + iceCandidatesRef.current.push(event.candidate.toJSON()) + // 候補が見つかるたびに送信する(例:ここで個別送信) + socket.send(JSON.stringify({ + type: "ice", + candidate: event.candidate.toJSON() + })) + } else { + // ICE候補の収集が完了(candidateがnull) + const signalingMessage = { + type: "offer", + sdp: connection.localDescription ? connection.localDescription.sdp : null, + iceCandidates: iceCandidatesRef.current + } + console.log("Signaling JSON message:", JSON.stringify(signalingMessage, null, 2)) + socket.send(JSON.stringify(signalingMessage)) + } + } + connection.onicegatheringstatechange = () => { + console.log("ICE gathering state:", connection.iceGatheringState) + } + // SDPオファー生成とローカル記述の設定 + connection.createOffer() + .then(offer => { + console.log("Created SDP offer:", offer) + return connection.setLocalDescription(offer) + }) + .then(() => { + // setLocalDescription()実行後、ICE候補収集が開始される + setPc(connection) + }) + .catch(err => console.error("Error during SDP offer creation:", err)) + }) + .catch(err => { + console.error("Error getting user media:", err) + }) + }, [iceServers]) + + return { pc, localStream, ws, setURL } +} diff --git a/node/src/types/globals.d.ts b/node/src/types/globals.d.ts new file mode 100755 index 0000000..ca54560 --- /dev/null +++ b/node/src/types/globals.d.ts @@ -0,0 +1,34 @@ +export {} + +declare global { + interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> + } + + interface RelatedApplication { + platform: string + url?: string + id?: string + } + + interface Navigator { + getInstalledRelatedApps?(): Promise + } + + interface Window { + webkit?: { + messageHandlers?: Record< + string, + { postMessage: (payload: any) => void } + > + } + __nativeCallback?: (res: NotificationResult) => void + } + + interface NotificationResult { + status: 'authorized' | 'notDetermined' | 'denied' | 'requested' | 'unknown' + token: string | null + id: string | null + } +} diff --git a/node/src/utils/index.ts b/node/src/utils/index.ts index 54794c3..cf37f87 100644 --- a/node/src/utils/index.ts +++ b/node/src/utils/index.ts @@ -12,21 +12,39 @@ return "Other" } -export const getBrowser = () => { - if (typeof window === "undefined") return "Other" +export const getBrowser = (): string => { + if (typeof window === 'undefined') return 'Other' + + // 1) iOS WebView判定: window.webkit.messageHandlers.getDevicePushToken があれば true + // 2) Android WebView判定: window.AndroidNative.getDevicePushToken があれば true + if ( + ( + (window as any).webkit + && (window as any).webkit.messageHandlers + && (window as any).webkit.messageHandlers.getDevicePushToken + ) + || ( + (window as any).AndroidNative + && (window as any).AndroidNative.getDevicePushToken + ) + ) { + return 'WebView' + } + const agent = window.navigator.userAgent.toLowerCase() - if (agent.indexOf("msie") != -1 || agent.indexOf("trident") != -1) { + if (agent.indexOf('msie') !== -1 || agent.indexOf('trident') !== -1) { return 'Internet Explorer' - } else if (agent.indexOf("edg") != -1 || agent.indexOf("edge") != -1) { + } else if (agent.indexOf('edg') !== -1 || agent.indexOf('edge') !== -1) { return 'Edge' - } else if (agent.indexOf("opr") != -1 || agent.indexOf("opera") != -1) { + } else if (agent.indexOf('opr') !== -1 || agent.indexOf('opera') !== -1) { return 'Opera' - } else if (agent.indexOf("chrome") != -1 || agent.indexOf("crios") != -1) { // iOSのChromeは 'crios' + } else if (agent.indexOf('chrome') !== -1 || agent.indexOf('crios') !== -1) { + // iOS版Chromeは 'crios' を含む return 'Chrome' - } else if (agent.indexOf("safari") != -1) { + } else if (agent.indexOf('safari') !== -1) { return 'Safari' - } else if (agent.indexOf("firefox") != -1) { + } else if (agent.indexOf('firefox') !== -1) { return 'FireFox' } else { return 'Other' diff --git a/node/src/utils/ios.ts b/node/src/utils/ios.ts new file mode 100644 index 0000000..40495fb --- /dev/null +++ b/node/src/utils/ios.ts @@ -0,0 +1,37 @@ +// ネイティブ呼び出しを Promise でラップ +let resolverMap = new Map void>() + +function callNative(handlerName: string): Promise { + return new Promise((resolve, reject) => { + if ( + typeof window === 'undefined' || + !window.webkit?.messageHandlers?.[handlerName] + ) { + return reject(new Error('native handler not found')) + } + + // 一意 ID を生成して resolver に登録 + const id = Date.now().toString(36) + Math.random().toString(36).slice(2) + resolverMap.set(id, resolve) + + // コールバック受信口を一度だけ定義 + if (!window.__nativeCallback) { + window.__nativeCallback = (res) => { + const { id } = res + if (id && resolverMap.has(id)) { + resolverMap.get(id)!(res) + resolverMap.delete(id) + } + } + } + + // ネイティブへ送信 + window.webkit!.messageHandlers[handlerName].postMessage(id) + }) +} + +export const checkNotificationStatus = () => + callNative('checkNotificationStatus') + +export const requestNotificationPermission = () => + callNative('requestNotificationPermission') diff --git a/public.tar.gz b/public.tar.gz index 96256e2..dbd0b8a 100644 --- a/public.tar.gz +++ b/public.tar.gz Binary files differ diff --git a/standalone.tar.gz b/standalone.tar.gz index 4676e74..096ad6f 100644 --- a/standalone.tar.gz +++ b/standalone.tar.gz Binary files differ diff --git a/._node b/._node new file mode 100755 index 0000000..cbe0ae3 --- /dev/null +++ b/._node Binary files differ diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..4bc6f22 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -:*)", + "Bash(npx tsc:*)", + "Bash(python3 -c \":*)" + ] + } +} diff --git a/node/.env.development b/node/.env.development new file mode 100755 index 0000000..de0b4af --- /dev/null +++ b/node/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://dev.mobile.raikyakun.app diff --git a/node/.env.production b/node/.env.production new file mode 100755 index 0000000..15d4cfc --- /dev/null +++ b/node/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL=https://mobile.raikyakun.app diff --git a/node/next.config.js b/node/next.config.js old mode 100644 new mode 100755 index 7004a6a..40d345b --- a/node/next.config.js +++ b/node/next.config.js @@ -4,7 +4,7 @@ swSrc: '/src/worker/index.js' }) const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, distDir: 'build', output: 'standalone', diff --git a/node/package-lock.json b/node/package-lock.json index 1f38087..9ccde6b 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -25,6 +25,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", @@ -6307,6 +6308,14 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/node/package.json b/node/package.json index 7e58043..3dae0f9 100644 --- a/node/package.json +++ b/node/package.json @@ -26,6 +26,7 @@ "eslint-config-next": "13.4.12", "jssha": "^3.3.1", "next": "13.4.7", + "qrcode.react": "^4.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.5", diff --git a/node/public/images/._android-chrome.png b/node/public/images/._android-chrome.png new file mode 100755 index 0000000..f9de086 --- /dev/null +++ b/node/public/images/._android-chrome.png Binary files differ diff --git a/node/public/images/._install_for_ios.png b/node/public/images/._install_for_ios.png new file mode 100755 index 0000000..7dd8ff2 --- /dev/null +++ b/node/public/images/._install_for_ios.png Binary files differ diff --git a/node/public/images/install_for_ios.png b/node/public/images/install_for_ios.png index aeed513..fc4a4d4 100755 --- a/node/public/images/install_for_ios.png +++ b/node/public/images/install_for_ios.png Binary files differ diff --git a/node/public/manifest.json b/node/public/manifest.json deleted file mode 100755 index 108558a..0000000 --- a/node/public/manifest.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "id": "/", - "theme_color": "#f69435", - "background_color": "#1a2484", - "display": "standalone", - "scope": "/", - "start_url": "/", - "name": "らいきゃくん通知", - "short_name": "らいきゃくん通知", - "icons": [ - { - "src": "/images/maskable_icon_x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/images/maskable_icon_x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "screenshots": [ - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "wide", - "label": "らいきゃくん通知" - }, - { - "src": "/images/maskable_icon_x512.png", - "sizes": "512x512", - "type": "image/gif", - "form_factor": "narrow", - "label": "らいきゃくん通知" - } - ], - "description": "モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます" -} \ No newline at end of file diff --git a/node/public/sw.js b/node/public/sw.js index ad0a7f5..6cefab4 100644 --- a/node/public/sw.js +++ b/node/public/sw.js @@ -1 +1 @@ -!function(){"use strict";console.log("WORKER"),[{'revision':'df6d18370126a213917aef32f80b50c4','url':'/_next/app-build-manifest.json'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/615-c2b36049af4e24f3.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/91-1110d2e8ea0b97b6.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/actions/page-a129bb8e5013d8ab.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/layout-b99c810ab648932e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/app/page-592291e29dfb0b06.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/main-fa5beb6329f3a69f.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'vgB98fWlAldTL3GAfOGDO','url':'/_next/static/chunks/webpack-bb32139746352e4d.js'},{'revision':'8b81d5f9f3414b62','url':'/_next/static/css/8b81d5f9f3414b62.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'e778ff09c2dd7b2d','url':'/_next/static/css/e778ff09c2dd7b2d.css'},{'revision':'f1b44860c66554b91f3b1c81556f73ca','url':'/_next/static/media/05a31a2ca4975f99-s.woff2'},{'revision':'5e22a46c04d947a36ea0cad07afcc9e1','url':'/_next/static/media/0e4fe491bf84089c-s.p.woff2'},{'revision':'491a7a9678c3cfd4f86c092c68480f23','url':'/_next/static/media/1c57ca6f5208a29b-s.woff2'},{'revision':'93dcb0c222437699e9dd591d8b5a6b85','url':'/_next/static/media/3dbd163d3bb09d47-s.woff2'},{'revision':'b44d0dd122f9146504d444f290252d88','url':'/_next/static/media/42d52f46a26971a3-s.woff2'},{'revision':'705e5297b1a92dac3b13b2705b7156a7','url':'/_next/static/media/44c3f6d12248be7f-s.woff2'},{'revision':'5fba57b10417c946c556545c9f348bbd','url':'/_next/static/media/4a8324e71b197806-s.woff2'},{'revision':'c4eb7f37bc4206c901ab08601f21f0f2','url':'/_next/static/media/513657b02c5c193f-s.woff2'},{'revision':'bb9d99fb9bbc695be80777ca2c1c2bee','url':'/_next/static/media/51ed15f9841b9f9d-s.woff2'},{'revision':'e64969a373d0acf2586d1fd4224abb90','url':'/_next/static/media/5647e4c23315a2d2-s.woff2'},{'revision':'e7df3d0942815909add8f9d0c40d00d9','url':'/_next/static/media/627622453ef56b0d-s.p.woff2'},{'revision':'2effa1fe2d0dff3e7b8c35ee120e0d05','url':'/_next/static/media/71ba03c5176fbd9c-s.woff2'},{'revision':'3ba6fb27a0ea92c2f1513add6dbddf37','url':'/_next/static/media/7be645d133f3ee22-s.woff2'},{'revision':'fd4ff709e3581e3f62e40e90260a1ad7','url':'/_next/static/media/7c53f7419436e04b-s.woff2'},{'revision':'0772a436bbaaaf4381e9d87bab168217','url':'/_next/static/media/7d8c9b0ca4a64a5a-s.p.woff2'},{'revision':'bd30db6b297b76f3a3a76f8d8ec5aac9','url':'/_next/static/media/83e4d81063b4b659-s.woff2'},{'revision':'7a2e2eae214e49b4333030f789100720','url':'/_next/static/media/8fb72f69fba4e3d2-s.woff2'},{'revision':'376ffe2ca0b038d08d5e582ec13a310f','url':'/_next/static/media/912a9cfe43c928d9-s.woff2'},{'revision':'1f6d3cf6d38f25d83d95f5a800b8cac3','url':'/_next/static/media/934c4b7cb736f2a3-s.p.woff2'},{'revision':'96e992d510ed36aa573ab75df8698b42','url':'/_next/static/media/a5b77b63ef20339c-s.woff2'},{'revision':'f7ec4e2d6c9f82076c56a871d1d23a2d','url':'/_next/static/media/a6d330d7873e7320-s.woff2'},{'revision':'8096f9b1a15c26638179b6c9499ff260','url':'/_next/static/media/baf12dd90520ae41-s.woff2'},{'revision':'5756151c819325914806c6be65088b13','url':'/_next/static/media/bbdb6f0234009aba-s.woff2'},{'revision':'cc0ffafe16e997fe75c32c5c6837e781','url':'/_next/static/media/bd976642b4f7fd99-s.woff2'},{'revision':'74c3556b9dad12fb76f84af53ba69410','url':'/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2'},{'revision':'c2b2c28b98016afb2cb7e029c23f1f9f','url':'/_next/static/media/cff529cd86cc0276-s.woff2'},{'revision':'4d1e5298f2c7e19ba39a6ac8d88e91bd','url':'/_next/static/media/d117eea74e01de14-s.woff2'},{'revision':'dd930bafc6297347be3213f22cc53d3e','url':'/_next/static/media/d6b16ce4a6175f26-s.woff2'},{'revision':'7155c037c22abdc74e4e6be351c0593c','url':'/_next/static/media/de9eb3a9f0fa9e10-s.woff2'},{'revision':'7a500aa24dccfcf0cc60f781072614f5','url':'/_next/static/media/dfa8b99978df7bbc-s.woff2'},{'revision':'9a74bbc5f0d651f8f5b6df4fb3c5c755','url':'/_next/static/media/e25729ca87cc7df9-s.woff2'},{'revision':'90687dc5a4b6b6271c9f1c1d4986ca10','url':'/_next/static/media/eb52b768f62eeeb4-s.woff2'},{'revision':'0e89df9522084290e01e4127495fae99','url':'/_next/static/media/ec159349637c90ad-s.woff2'},{'revision':'2855f7c90916c37fe4e6bd36205a26a8','url':'/_next/static/media/f06116e890b3dadb-s.woff2'},{'revision':'71f3fcaf22131c3368d9ec28ef839831','url':'/_next/static/media/fd4db3eb5472fc27-s.woff2'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/vgB98fWlAldTL3GAfOGDO/_ssgManifest.js'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'9e7ece4e34371110a30e29d639072b70','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'5260443e92381a6afa0efa13f97c0d90','url':'/manifest.json'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file +!function(){"use strict";console.log("WORKER"),[{'revision':'c97631b91069d011bcbd3ca0bdd2d430','url':'/_next/app-build-manifest.json'},{'revision':'b78f2f95f712fdbfd1149569fa52161f','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/7-Xkb8JgbEAQuMGrUfa00/_ssgManifest.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/138-7e73bd80a974c846.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/212-f854a6d764cb4e9c.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/307-8c8f6ce7b0973788.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/625-b4599b8aef945112.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/91-f7e8a0fbd32994bc.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/actions/page-174bc631b586acde.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/layout-0ba6141c13d4b6d7.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/app/page-6516f59e532f6fa5.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/bce60fc1-3796239d190b3b86.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/framework-8883d1e9be70c3da.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-app-8e36dfb2634fcb78.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/main-c6d8d4626ca856cb.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_app-998b8fceeadee23e.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/pages/_error-e8b35f8a0cf92802.js'},{'revision':'79330112775102f91e1010318bae2bd3','url':'/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js'},{'revision':'7-Xkb8JgbEAQuMGrUfa00','url':'/_next/static/chunks/webpack-838ea4bca6f6c7d2.js'},{'revision':'62953a87cc49d3a7','url':'/_next/static/css/62953a87cc49d3a7.css'},{'revision':'922f92dac52cd9b3','url':'/_next/static/css/922f92dac52cd9b3.css'},{'revision':'b6802c76992d71e7','url':'/_next/static/css/b6802c76992d71e7.css'},{'revision':'a0c5b49eea2028b7fd6e3b0d0d1c8a0a','url':'/_next/static/media/001f750b538f7a9e-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'536359ff0fc970eef8be299490b3eaff','url':'/_next/static/media/1a634e73dfeff02c-s.woff2'},{'revision':'b7627e3c9663757d70121f2ad4c8d986','url':'/_next/static/media/1e41be92c43b3255-s.p.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'1e5f06cab9f9fe1f9df22e2e2aeae2e4','url':'/_next/static/media/4120b0a488381b31-s.woff2'},{'revision':'4409a8110fdf0ba9059a609f00deafbd','url':'/_next/static/media/4f48fe9100901594-s.woff2'},{'revision':'a721fb76b97a8ad2d71e6466a663e7d1','url':'/_next/static/media/5eae37b69937655e-s.woff2'},{'revision':'f852254ed0041481aaac038e94fb24dc','url':'/_next/static/media/80841ae24d03ed90-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'c65df4878c04253139ed838edf774dee','url':'/_next/static/media/970d71e7dcbc144d-s.woff2'},{'revision':'7b8d2e8d1d6863bd8250cdfe9b2a583e','url':'/_next/static/media/b3f718d64f9a6dea-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'fd0fd1665e816597c3b3b87ac1cd28bc','url':'/images/android-chrome.png'},{'revision':'6f3e3155a3f2321e5f7405f1842faaa7','url':'/images/icon_checkbox_accept.png'},{'revision':'33149b81595b8e5f2f567682590928cf','url':'/images/icon_checkbox_reject.png'},{'revision':'133b7f2dc4ea79de526bbe6a50736e45','url':'/images/install_for_ios.png'},{'revision':'e19320bec37641bf5b180e1ae046ee78','url':'/images/iphone-bell-icon.png'},{'revision':'88b2400e45bcf521514b7252cbb2d959','url':'/images/iphone-settings-icon.png'},{'revision':'7712da80ea749c4448626fa030a88416','url':'/images/maskable_icon_x128.png'},{'revision':'d17df9b05031ca9232898c1d18c9f800','url':'/images/maskable_icon_x144.png'},{'revision':'97d32f45fa7117f0ef91643cf12b1f2c','url':'/images/maskable_icon_x192.png'},{'revision':'8cc5c97b7006c09dfb4af4e2d9383cee','url':'/images/maskable_icon_x384.png'},{'revision':'3484c469af447168b6f232e40f1ef72e','url':'/images/maskable_icon_x512.png'},{'revision':'752bab2112e77141088d13e6e9dcf6df','url':'/images/らいきゃくんアプリ.png'},{'revision':'1c4ca837c40e96a6c2b3b57445dfd099','url':'/sw.js'},{'revision':'facf02c3cd022e48ee4dcada88dd5d1f','url':'/url.png'}],self.addEventListener("push",function(o){console.log("data",o.data.text());let{message:n,callId:i,createdAt:r,visitor:s,dst:c}=JSON.parse(o.data.text()),a=t().then(t=>{var o;return o={callId:i,createdAt:r,visitor:s,dst:c},new Promise((n,i)=>{let r=t.transaction([e],"readwrite"),s=r.objectStore(e),c=s.put(o);c.onerror=e=>i(e.target.errorCode),c.onsuccess=e=>n(e.target.result)})}),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:r,visitor:s,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});o.waitUntil(Promise.all([a,l]).catch(e=>{console.error("Error in one of the push event processes:",e)}))}),self.addEventListener("notificationclick",function(e){var t;console.log("notificationclick");let o=null==e?void 0:null===(t=e.notification)||void 0===t?void 0:t.data;e.notification.close(),e.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then(function(t){let n="/actions?data=".concat(encodeURIComponent(o),"&action=").concat(e.action);for(let e=0;e{let n=indexedDB.open("pwa-db",1);n.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},n.onerror=e=>o(e.target.errorCode),n.onsuccess=e=>t(e.target.result)})}async function o(e){try{console.log(e);let o=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:e})});if(!o.ok)throw Error("Failed to subscribe to push service.");let n=await o.json(),i=await t();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(e){console.error("Error in subscribePush:",e)}}async function n(e){console.log(e);let t=await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:e})});if(!t.ok)throw Error("Failed to subscribe to push service.")}}(); \ No newline at end of file diff --git a/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js new file mode 100644 index 0000000..c9ce282 --- /dev/null +++ b/node/public/worker-7-Xkb8JgbEAQuMGrUfa00.js @@ -0,0 +1 @@ +(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js b/node/public/worker-vgB98fWlAldTL3GAfOGDO.js deleted file mode 100644 index c9ce282..0000000 --- a/node/public/worker-vgB98fWlAldTL3GAfOGDO.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";console.log("WORKER"),self.__WB_MANIFEST,self.addEventListener("push",(function(t){console.log("data",t.data.text());const{message:n,callId:i,createdAt:s,visitor:r,dst:c}=JSON.parse(t.data.text()),a=o().then((t=>function(t,o){return new Promise(((n,i)=>{const s=t.transaction([e],"readwrite").objectStore(e).put(o);s.onerror=t=>i(t.target.errorCode),s.onsuccess=t=>n(t.target.result)}))}(t,{callId:i,createdAt:s,visitor:r,dst:c}))),l=registration.showNotification(n,{body:"30秒以内に回答してください",icon:"/icons/android-chrome-192x192.png",data:JSON.stringify({callId:i,createdAt:s,visitor:r,dst:c}),actions:[{action:"accept",title:"対応可能"},{action:"reject",title:"対応不可"}]});t.waitUntil(Promise.all([a,l]).catch((t=>{console.error("Error in one of the push event processes:",t)})))})),self.addEventListener("notificationclick",(function(t){var e;console.log("notificationclick");const o=null==t||null===(e=t.notification)||void 0===e?void 0:e.data;t.notification.close(),t.waitUntil(clients.matchAll({type:"window",includeUncontrolled:!0,userVisibleOnly:!0}).then((function(e){let n=`/actions?data=${encodeURIComponent(o)}&action=${t.action}`;for(let t=0;t{const i=indexedDB.open(t,1);i.onupgradeneeded=t=>{t.target.result.createObjectStore(e,{keyPath:"id"})},i.onerror=t=>n(t.target.errorCode),i.onsuccess=t=>o(t.target.result)}))}async function n(t){try{console.log(t);const e=await fetch("/api/mobile",{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({registration:t})});if(!e.ok)throw new Error("Failed to subscribe to push service.");const n=await e.json(),i=await o();await storeIdentifier(i,n.identifier),console.log("Identifier stored in IndexedDB:",n.identifier)}catch(t){console.error("Error in subscribePush:",t)}}async function i(t){console.log(t);if(!(await fetch("/api/mobile",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubscription:t})})).ok)throw new Error("Failed to subscribe to push service.")}})(); \ No newline at end of file diff --git a/node/src/app/accessKey/route.ts b/node/src/app/accessKey/route.ts new file mode 100755 index 0000000..85c7dac --- /dev/null +++ b/node/src/app/accessKey/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const COOKIE_NAME = '__Host-raikyakun_access' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 400 + +export async function GET() { + const cookieStore = await cookies() + const accessKey = cookieStore.get(COOKIE_NAME)?.value ?? null + + const res = NextResponse.json({ accessKey }) + res.headers.set('Cache-Control', 'no-store') + + if (accessKey) { + // Rolling: アクセスのたびに期限をリセット + res.cookies.set(COOKIE_NAME, accessKey, { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + } + + return res +} + +export async function DELETE() { + const res = new NextResponse(null, { status: 204 }) + res.cookies.set(COOKIE_NAME, '', { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 0, + }) + return res +} + +export async function POST(req: Request) { + // ざっくりCSRF対策:同一オリジン以外を弾く(不要なら削除OK) + const origin = req.headers.get('origin') ?? '' + const host = req.headers.get('host') ?? '' + if (origin && host && !origin.includes(host)) { + return new NextResponse('forbidden', { status: 403 }) + } + + const { accessKey } = await req.json().catch(() => ({} as any)) + if (typeof accessKey !== 'string' || accessKey.length === 0) { + return new NextResponse('bad request', { status: 400 }) + } + + const res = NextResponse.json({ ok: true }) + res.headers.set('Cache-Control', 'no-store') + res.headers.set('Referrer-Policy', 'no-referrer') + + res.cookies.set('__Host-raikyakun_access', accessKey, { + httpOnly: true, + secure: true, // HTTPS必須(ローカルHTTPなら開発時だけfalseに) + sameSite: 'lax', + path: '/', + maxAge: COOKIE_MAX_AGE, + }) + + return res +} \ No newline at end of file diff --git a/node/src/app/actions/page.tsx b/node/src/app/actions/page.tsx index 669e689..a8ffee4 100755 --- a/node/src/app/actions/page.tsx +++ b/node/src/app/actions/page.tsx @@ -1,291 +1,300 @@ -'use client' -import React, { useMemo, useState, useEffect, useRef } from 'react' -import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, - AppBar, Toolbar - } from '@mui/material' -import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' -import { CircularProgressProps } from '@mui/material/CircularProgress' -import { useSearchParams } from 'next/navigation' -import { User } from '@/types' -import axios, { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import './page.css' -import { useIndexedDB } from '@/hooks' - -interface Notification { - title: string - messsage: string - callId: string - visitor: string - dst: User - createdAt: number -} - -/* 応答結果と来訪者へのメッセージ */ -interface ActionReply { - callId: string - userId: string - result: string - optionMessage: string -} - -/* 代替対応者へメッセージを送る */ -interface ActionContact { - callId: string - message: string -} - -const TIMEOUT = 59 -export default function Actions(){ - const searchParams = useSearchParams() - const action = searchParams.get('action') - const router = useRouter() - - const [radioValue, setRadioValue] = useState('default') - const [selectedTime, setSelectedTime] = useState('5分') - const [customMessage, setCustomMessage] = useState('') - const [count, setCount] = useState(TIMEOUT) - const [users, setUsers] = useState([]) - const [userId, setUserId] = useState('') - const [messages, setMessages] = useState([]) - const [templateMessage, setTemplateMessage] = useState('') - const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) - const [accessKeyError, setAccessKeyError] = useState(null) - const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') - const [isLoading, setIsLoading] = useState(true) - const reqIdRef = useRef(0) - const timeout = useRef(TIMEOUT) - - const { latestCall, updateResponder } = useIndexedDB() - - useEffect(() => { - setIsLoading(true) - const messagesJSON = localStorage.getItem('messages') - if(messagesJSON) { - const messages = JSON.parse(messagesJSON) as string[] - setMessages(messages) - if(messages.length > 0) setTemplateMessage(messages[0]) - } - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - setAccessKeyError('アクセスキーがありません') - return - } - axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) - .then(({data:{users}}) => { - setUsers(users) - if(users.length > 0) { - const userId = users[0].id - setUserId(userId) - } - setIsLoading(false) - }) - .catch(err => { - setUsers([]) - console.error(err) - if (axios.isAxiosError(err)) { - const serverError = err as AxiosError<{error: string}> - if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) - else setAccessKeyError('接続に失敗しました') - } else setAccessKeyError('不明なエラーが発生しました') - setIsLoading(false) - }) - }, []) - - const notification = useMemo(()=>{ - const dataJSON = searchParams.get('data') - if(!dataJSON) return null - const {createdAt, ...theOthers} = JSON.parse(dataJSON) - return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification - }, [searchParams]) - - - const message = useMemo(()=>{ - switch(radioValue){ - case 'time': return `${selectedTime}ほどお待ちください` - case 'custom': return customMessage - case 'template': return templateMessage - default: return '' - } - }, [radioValue, selectedTime, customMessage, templateMessage]) - - useEffect(()=>{ - if(!notification) return - console.log('notification', notification) - - // タイムアウトの設定 - const { createdAt } = notification - timeout.current -= Date.now()/1000 - createdAt - let last = performance.now() - const draw = () => { - const now = performance.now() - const diff = (now-last)/1000 - last = now - if(timeout.current > 0){ - timeout.current -= diff - setCount(timeout.current) - reqIdRef.current = requestAnimationFrame(draw) - } else { - // タイムアウトした場合 - setCount(0) - setIsAccept(false) - setRadioValue('default') - setSubmitResult('timeout') - } - } - draw() - - return () => { - console.log("Countdownキャンセル") - cancelAnimationFrame(reqIdRef.current) - } - }, [notification]) - - const handleSelect = (value: string) => setSelectedTime(value) - - const handleSubmit = (isAccept: boolean) => { - if(!notification) return - setIsAccept(isAccept) - setSubmitResult('pending') - const accessKey = localStorage.getItem('AccessKey') - if(!accessKey) { - window.alert('アクセスキーが無いため失敗しました') - return - } - const { callId } = notification - const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} - axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) - .then(()=>{ - setSubmitResult('ok') - const responder = users.find(user => user.id == userId) - if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) - }) - .catch(err => { - setSubmitResult('error') - console.error('送信失敗しました', err) - }) - console.log('res') - } - - const disabled = isLoading || isAccept != null - - const cancel = () => { - if(notification && latestCall && !('responder' in latestCall) ) { - const { callId } = notification - updateResponder(callId, null) - } - router.replace('/') - } - - return (<> - - - - - - - - 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 - - - 訪問先: [{notification?.dst.group}] {notification?.dst.name} - - - {submitResult === '' && } - {submitResult === 'pending' && } - - - setRadioValue(e.target.value)}> - } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> - } disabled={disabled} label={<> - setRadioValue('time')}> - - - - - -
- ほどお待ちください - } /> - } disabled={disabled} label={ - setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> - } /> - {messages.length != 0 && } disabled={disabled} label={ - messages.length == 1 ? messages[0] - : - } />} -
- - {users.length == 1 && -
-
対応者名義
-
[{users[0].group}] {users[0].name}
-
- } - {users.length > 1 && - <> - - 対応者の名義を選択してください - - } -
- {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} - {submitResult === 'ok' &&
送信成功しました
} - {submitResult === 'error' &&
送信失敗しました
} - {submitResult === 'timeout' &&
有効期限が過ぎました
} - {accessKeyError &&
{accessKeyError}
} - {isLoading &&
読み込み中…
} - {!isLoading &&
- isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 - isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 -
} -
-
-
-
- ) -} -/* -代わりに「◯◯◯」が対応します - - 代理対応者に連絡事項を伝えられます -*/ - -function CircularProgressWithLabel( - props: CircularProgressProps & { value: number }, - ) { - return ( - - - 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> - - 10 ? '#1976d2':'#d32f2f'}} - > - {Math.floor(props.value)} - - - - - ) +'use client' +import React, { useMemo, useState, useEffect, useRef } from 'react' +import { Container, Box, Grid, Alert, ButtonGroup, RadioGroup, Radio, FormControlLabel, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, CircularProgress, Select, MenuItem, + AppBar, Toolbar + } from '@mui/material' +import {ArrowBackIosNew as ArrowBackIosNewIcon} from '@mui/icons-material' +import { CircularProgressProps } from '@mui/material/CircularProgress' +import { useSearchParams } from 'next/navigation' +import { User } from '@/types' +import axios, { AxiosError } from 'axios' +import { useRouter } from 'next/navigation' +import './page.css' +import { useIndexedDB, useWebRTC } from '@/hooks' + +interface Notification { + title: string + messsage: string + callId: string + visitor: string + dst: User + createdAt: number +} + +/* 応答結果と来訪者へのメッセージ */ +interface ActionReply { + callId: string + userId: string + result: string + optionMessage: string +} + +/* 代替対応者へメッセージを送る */ +interface ActionContact { + callId: string + message: string +} + +const TIMEOUT = 59 +export default function Actions(){ + const searchParams = useSearchParams() + const action = searchParams.get('action') + const router = useRouter() + + const [radioValue, setRadioValue] = useState('default') + const [selectedTime, setSelectedTime] = useState('5分') + const [customMessage, setCustomMessage] = useState('') + const [count, setCount] = useState(TIMEOUT) + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [messages, setMessages] = useState([]) + const [templateMessage, setTemplateMessage] = useState('') + const [isAccept, setIsAccept] = useState(action === 'accept' ? true : action === 'reject' ? false : null) + const [accessKey, setAccessKey] = useState('') + const [accessKeyError, setAccessKeyError] = useState(null) + const [submitResult, setSubmitResult] = useState<'' | 'pending' | 'ok' | 'timeout' | 'error'>('') + const [isLoading, setIsLoading] = useState(true) + const reqIdRef = useRef(0) + const timeout = useRef(TIMEOUT) + + const { latestCall, updateResponder } = useIndexedDB() + + useEffect(() => { + setIsLoading(true) + const messagesJSON = localStorage.getItem('messages') + if(messagesJSON) { + const messages = JSON.parse(messagesJSON) as string[] + setMessages(messages) + if(messages.length > 0) setTemplateMessage(messages[0]) + } + fetch('/accessKey') + .then(res => res.ok ? res.json() : Promise.reject()) + .then(({ accessKey }: { accessKey: string | null }) => { + if(!accessKey) { + setAccessKeyError('アクセスキーがありません') + setIsLoading(false) + return + } + setAccessKey(accessKey) + axios.get<{users: User[], hash: null | string}>('/api/mobile/' + accessKey) + .then(({data:{users}}) => { + setUsers(users) + if(users.length > 0) { + const userId = users[0].id + setUserId(userId) + } + setIsLoading(false) + }) + .catch(err => { + setUsers([]) + console.error(err) + if (axios.isAxiosError(err)) { + const serverError = err as AxiosError<{error: string}> + if (serverError && serverError.response) setAccessKeyError(serverError.response.data.error) + else setAccessKeyError('接続に失敗しました') + } else setAccessKeyError('不明なエラーが発生しました') + setIsLoading(false) + }) + }) + .catch(() => { + setAccessKeyError('アクセスキーの取得に失敗しました') + setIsLoading(false) + }) + }, []) + + const notification = useMemo(()=>{ + const dataJSON = searchParams.get('data') + if(!dataJSON) return null + const {createdAt, ...theOthers} = JSON.parse(dataJSON) + return {...theOthers, createdAt: new Date(createdAt).getTime()/1000 } as Notification + }, [searchParams]) + + + const message = useMemo(()=>{ + switch(radioValue){ + case 'time': return `${selectedTime}ほどお待ちください` + case 'custom': return customMessage + case 'template': return templateMessage + default: return '' + } + }, [radioValue, selectedTime, customMessage, templateMessage]) + + useEffect(()=>{ + if(!notification) return + console.log('notification', notification) + + // タイムアウトの設定 + const { createdAt } = notification + timeout.current -= Date.now()/1000 - createdAt + let last = performance.now() + const draw = () => { + const now = performance.now() + const diff = (now-last)/1000 + last = now + if(timeout.current > 0){ + timeout.current -= diff + setCount(timeout.current) + reqIdRef.current = requestAnimationFrame(draw) + } else { + // タイムアウトした場合 + setCount(0) + setIsAccept(false) + setRadioValue('default') + setSubmitResult('timeout') + } + } + draw() + + return () => { + console.log("Countdownキャンセル") + cancelAnimationFrame(reqIdRef.current) + } + }, [notification]) + + const handleSelect = (value: string) => setSelectedTime(value) + + const handleSubmit = (isAccept: boolean) => { + if(!notification) return + setIsAccept(isAccept) + setSubmitResult('pending') + if(!accessKey) { + window.alert('アクセスキーが無いため失敗しました') + return + } + const { callId } = notification + const payload: ActionReply = {callId, userId, result: isAccept ? 'accept':'reject', optionMessage: message} + axios.post(`/api/mobile/${accessKey}/actions/reply`, payload) + .then(()=>{ + setSubmitResult('ok') + const responder = users.find(user => user.id == userId) + if(responder) updateResponder(callId, `[${responder.group}]${responder.name}`) + }) + .catch(err => { + setSubmitResult('error') + console.error('送信失敗しました', err) + }) + console.log('res') + } + + const disabled = isLoading || isAccept != null + + const cancel = () => { + if(notification && latestCall && !('responder' in latestCall) ) { + const { callId } = notification + updateResponder(callId, null) + } + router.replace('/') + } + + return (<> + + + + + + + + 「{notification?.visitor == ''?'(未入力)':notification?.visitor}」様 + + + 訪問先: [{notification?.dst.group}] {notification?.dst.name} + + + {submitResult === '' && } + {submitResult === 'pending' && } + + + setRadioValue(e.target.value)}> + } label="しばらくお待ち下さい (デフォルト)" disabled={disabled} /> + } disabled={disabled} label={<> + setRadioValue('time')}> + + + + + +
+ ほどお待ちください + } /> + } disabled={disabled} label={ + setCustomMessage(e.target.value)} onFocus={()=>setRadioValue('custom')} label="カスタムメッセージ" name="message" disabled={disabled} /> + } /> + {messages.length != 0 && } disabled={disabled} label={ + messages.length == 1 ? messages[0] + : + } />} +
+ + {users.length == 1 && +
+
対応者名義
+
[{users[0].group}] {users[0].name}
+
+ } + {users.length > 1 && + <> + + 対応者の名義を選択してください + + } +
+ {(submitResult === '' || submitResult === 'pending') &&
制限時間内に選択してください
} + {submitResult === 'ok' &&
送信成功しました
} + {submitResult === 'error' &&
送信失敗しました
} + {submitResult === 'timeout' &&
有効期限が過ぎました
} + {accessKeyError &&
{accessKeyError}
} + {isLoading &&
読み込み中…
} + {!isLoading &&
+ isAccept === null && handleSubmit(true)} className={isAccept != null?isAccept == true?'accept selected':'accept disabled':'accept enable'}>対応可能 + isAccept === null && handleSubmit(false)} className={isAccept != null?isAccept == false?'reject selected':'reject disabled':'reject enable'}>対応不可 +
} +
+
+
+
+ ) +} +/* +代わりに「◯◯◯」が対応します + + 代理対応者に連絡事項を伝えられます +*/ + +function CircularProgressWithLabel( + props: CircularProgressProps & { value: number }, + ) { + return ( + + + 10 ? '#1976d2':'#d32f2f'}} variant="determinate" size="10vw" value={Math.floor(props.value/TIMEOUT*100)} /> + + 10 ? '#1976d2':'#d32f2f'}} + > + {Math.floor(props.value)} + + + + + ) } \ No newline at end of file diff --git a/node/src/app/manifest.ts b/node/src/app/manifest.ts new file mode 100755 index 0000000..d12ce08 --- /dev/null +++ b/node/src/app/manifest.ts @@ -0,0 +1,28 @@ +import { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app' + + return { + id: '/', + theme_color: '#f69435', + background_color: '#1a2484', + display: 'standalone', + scope: '/', + start_url: '/', + name: 'らいきゃくん通知', + short_name: 'らいきゃくん通知', + description: 'モバイルデバイスでらいきゃくんの通知を受け取ることが出来ます', + icons: [ + { src: '/images/maskable_icon_x128.png', sizes: '128x128', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x144.png', sizes: '144x144', type: 'image/png', purpose: 'any' }, + { src: '/images/maskable_icon_x192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x384.png', sizes: '384x384', type: 'image/png', purpose: 'maskable' }, + { src: '/images/maskable_icon_x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], +related_applications: [ + { platform: 'webapp', url: `${baseUrl}/manifest.webmanifest` }, + ], + prefer_related_applications: false, + } +} diff --git a/node/src/app/page.module.css b/node/src/app/page.module.css old mode 100644 new mode 100755 index abd3a56..e50a7a7 --- a/node/src/app/page.module.css +++ b/node/src/app/page.module.css @@ -1,8 +1,9 @@ .main { display: flex; flex-direction: column; - justify-content: space-between; + justify-content: space-around; align-items: center; + gap: 1.5rem; /*padding: 2rem;*/ min-height: 80vh; } diff --git a/node/src/app/page.tsx b/node/src/app/page.tsx old mode 100644 new mode 100755 index e7d60cb..3c9a16a --- a/node/src/app/page.tsx +++ b/node/src/app/page.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import styles from './page.module.css' import { Box, Alert, Button, TextField, Typography, InputAdornment, IconButton, FormControl, InputLabel, OutlinedInput, FormHelperText, - Divider + Divider, Skeleton } from '@mui/material' import { ContentCopy as ContentCopyIcon } from '@mui/icons-material' import { getMobileOS, getBrowser } from '@/utils' @@ -11,17 +11,22 @@ import LockIcon from '@mui/icons-material/Lock'; import { useIndexedDB } from '@/hooks' import dynamic from 'next/dynamic' +import { QRCodeSVG } from 'qrcode.react' import { diffTimeCalc } from '@/utils/date' // ITPについい // https://webkit.org/tracking-prevention/#intelligent-tracking-prevention-itp -const URL = 'https://mobile.raikyakun.app/' +const URL = (process.env.NEXT_PUBLIC_BASE_URL ?? 'https://mobile.raikyakun.app') + '/' export default function Home() { const [os, setOS] = useState('Other') const [browser, setBrowser] = useState('Other') - const [available, setAvailable] = useState(false) + const [osDetected, setOsDetected] = useState(false) + + const [installPrompt, setInstallPrompt] = useState(null) + const [isInstalled, setIsInstalled] = useState(false) + const [isStandalone, setIsStandalone] = useState(false) const [now, setNow] = useState(new Date()) const [isLatestCallActive, setIsLatestCallActive] = useState(false) const { latestCall, callList, update } = useIndexedDB() @@ -32,7 +37,20 @@ const browser = getBrowser() setOS(os) setBrowser(browser) - setAvailable( (os == 'Android' && browser == 'Chrome') || (os == 'iOS' && browser == 'Safari')) + setOsDetected(true) + + setIsStandalone(window.matchMedia('(display-mode: standalone)').matches) + + navigator.getInstalledRelatedApps?.().then(apps => { + setIsInstalled(apps.length > 0) + }) + + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault() + setInstallPrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) }, []) useEffect(() => { @@ -78,10 +96,13 @@ }, [latestCall]) const copyToClipboard = async () => { - await global.navigator.clipboard.writeText(URL) + await global.navigator.clipboard.writeText(`${URL}${accessKey ? `#accessKey=${accessKey}` : ''}`) } - const { Subscribe, isSubscribed, errorMessage } = useWebpush() + const registerMode = isStandalone || (!installPrompt && !isInstalled) ? 'allowed' + : installPrompt ? 'install-required' + : 'hidden' + const { Subscribe, errorMessage, accessKey, isSubscribed, canSubscribe, isLoading } = useWebpush({ registerMode, os, browser, isStandalone }) @@ -90,6 +111,12 @@
らいきゃくん通知{os != 'Other' ? <>
for {os} : ''}
+ {(!osDetected || isLoading) && + + + + } + {osDetected && !isLoading && <> {latestCall && } -
+ {(os === 'Other' || (os === 'Android' && browser !== 'Chrome' && browser !== 'WebView')) &&
{os == 'Other' && このページを スマートフォンで開いてください} - {os == 'Android' && browser != 'Chrome' && このページはChromeで開いてください} - {os == 'iOS' && browser != 'Safari' && このページはSafariで開いてください} -
+ {os == 'Android' && browser != 'Chrome' && browser != 'WebView' && このページはChromeで開いてください} +
- {os == 'Other' && } - {!available && } + {os === 'Other' && URLをコピー }
-
- {os == 'iOS' && browser == 'Safari' && isSubscribed == false && } - -
-
- { Subscribe } +
} +
{ Subscribe }
+ {!isStandalone && isInstalled && + Webアプリとしてインストール済みです。ホーム画面のアイコンから起動してください。 + } + {!isStandalone && !isInstalled && installPrompt &&
+ +
} {errorMessage != '' && {errorMessage}} {errorMessage != '' && os == 'iOS' && browser == 'Safari' && 通知許可ダイアログで「許可しない」を選択してしまった場合は
@@ -185,7 +217,17 @@ {diffTimeCalc(now, new Date(call.createdAt))} )} - + + {os === 'iOS' && browser === 'Safari' && !isStandalone && } + + {os === 'Android' && (isStandalone || isInstalled) && + 通知が届かなくなった場合
+ Androidの「使用していないアプリを管理する」機能により、しばらく起動しないと通知権限が自動で削除されることがあります。
+ 「設定」>「アプリ」>「らいきゃくん通知」から「使用していないアプリを管理する」をオフにすることをおすすめします。 +
} + + } + ) } diff --git a/node/src/app/useWebpush.tsx b/node/src/app/useWebpush.tsx index a3b4f43..540d330 100755 --- a/node/src/app/useWebpush.tsx +++ b/node/src/app/useWebpush.tsx @@ -9,7 +9,7 @@ import { VerticalTabs, CustomMessageEditor } from '@/components' import { User } from '@/types' import jsSHA from "jssha" - +import { checkNotificationStatus, requestNotificationPermission } from '@/utils/ios' function generateSHA256Hash(data: string): string { const shaObj = new jsSHA("SHA-256", "TEXT"); @@ -17,7 +17,7 @@ return shaObj.getHash("HEX"); } -export default function useWebpush(){ +export default function useWebpush({ registerMode = 'allowed', os = 'Other', browser = 'Other', isStandalone = false }: { registerMode?: 'allowed' | 'install-required' | 'hidden', os?: string, browser?: string, isStandalone?: boolean } = {}){ const [available, setAvailable] = useState(false) const [isSubscribed, setIsSubscribed] = useState(null) const [errorMessage, setErrorMessage] = useState('') @@ -27,21 +27,26 @@ const [users, setUsers] = useState([]) const [localHash, setLocalHash] = useState(null) const [remoteHash, setRemoteHash] = useState(null) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [pushSupported, setPushSupported] = useState(null) + const [state, setState] = useState<{status: string, token: string | null}>({ status: 'unknown', token: null }) // アクセスキーが入力されたらサーバへ問い合わせる const checkAccessKey = useCallback((newAccessKey: string) => { setAccessKey(newAccessKey) setIsLoading(true) setAccessKeyError(null) - localStorage.setItem('AccessKey', newAccessKey) return axios.get<{users: User[], hash: null | string}>('/api/mobile/'+newAccessKey) .then(({data}) => { setUsers(data.users) localStorage.setItem('Users', JSON.stringify(data.users)) setIsLoading(false) - console.log('setRemoteHash', data.hash) setRemoteHash(data.hash) + fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: newAccessKey }), + }) return data.hash }) .catch(err => { @@ -59,104 +64,212 @@ // 初回読み込み時 useEffect(() => { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready - .then((registration) => { - // Service Workerの更新をチェック - registration.update(); - console.log('registration', registration) - if(!registration.pushManager) { - console.log('!registration.pushManager') - setIsSubscribed(false) + const saveKey = (key: string) => fetch('/accessKey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessKey: key }), + }) + + const init = async () => { + // 1. 現在のCookieキーを取得 + let cookieKey: string | null = null + try { + const res = await fetch('/accessKey') + if (res.ok) { + const data: { accessKey: string | null } = await res.json() + cookieKey = data.accessKey + } + } catch (e) { + console.error('GET /accessKey failed', e) + } + + // 2. URLハッシュのキーを取得し、即座に消去 + const hashMatch = window.location.hash.match(/[#&]accessKey=([^&]+)/) + const hashKey = hashMatch ? decodeURIComponent(hashMatch[1]) : null + if (hashKey) history.replaceState(null, '', location.pathname + location.search) + + // 3. localStorageからCookieへ移行(CookieもURLハッシュもない場合) + let migratedKey: string | null = null + if (!cookieKey && !hashKey) { + const lsKey = localStorage.getItem('AccessKey') + if (lsKey && lsKey.length === 20) { + await saveKey(lsKey) + localStorage.removeItem('AccessKey') + migratedKey = lsKey + } + } + + // WebViewの場合 + if (!('serviceWorker' in navigator)) { + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey && hashKey !== cookieKey) await saveKey(hashKey) + if (resolvedKey) setAccessKey(resolvedKey) + setIsLoading(true) + checkNotificationStatus() + .then(res => { + setState(res) + if ((res.status === 'authorized' && !res.token) || res.status !== 'denied') { + setIsSubscribed(false) + } + setIsLoading(false) + }) + .catch(e => { + console.error(e) + setErrorMessage('ご利用できない環境です') + setIsLoading(false) + }) + return + } + + // 4. SW初期化 + let registration: ServiceWorkerRegistration + try { + registration = await navigator.serviceWorker.ready + } catch (err) { + setErrorMessage(String(err)) + return + } + registration.update() + + if (!registration.pushManager) { + setIsSubscribed(false) + setPushSupported(false) + const resolvedKey = hashKey ?? cookieKey ?? migratedKey + if (hashKey) await saveKey(hashKey) + if (resolvedKey) { + setAccessKey(resolvedKey) + checkAccessKey(resolvedKey) + } else { + setIsLoading(false) + } + return + } + setPushSupported(true) + + let subscription: PushSubscription | null + try { + subscription = await registration.pushManager.getSubscription() + } catch (err) { + console.error('サブスクリプション取得エラー', err) + return + } + + // 5. キー変更 + サブスク登録済み → ユーザーに確認 + const isKeyChange = hashKey !== null && hashKey !== cookieKey + let keyChangeCancelled = false + if (isKeyChange && subscription && cookieKey) { + if (confirm('現在別のアクセスキーで登録中です。登録解除してアクセスキーを変更しますか?')) { + // 確認: フル解除 → 新キーで継続 + try { + await unsubscribe({ isLocalOnly: false, accessKey: cookieKey }) + } catch { + setErrorMessage('登録解除に失敗しました。先に登録解除ボタンから解除してください。') + return + } + await saveKey(hashKey) + setAccessKey(hashKey) + setUsers([]) + checkAccessKey(hashKey) return } - console.log('registration.pushManager', registration.pushManager) - return registration.pushManager.getSubscription() - .then(subscription => { - const accessKey = localStorage.getItem('AccessKey') - console.log('accessKey', accessKey) - if (!subscription) { - setAvailable(true); - setIsSubscribed(false); - } - // 以下、アクセスキーが登録されている場合 - if(accessKey && accessKey.length === 20) { - if (!subscription) { - console.log('OK') - checkAccessKey(accessKey) - } else { - console.log('NG') - setAvailable(false) - setIsSubscribed(true) - const localHash = generateSHA256Hash(subscription.endpoint) - setLocalHash(localHash) - checkAccessKey(accessKey) - .then(remoteHash => { - console.log('localHash', JSON.stringify(subscription)) - console.log('登録チェック', localHash, remoteHash) - if(localHash != null && localHash != remoteHash){ - console.log('プッシュ通知登録解除', accessKey) - // ハッシュを比較して既に登録されている場合は確認ダイアログを表示する - unsubscribe({isLocalOnly: true, accessKey}) - .then(()=>{ - alert('別の端末で新しく登録されたため登録解除しました') - }) - .catch(()=>{ - alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました') - }) - } - }) + // キャンセル: 旧キーのまま継続(hashKeyは消去済み、CookieはcookieKeyのまま) + keyChangeCancelled = true + } + + // 6. キーの確定 + let resolvedKey: string | null + if (keyChangeCancelled) { + resolvedKey = cookieKey + } else if (hashKey) { + // キー変更でサブスクなし、または同一キーの再アクセス + await saveKey(hashKey) + resolvedKey = hashKey + } else { + resolvedKey = cookieKey ?? migratedKey + } + + if (resolvedKey) setAccessKey(resolvedKey) + + if (!subscription) { + setAvailable(true) + setIsSubscribed(false) + if (resolvedKey && resolvedKey.length === 20) checkAccessKey(resolvedKey) + else setIsLoading(false) + } else { + setAvailable(false) + setIsSubscribed(true) + if (resolvedKey && resolvedKey.length === 20) { + const localHash = generateSHA256Hash(subscription.endpoint) + setLocalHash(localHash) + checkAccessKey(resolvedKey).then(remoteHash => { + if (localHash !== null && localHash !== remoteHash) { + // ハッシュを比較して別端末で登録済みの場合はローカルのみ解除 + unsubscribe({ isLocalOnly: true, accessKey: resolvedKey! }) + .then(() => alert('別の端末で新しく登録されたため登録解除しました')) + .catch(() => alert('別の端末で新しく登録されたため登録解除しようとしましたが失敗しました')) } - } - }) - .catch(err => { - console.log('サブスクリプション取得エラー', err) - }) - }) - .catch(err => setErrorMessage(err.toString())); - - navigator.serviceWorker.addEventListener("activate", function (event) { - console.log("service worker activated"); - }); - } else { - setErrorMessage('ご利用できない環境です'); + }) + } else { + setIsLoading(false) + } + } } - - }, []); + + const handleHashChange = () => { + if (window.location.hash.match(/[#&]accessKey=([^&]+)/)) init() + } + window.addEventListener('hashchange', handleHashChange) + init() + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const subscribe = useCallback(() => { - console.log('remoteHash', remoteHash) - navigator.serviceWorker.ready - .then(registration => { - registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), - }) - .then(async(subscription) => { - console.log('localHash2', JSON.stringify(subscription)) - const latestRemoteHash = await checkAccessKey(accessKey) - if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return - setIsSubscribed(true) - - axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { - const { id, isStealth } = item - if(id) acc[id] = isStealth - return acc - }, {} as {[key: string]: boolean})}) - .catch((err: AxiosError<{error: string}>) => { - // エラーの処理 - if (err.response?.data?.error) { - // サーバーからの応答がある場合 - setErrorMessage(err.response.data.error) - } else { - // リクエストの設定中に何かが起こった場合 - setErrorMessage('エラーが発生しました:' + err.message) - } + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(registration => { + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }) + .then(async(subscription) => { + const latestRemoteHash = await checkAccessKey(accessKey) + if(latestRemoteHash && generateSHA256Hash(JSON.stringify(subscription)) != latestRemoteHash && !confirm('既に別の端末で登録されています。上書き登録しますか?')) return + setIsSubscribed(true) + + axios.patch('/api/mobile/'+accessKey, {subscription, users: users.reduce((acc, item) => { + const { id, isStealth } = item + if(id) acc[id] = isStealth + return acc + }, {} as {[key: string]: boolean})}) + .catch((err: AxiosError<{error: string}>) => { + // エラーの処理 + if (err.response?.data?.error) { + // サーバーからの応答がある場合 + setErrorMessage(err.response.data.error) + } else { + // リクエストの設定中に何かが起こった場合 + setErrorMessage('エラーが発生しました:' + err.message) + } + }) + }) + .catch(err => setErrorMessage(err.toString())) }) .catch(err => setErrorMessage(err.toString())) - }) - .catch(err => setErrorMessage(err.toString())) + } else { + // WebViewの場合 + setIsLoading(true) + requestNotificationPermission() + .then(res => { + setState(res) + setIsLoading(false) + }) + .catch((e) => { + console.error(e) + setErrorMessage('ご利用できない環境です') + }) + } }, [accessKey, users, remoteHash]) const unsubscribe = useCallback(async (opts: {isLocalOnly: boolean, accessKey: string}) => { @@ -182,13 +295,11 @@ }) setRemoteHash(null) await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) return true } else { // ローカル情報のみ削除 await subscription.unsubscribe() - console.log('サブスクリプションを解除しました') setIsSubscribed(false) } } catch(err){ @@ -200,7 +311,6 @@ throw err } } else { - console.error('サブスクリプションを取得出来ませんでした') throw new Error('サブスクリプションを取得出来ませんでした') } return false @@ -222,20 +332,31 @@ } } } - console.log('isSubscribed === null || isLoading || users.length < 1', isSubscribed , isLoading , users.length ) - + // ボタンを押した時 + const handleClick = () => { + requestNotificationPermission() + .then((res) => { + setState(res) + // status が requested → 直後に OS がトークンを返すと + // ScriptBridge が onNotificationUpdate() を再送してくるので + // その都度 setState が呼ばれる + }) + .catch((e) => console.error(e)); + } + + const canSubscribe = isSubscribed === false && !isLoading && users.length >= 1 && pushSupported !== false const Subscribe = ( <> { <> -
- +
+ アクセスキー setTooltipMessagep(null)}> + startAdornment={os === 'Other' || isStandalone + ? setTooltipMessagep(null)}> setTooltipMessagep(null)} open={!!tooltipMessage} @@ -248,38 +369,48 @@ + : undefined } - endAdornment={ - - {setAccessKey('');setAccessKeyError(null);setUsers([]);localStorage.removeItem('AccessKey')}} disabled={!!isSubscribed || isLoading}> - + endAdornment={os === 'Other' || isStandalone + ? + {setAccessKey('');setAccessKeyError(null);setUsers([]);fetch('/accessKey', {method:'DELETE'})}} disabled={!!isSubscribed || isLoading}> + + : undefined } label="アクセスキー" placeholder="(タップしてペースト)" onPaste={e => !isLoading && !isSubscribed && checkAccessKey(e.clipboardData.getData('text'))} readOnly disabled={isLoading} + inputProps={{ size: 20 }} sx={{fontFamily: 'monospace'}} /> {isLoading && }
- {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} - {accessKeyError && {accessKeyError}} - - + {!isSubscribed ? 'アクセスコードは管理者により発行されます' : '変更する場合はプッシュ通知登録解除してください'} + {accessKeyError && {accessKeyError}} + {(os === 'Other' || (isStandalone && (canSubscribe || isSubscribed === true))) && } + {(os === 'Other' ? isSubscribed === true : isStandalone && (canSubscribe || isSubscribed === true)) && } } {!isSubscribed &&
- + {pushSupported === false && os === 'iOS' && browser !== 'Safari' && <> + プッシュ通知にはSafariで開く必要があります + + } + {pushSupported === false && os !== 'iOS' && このブラウザはプッシュ通知に対応していません。AndroidはChromeでお開きください。} + {registerMode === 'allowed' && os === 'iOS' && browser === 'Safari' && !isStandalone && プッシュ通知にはホーム画面への追加が必要です。画面下部の共有ボタン(□↑)から「ホーム画面に追加」を選んでください} + {registerMode === 'allowed' && !(os === 'iOS' && browser === 'Safari' && !isStandalone) && pushSupported !== false && } + {registerMode === 'install-required' && プッシュ通知を受け取るにはWebアプリとしてインストールしてください}
} - {isSubscribed &&
+ {isSubscribed && registerMode !== 'hidden' &&
} ) - return { Subscribe, isSubscribed, errorMessage } + return { Subscribe, isSubscribed, errorMessage, accessKey, canSubscribe, isLoading } } function urlBase64ToUint8Array(base64String: string) { diff --git a/node/src/components/CustomMessageEditor.tsx b/node/src/components/CustomMessageEditor.tsx index ddf581a..83b36f3 100755 --- a/node/src/components/CustomMessageEditor.tsx +++ b/node/src/components/CustomMessageEditor.tsx @@ -90,7 +90,7 @@ return ( <> - + setIsOpen(false)}> diff --git a/node/src/components/ThemeRegistry/theme.ts b/node/src/components/ThemeRegistry/theme.ts index 445f8f9..566a51e 100755 --- a/node/src/components/ThemeRegistry/theme.ts +++ b/node/src/components/ThemeRegistry/theme.ts @@ -15,6 +15,13 @@ fontFamily: roboto.style.fontFamily, }, components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, + }, MuiAlert: { styleOverrides: { root: ({ ownerState }) => ({ diff --git a/node/src/hooks/index.ts b/node/src/hooks/index.ts index 3fd2095..281dae5 100755 --- a/node/src/hooks/index.ts +++ b/node/src/hooks/index.ts @@ -1 +1,2 @@ -export { useIndexedDB } from './useIndexedDB' \ No newline at end of file +export { useIndexedDB } from './useIndexedDB' +export { useWebRTC } from './useWebRTC' \ No newline at end of file diff --git a/node/src/hooks/useWebRTC.tsx b/node/src/hooks/useWebRTC.tsx new file mode 100755 index 0000000..ba8a142 --- /dev/null +++ b/node/src/hooks/useWebRTC.tsx @@ -0,0 +1,105 @@ +// useWebRTC.ts +import { useState, useEffect, useRef, useCallback } from 'react' + +interface useWebRTCReturn { + pc: RTCPeerConnection | null + localStream: MediaStream | null + ws: WebSocket | null + setURL: (wsUrl: string) => () => void +} + +export function useWebRTC(): useWebRTCReturn { + const [pc, setPc] = useState(null) + const [localStream, setLocalStream] = useState(null) + const [ws, setWs] = useState(null) + const iceServers: RTCIceServer[] = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:19302" }, + { urls: "stun:stun3.l.google.com:19302" }, + { urls: "stun:stun4.l.google.com:19302" } + ] + // ICE候補を格納する配列(シグナリング送信用) + const iceCandidatesRef = useRef([]) + + // WebSocket初期化 + const setURL = useCallback((wsUrl: string) => { + const socket = new WebSocket(wsUrl) + socket.onopen = () => { + console.log("WebSocket connected") + // WebSocket接続後にWebRTCの初期化を開始 + initWebRTC(socket) + } + socket.onmessage = (event: MessageEvent) => { + console.log("WebSocket message received:", event.data) + // ここでリモートからのシグナリング(アンサーやICE候補)を処理する + } + socket.onerror = (err) => { + console.error("WebSocket error:", err) + } + socket.onclose = () => { + console.log("WebSocket closed") + } + setWs(socket) + // クリーンアップ + return () => { + socket.close() + pc?.close() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // WebRTC初期化関数 + const initWebRTC = useCallback((socket: WebSocket) => { + // ユーザーに映像・音声の許可をリクエスト + navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + .then(stream => { + setLocalStream(stream) + const connection = new RTCPeerConnection({ iceServers }) + // ローカルストリームの各トラックを追加 + stream.getTracks().forEach(track => connection.addTrack(track, stream)) + // ダミーデータチャネルを作成してICE候補収集を促進 + connection.createDataChannel("dummy") + // ICE候補イベントのハンドラ + connection.onicecandidate = (event) => { + console.log("ICE candidate event:", event) + if (event.candidate) { + iceCandidatesRef.current.push(event.candidate.toJSON()) + // 候補が見つかるたびに送信する(例:ここで個別送信) + socket.send(JSON.stringify({ + type: "ice", + candidate: event.candidate.toJSON() + })) + } else { + // ICE候補の収集が完了(candidateがnull) + const signalingMessage = { + type: "offer", + sdp: connection.localDescription ? connection.localDescription.sdp : null, + iceCandidates: iceCandidatesRef.current + } + console.log("Signaling JSON message:", JSON.stringify(signalingMessage, null, 2)) + socket.send(JSON.stringify(signalingMessage)) + } + } + connection.onicegatheringstatechange = () => { + console.log("ICE gathering state:", connection.iceGatheringState) + } + // SDPオファー生成とローカル記述の設定 + connection.createOffer() + .then(offer => { + console.log("Created SDP offer:", offer) + return connection.setLocalDescription(offer) + }) + .then(() => { + // setLocalDescription()実行後、ICE候補収集が開始される + setPc(connection) + }) + .catch(err => console.error("Error during SDP offer creation:", err)) + }) + .catch(err => { + console.error("Error getting user media:", err) + }) + }, [iceServers]) + + return { pc, localStream, ws, setURL } +} diff --git a/node/src/types/globals.d.ts b/node/src/types/globals.d.ts new file mode 100755 index 0000000..ca54560 --- /dev/null +++ b/node/src/types/globals.d.ts @@ -0,0 +1,34 @@ +export {} + +declare global { + interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> + } + + interface RelatedApplication { + platform: string + url?: string + id?: string + } + + interface Navigator { + getInstalledRelatedApps?(): Promise + } + + interface Window { + webkit?: { + messageHandlers?: Record< + string, + { postMessage: (payload: any) => void } + > + } + __nativeCallback?: (res: NotificationResult) => void + } + + interface NotificationResult { + status: 'authorized' | 'notDetermined' | 'denied' | 'requested' | 'unknown' + token: string | null + id: string | null + } +} diff --git a/node/src/utils/index.ts b/node/src/utils/index.ts index 54794c3..cf37f87 100644 --- a/node/src/utils/index.ts +++ b/node/src/utils/index.ts @@ -12,21 +12,39 @@ return "Other" } -export const getBrowser = () => { - if (typeof window === "undefined") return "Other" +export const getBrowser = (): string => { + if (typeof window === 'undefined') return 'Other' + + // 1) iOS WebView判定: window.webkit.messageHandlers.getDevicePushToken があれば true + // 2) Android WebView判定: window.AndroidNative.getDevicePushToken があれば true + if ( + ( + (window as any).webkit + && (window as any).webkit.messageHandlers + && (window as any).webkit.messageHandlers.getDevicePushToken + ) + || ( + (window as any).AndroidNative + && (window as any).AndroidNative.getDevicePushToken + ) + ) { + return 'WebView' + } + const agent = window.navigator.userAgent.toLowerCase() - if (agent.indexOf("msie") != -1 || agent.indexOf("trident") != -1) { + if (agent.indexOf('msie') !== -1 || agent.indexOf('trident') !== -1) { return 'Internet Explorer' - } else if (agent.indexOf("edg") != -1 || agent.indexOf("edge") != -1) { + } else if (agent.indexOf('edg') !== -1 || agent.indexOf('edge') !== -1) { return 'Edge' - } else if (agent.indexOf("opr") != -1 || agent.indexOf("opera") != -1) { + } else if (agent.indexOf('opr') !== -1 || agent.indexOf('opera') !== -1) { return 'Opera' - } else if (agent.indexOf("chrome") != -1 || agent.indexOf("crios") != -1) { // iOSのChromeは 'crios' + } else if (agent.indexOf('chrome') !== -1 || agent.indexOf('crios') !== -1) { + // iOS版Chromeは 'crios' を含む return 'Chrome' - } else if (agent.indexOf("safari") != -1) { + } else if (agent.indexOf('safari') !== -1) { return 'Safari' - } else if (agent.indexOf("firefox") != -1) { + } else if (agent.indexOf('firefox') !== -1) { return 'FireFox' } else { return 'Other' diff --git a/node/src/utils/ios.ts b/node/src/utils/ios.ts new file mode 100644 index 0000000..40495fb --- /dev/null +++ b/node/src/utils/ios.ts @@ -0,0 +1,37 @@ +// ネイティブ呼び出しを Promise でラップ +let resolverMap = new Map void>() + +function callNative(handlerName: string): Promise { + return new Promise((resolve, reject) => { + if ( + typeof window === 'undefined' || + !window.webkit?.messageHandlers?.[handlerName] + ) { + return reject(new Error('native handler not found')) + } + + // 一意 ID を生成して resolver に登録 + const id = Date.now().toString(36) + Math.random().toString(36).slice(2) + resolverMap.set(id, resolve) + + // コールバック受信口を一度だけ定義 + if (!window.__nativeCallback) { + window.__nativeCallback = (res) => { + const { id } = res + if (id && resolverMap.has(id)) { + resolverMap.get(id)!(res) + resolverMap.delete(id) + } + } + } + + // ネイティブへ送信 + window.webkit!.messageHandlers[handlerName].postMessage(id) + }) +} + +export const checkNotificationStatus = () => + callNative('checkNotificationStatus') + +export const requestNotificationPermission = () => + callNative('requestNotificationPermission') diff --git a/public.tar.gz b/public.tar.gz index 96256e2..dbd0b8a 100644 --- a/public.tar.gz +++ b/public.tar.gz Binary files differ diff --git a/standalone.tar.gz b/standalone.tar.gz index 4676e74..096ad6f 100644 --- a/standalone.tar.gz +++ b/standalone.tar.gz Binary files differ diff --git a/static.tar.gz b/static.tar.gz index 24cf266..435eaa9 100644 --- a/static.tar.gz +++ b/static.tar.gz Binary files differ