diff --git a/package.json b/package.json index 89e6b05..ad92786 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,13 @@ "@google-analytics/data": "^7.2.0", "@tanstack/react-query": "^5.101.4", "@uiw/react-codemirror": "^4.25.12", - "axios": "^1.19.0", + "axios": "^1.20.0", "babel-plugin-react-compiler": "^1.0.0", "clsx": "^2.1.1", "date-fns": "^4.4.0", "framer-motion": "^12.43.0", "jwt-decode": "^4.0.0", - "next": "^15.5.25", + "next": "^15.5.28", "react": "^19.2.8", "react-dom": "^19.2.8", "react-hook-form": "^7.85.0", @@ -68,7 +68,9 @@ "pnpm": { "overrides": { "postcss": "$postcss", - "next>sharp": "^0.35.3" + "next>sharp": "^0.35.5", + "ts-jest>handlebars": "^4.7.10", + "source-map-js": "^1.2.2" } }, "lint-staged": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd0460a..dfd9ae4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,9 @@ settings: overrides: postcss: ^8.5.26 - next>sharp: ^0.35.3 + next>sharp: ^0.35.5 + ts-jest>handlebars: ^4.7.10 + source-map-js: ^1.2.2 importers: @@ -37,8 +39,8 @@ importers: specifier: ^4.25.12 version: 4.25.12(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.2)(@codemirror/state@6.7.6)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.13)(codemirror@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) axios: - specifier: ^1.19.0 - version: 1.19.0 + specifier: ^1.20.0 + version: 1.20.0 babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -55,8 +57,8 @@ importers: specifier: ^4.0.0 version: 4.0.0 next: - specifier: ^15.5.25 - version: 15.5.25(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@20.19.43)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^15.5.28 + version: 15.5.28(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@20.19.43)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: ^19.2.8 version: 19.2.8 @@ -985,160 +987,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1302,57 +1304,57 @@ packages: '@marijn/find-cluster-break@1.0.4': resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} - '@next/env@15.5.25': - resolution: {integrity: sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==} + '@next/env@15.5.28': + resolution: {integrity: sha512-xxW5EBI8fMPuVTPBW7LL980IRbuDXlbkUAwp9DDhtb4Lnich3nJ13uYpvrX4VE74hWPf6AvHHAMmGCidcHjbfg==} - '@next/swc-darwin-arm64@15.5.25': - resolution: {integrity: sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==} + '@next/swc-darwin-arm64@15.5.28': + resolution: {integrity: sha512-dPoQYXcfSIqx5o+TOXVXO7kONK5qbtNRDeiTfII0bT3puGIT6VF5e0QwmxNZfEZ5Zety5f3m9P/p4xqwfiLlPg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.25': - resolution: {integrity: sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==} + '@next/swc-darwin-x64@15.5.28': + resolution: {integrity: sha512-RgCJ7buBivTH+6sddqaVm+/v8kwMHlFkobvdt3DfETutws5HrxnToMIWSN9X3MZ3aAi7BbKrGZZWUDQLEycXqw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.25': - resolution: {integrity: sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==} + '@next/swc-linux-arm64-gnu@15.5.28': + resolution: {integrity: sha512-OHXQe13olE59aKe++zHavGcJPh5SLHXi6Anret3BBeq1+qhSuOJDNAli/EDDfxs6UTqA4Qddo4uyYry2slyjiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.25': - resolution: {integrity: sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==} + '@next/swc-linux-arm64-musl@15.5.28': + resolution: {integrity: sha512-nTB7bB6vfn4ezCJNYP43ukt2o8L3+WxKF/sTcTxepdXsJh3SM9RhlU7wrZYbH7L4HO5M87dqDTwm8587vAkCiw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@15.5.25': - resolution: {integrity: sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==} + '@next/swc-linux-x64-gnu@15.5.28': + resolution: {integrity: sha512-WXCfVI32rW3xCusoUX1RpZKtyChWU2Zwe27JUDmM3KrXwcDNc9vV4zEq0GKFAURnuQ/Ouo/XqPvB0yh445XZCg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@15.5.25': - resolution: {integrity: sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==} + '@next/swc-linux-x64-musl@15.5.28': + resolution: {integrity: sha512-Miq+NDb/HWsXuxseYxMM6fuEOQXgTziarkKIRpVKCn8W69cEfhQaQr+90pO+u4q2CbJR2wzXmUuCFyZaJs/Nng==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.25': - resolution: {integrity: sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==} + '@next/swc-win32-arm64-msvc@15.5.28': + resolution: {integrity: sha512-5LR79lemgDlgxvG7MQJ+zHCkVcrUoQuO7CiIvq3I58vNW4goSOE2b+19Z6FujRWMouvni4gvudYFuFuNxolGWA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.25': - resolution: {integrity: sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==} + '@next/swc-win32-x64-msvc@15.5.28': + resolution: {integrity: sha512-GNipvXUYROSTf4PXEyH95FrWImIB5zdaK6uEOg0RP1/X2drnvoMwiBkJ0VWdzX7w3iiBm3y5g2TxkKDGVTOQcg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1695,8 +1697,8 @@ packages: peerDependencies: postcss: ^8.5.26 - axios@1.19.0: - resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -2343,8 +2345,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + handlebars@4.7.10: + resolution: {integrity: sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==} engines: {node: '>=0.4.7'} hasBin: true @@ -2895,8 +2897,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next@15.5.25: - resolution: {integrity: sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==} + next@15.5.28: + resolution: {integrity: sha512-JjhLBBY1PNnuwPtcZ7t8yxVeJVYvD2Jyp633J+gRCNbzSt7N0E+xqCf6uQiff3+3qmUkwcF/ksfSsK1xsZ76Tw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -3334,8 +3336,8 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -3376,8 +3378,8 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map-support@0.5.13: @@ -4828,108 +4830,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@istanbuljs/load-nyc-config@1.1.0': @@ -5210,30 +5212,30 @@ snapshots: '@marijn/find-cluster-break@1.0.4': {} - '@next/env@15.5.25': {} + '@next/env@15.5.28': {} - '@next/swc-darwin-arm64@15.5.25': + '@next/swc-darwin-arm64@15.5.28': optional: true - '@next/swc-darwin-x64@15.5.25': + '@next/swc-darwin-x64@15.5.28': optional: true - '@next/swc-linux-arm64-gnu@15.5.25': + '@next/swc-linux-arm64-gnu@15.5.28': optional: true - '@next/swc-linux-arm64-musl@15.5.25': + '@next/swc-linux-arm64-musl@15.5.28': optional: true - '@next/swc-linux-x64-gnu@15.5.25': + '@next/swc-linux-x64-gnu@15.5.28': optional: true - '@next/swc-linux-x64-musl@15.5.25': + '@next/swc-linux-x64-musl@15.5.28': optional: true - '@next/swc-win32-arm64-msvc@15.5.25': + '@next/swc-win32-arm64-msvc@15.5.28': optional: true - '@next/swc-win32-x64-msvc@15.5.25': + '@next/swc-win32-x64-msvc@15.5.28': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5598,7 +5600,7 @@ snapshots: postcss: 8.5.26 postcss-value-parser: 4.2.0 - axios@1.19.0: + axios@1.20.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 @@ -5908,12 +5910,12 @@ snapshots: css-tree@2.2.1: dependencies: mdn-data: 2.0.28 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-tree@2.3.1: dependencies: mdn-data: 2.0.30 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css-what@6.2.2: {} @@ -6279,7 +6281,7 @@ snapshots: graceful-fs@4.2.11: {} - handlebars@4.7.9: + handlebars@4.7.10: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -7034,9 +7036,9 @@ snapshots: neo-async@2.6.2: {} - next@15.5.25(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@20.19.43)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@15.5.28(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@20.19.43)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 15.5.25 + '@next/env': 15.5.28 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001810 postcss: 8.5.26 @@ -7044,18 +7046,18 @@ snapshots: react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.25 - '@next/swc-darwin-x64': 15.5.25 - '@next/swc-linux-arm64-gnu': 15.5.25 - '@next/swc-linux-arm64-musl': 15.5.25 - '@next/swc-linux-x64-gnu': 15.5.25 - '@next/swc-linux-x64-musl': 15.5.25 - '@next/swc-win32-arm64-msvc': 15.5.25 - '@next/swc-win32-x64-msvc': 15.5.25 + '@next/swc-darwin-arm64': 15.5.28 + '@next/swc-darwin-x64': 15.5.28 + '@next/swc-linux-arm64-gnu': 15.5.28 + '@next/swc-linux-arm64-musl': 15.5.28 + '@next/swc-linux-x64-gnu': 15.5.28 + '@next/swc-linux-x64-musl': 15.5.28 + '@next/swc-win32-arm64-msvc': 15.5.28 + '@next/swc-win32-x64-msvc': 15.5.28 '@opentelemetry/api': 1.9.1 '@playwright/test': 1.62.1 babel-plugin-react-compiler: 1.0.0 - sharp: 0.35.4(@types/node@20.19.43) + sharp: 0.35.5(@types/node@20.19.43) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -7201,7 +7203,7 @@ snapshots: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 prettier-plugin-tailwindcss@0.7.4(prettier@3.9.6): dependencies: @@ -7386,37 +7388,37 @@ snapshots: semver@7.8.5: {} - sharp@0.35.4(@types/node@20.19.43): + sharp@0.35.5(@types/node@20.19.43): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 20.19.43 optional: true @@ -7449,7 +7451,7 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map-support@0.5.13: dependencies: @@ -7647,7 +7649,7 @@ snapshots: dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 + handlebars: 4.7.10 jest: 29.7.0(@types/node@20.19.43) json5: 2.2.3 lodash.memoize: 4.1.2 diff --git a/src/api/fetch/admin/api/useGetPostStatistics.ts b/src/api/fetch/admin/api/useGetPostStatistics.ts new file mode 100644 index 0000000..464bac1 --- /dev/null +++ b/src/api/fetch/admin/api/useGetPostStatistics.ts @@ -0,0 +1,15 @@ +import { keepPreviousData } from "@tanstack/react-query"; +import useAppQuery from "@/api/_base/query/useAppQuery"; +import { GetPostStatisticsResponse, StatisticsPeriod } from "../types/StatisticsType"; + +export const useGetPostStatistics = ({ startDate, endDate }: StatisticsPeriod) => { + const params = new URLSearchParams({ startDate, endDate }); + + return useAppQuery( + "auth", + ["admin-post-statistics", startDate, endDate], + `/admin/posts/statistics?${params.toString()}`, + // 기간을 바꾸는 동안 이전 값을 유지해 카드가 비었다 채워지며 높이가 바뀌지 않게 한다. + { placeholderData: keepPreviousData } + ); +}; diff --git a/src/api/fetch/admin/api/useGetUserStatistics.ts b/src/api/fetch/admin/api/useGetUserStatistics.ts new file mode 100644 index 0000000..8d25104 --- /dev/null +++ b/src/api/fetch/admin/api/useGetUserStatistics.ts @@ -0,0 +1,15 @@ +import { keepPreviousData } from "@tanstack/react-query"; +import useAppQuery from "@/api/_base/query/useAppQuery"; +import { GetUserStatisticsResponse, StatisticsPeriod } from "../types/StatisticsType"; + +export const useGetUserStatistics = ({ startDate, endDate }: StatisticsPeriod) => { + const params = new URLSearchParams({ startDate, endDate }); + + return useAppQuery( + "auth", + ["admin-user-statistics", startDate, endDate], + `/admin/users/statistics?${params.toString()}`, + // 기간을 바꾸는 동안 이전 값을 유지해 카드가 비었다 채워지며 높이가 바뀌지 않게 한다. + { placeholderData: keepPreviousData } + ); +}; diff --git a/src/api/fetch/admin/index.ts b/src/api/fetch/admin/index.ts index 45df116..78daa93 100644 --- a/src/api/fetch/admin/index.ts +++ b/src/api/fetch/admin/index.ts @@ -30,3 +30,7 @@ export { useGetNoticeDraft } from "./api/useGetNoticeDraft"; export { usePostNoticeDraft } from "./api/usePostNoticeDraft"; export { usePutNoticeDraft } from "./api/usePutNoticeDraft"; export { useGetUserDetail } from "./api/useGetUserDetail"; +export * from "./types/StatisticsType"; + +export { useGetUserStatistics } from "./api/useGetUserStatistics"; +export { useGetPostStatistics } from "./api/useGetPostStatistics"; diff --git a/src/api/fetch/admin/types/StatisticsType.ts b/src/api/fetch/admin/types/StatisticsType.ts new file mode 100644 index 0000000..fc8d891 --- /dev/null +++ b/src/api/fetch/admin/types/StatisticsType.ts @@ -0,0 +1,29 @@ +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; + +/** 통계 조회 기간. 한국 시간 기준 `YYYY-MM-DD`이며 시작일과 종료일을 모두 포함한다. */ +export interface StatisticsPeriod { + startDate: string; + endDate: string; +} + +export interface UserStatistics { + /** 종료일 기준 전체 회원 수 */ + totalUserCount: number; + /** 기간 중 가입 수 */ + signupCount: number; + /** 기간 중 탈퇴 수 */ + withdrawalCount: number; +} + +export interface PostStatistics { + /** 종료일 기준 전체 게시글 수 */ + totalPostCount: number; + /** 기간 중 분실 게시글 최초 공개 수 */ + lostPostCount: number; + /** 기간 중 발견 게시글 최초 공개 수 */ + foundPostCount: number; +} + +export interface GetUserStatisticsResponse extends ApiBaseResponseType {} + +export interface GetPostStatisticsResponse extends ApiBaseResponseType {} diff --git a/src/app/(admin)/admin/_components/AdminNotice/AdminNotice.tsx b/src/app/(admin)/admin/_components/AdminNotice/AdminNotice.tsx index 9a991ef..d30d379 100644 --- a/src/app/(admin)/admin/_components/AdminNotice/AdminNotice.tsx +++ b/src/app/(admin)/admin/_components/AdminNotice/AdminNotice.tsx @@ -1,12 +1,18 @@ +import { cn } from "@/utils"; + interface AdminNoticeProps { title: string; description: string; + className?: string; } /** 외부 연동 설정이 없거나 조회에 실패했을 때 보여주는 안내입니다. 서비스 통계와 DB 조회 화면에서 씁니다. */ -const AdminNotice = ({ title, description }: AdminNoticeProps) => { +const AdminNotice = ({ title, description, className }: AdminNoticeProps) => { return ( -
+

{title}

{description}

diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/AnalyticsDashboard.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/AnalyticsDashboard.tsx index cae9af0..7d14ecf 100644 --- a/src/app/(admin)/admin/_components/AnalyticsDashboard/AnalyticsDashboard.tsx +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/AnalyticsDashboard.tsx @@ -4,11 +4,13 @@ import { fetchAnalyticsReport, hasAnalyticsConfig, } from "../../_utils/fetchAnalyticsReport/fetchAnalyticsReport"; +import { getAnalyticsPeriods } from "../../_utils/getAnalyticsPeriods/getAnalyticsPeriods"; import { getServiceUrl } from "@/utils"; import { AnalyticsRangeTabs, AnalyticsRankList, AnalyticsSummaryCards, + ServiceStatisticsCards, VisitorsBarChart, } from "./_internal"; import AdminNotice from "../AdminNotice/AdminNotice"; @@ -73,7 +75,40 @@ const AnalyticsCharts = ({ return ( <> - +
{ const range = toRange(rangeParam); - const rangeLabel = ANALYTICS_RANGES.find((item) => item.value === range)?.label ?? ""; - const previousLabel = `이전 ${rangeLabel}`; + const { previousLabel } = + ANALYTICS_RANGES.find((item) => item.value === range) ?? ANALYTICS_RANGES[0]; + const periods = getAnalyticsPeriods(new Date(), range); let content; if (!hasAnalyticsConfig()) { @@ -162,11 +198,35 @@ const AnalyticsDashboard = async ({ rangeParam, basePath }: AnalyticsDashboardPr

- 운영 앱의 Google Analytics 기준이며 10분마다 갱신돼요. 증감은 {previousLabel}과 비교한 + {periods.current.startDate} ~ {periods.current.endDate} (한국 시간). 증감은{" "} + {previousLabel}({periods.previous.startDate} ~ {periods.previous.endDate})과 비교한 값이에요.

- {content} + +
+
+

+ 회원과 게시글 +

+

+ 서비스 서버가 집계한 값이에요. +

+
+ +
+ +
+
+

+ 방문 +

+

+ 운영 앱의 Google Analytics 기준이며 10분마다 갱신돼요. +

+
+ {content} +
); }; diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRangeTabs/AnalyticsRangeTabs.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRangeTabs/AnalyticsRangeTabs.tsx index 5befeb8..59b6080 100644 --- a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRangeTabs/AnalyticsRangeTabs.tsx +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRangeTabs/AnalyticsRangeTabs.tsx @@ -28,7 +28,7 @@ const AnalyticsRangeTabs = ({ range, basePath }: AnalyticsRangeTabsProps) => { : "text-neutralInversed-normal-default bg-fill-neutralInversed-normal-default hover:text-black hover:bg-fill-neutralInversed-normal-hover" )} > - 최근 {item.label} + {item.label} ); })} diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsSummaryCards/AnalyticsSummaryCards.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsSummaryCards/AnalyticsSummaryCards.tsx index 5fda11f..24e447e 100644 --- a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsSummaryCards/AnalyticsSummaryCards.tsx +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsSummaryCards/AnalyticsSummaryCards.tsx @@ -1,26 +1,30 @@ import { cn } from "@/utils"; -import { AnalyticsSummary, AnalyticsSummaryValues } from "../../../../_types/AnalyticsReport"; import { getChangeRate } from "../../../../_utils/getChangeRate/getChangeRate"; +export interface SummaryCard { + key: string; + label: string; + help: string; + /** 아직 값을 받지 못했으면 `null`이고, 같은 높이의 스켈레톤으로 자리를 잡는다. */ + current: number | null; + /** 비교할 값을 받지 못했으면 `null`이고, 증감 자리에 비교할 값이 없다고 표시한다. */ + previous: number | null; +} + interface AnalyticsSummaryCardsProps { - summary: AnalyticsSummary; + /** 섹션의 접근 가능한 이름 */ + sectionLabel: string; + cards: SummaryCard[]; /** 비교 기간 라벨. 예: "이전 7일" */ previousLabel: string; + /** 새 기간 값을 받는 동안 이전 값을 흐리게 보여준다. */ + isStale?: boolean; } -const CARDS: { key: keyof AnalyticsSummaryValues; label: string; help: string }[] = [ - { key: "activeUsers", label: "방문자", help: "기간 중 한 번 이상 방문한 사용자" }, - { key: "newUsers", label: "신규 방문자", help: "처음 방문한 사용자" }, - { key: "sessions", label: "방문 횟수", help: "세션 수" }, - { key: "pageViews", label: "페이지뷰", help: "조회한 화면 수" }, - { key: "signUps", label: "가입", help: "GA 가입 완료 이벤트 수" }, - { key: "postCompletes", label: "게시글 작성", help: "GA 게시글 작성 완료 이벤트 수" }, -]; - const formatCount = (value: number) => value.toLocaleString("ko-KR"); -const ChangeBadge = ({ current, previous }: { current: number; previous: number }) => { - const rate = getChangeRate(current, previous); +const ChangeBadge = ({ current, previous }: { current: number; previous: number | null }) => { + const rate = previous === null ? null : getChangeRate(current, previous); if (rate === null) { return 비교할 값 없음; @@ -41,14 +45,23 @@ const ChangeBadge = ({ current, previous }: { current: number; previous: number }; /** 기간 합계 지표를 카드로 보여주고, 바로 앞 같은 길이 기간과 비교한 증감률을 함께 표시합니다. */ -const AnalyticsSummaryCards = ({ summary, previousLabel }: AnalyticsSummaryCardsProps) => { - return ( -
-
- {CARDS.map(({ key, label, help }) => { - const current = summary.current[key]; - const previous = summary.previous[key]; +const AnalyticsSummaryCards = ({ + sectionLabel, + cards, + previousLabel, + isStale = false, +}: AnalyticsSummaryCardsProps) => { + const isLoading = cards.some((card) => card.current === null); + return ( +
+
+ {cards.map(({ key, label, help, current, previous }) => { return (
{label} {help} -
- {formatCount(current)} - - - - {previousLabel} {formatCount(previous)} + {current === null ? ( + // 값이 들어왔을 때와 높이가 같도록 보이지 않는 글자로 줄 높이를 잡고 그 위에 막대를 그린다. +
+ + 0 + + + + 0 + + +
+ ) : ( +
+ + {formatCount(current)} + + + + {previous !== null && ( + + {previousLabel} {formatCount(previous)} + + )} - -
+ + )}
); })} diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/ServiceStatisticsCards/ServiceStatisticsCards.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/ServiceStatisticsCards/ServiceStatisticsCards.tsx new file mode 100644 index 0000000..3736970 --- /dev/null +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/ServiceStatisticsCards/ServiceStatisticsCards.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { AxiosError } from "axios"; +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; +import { StatisticsPeriod, useGetPostStatistics, useGetUserStatistics } from "@/api/fetch/admin"; +import AdminNotice from "../../../AdminNotice/AdminNotice"; +import AnalyticsSummaryCards from "../AnalyticsSummaryCards/AnalyticsSummaryCards"; + +interface ServiceStatisticsCardsProps { + /** 조회 기간 */ + current: StatisticsPeriod; + /** 증감 비교에 쓰는 기간 */ + previous: StatisticsPeriod; + /** 비교 기간 라벨. 예: "이전 7일" */ + previousLabel: string; +} + +/** 조회 기간에 서버의 통계 도입일 이전 날짜가 들어 있으면 서버가 돌려주는 오류 코드 */ +const BEFORE_AVAILABLE_DATE_CODE = "ADMIN_STATISTICS400-BEFORE_AVAILABLE_DATE"; + +const isBeforeAvailableDate = (error: unknown) => + (error as AxiosError>)?.response?.data?.code === + BEFORE_AVAILABLE_DATE_CODE; + +/** + * 백엔드가 집계한 회원과 게시글 통계를 GA 요약과 같은 카드로 보여줍니다. + * + * @remarks + * - 관리자 토큰이 필요한 API라 토큰 재발급을 처리하는 클라이언트 요청으로 조회합니다. + * - 비교 기간을 받지 못하면(도입일 이전 등) 숫자만 보여주고 증감 자리에 비교할 값이 없다고 표시합니다. + */ +const ServiceStatisticsCards = ({ + current, + previous, + previousLabel, +}: ServiceStatisticsCardsProps) => { + const currentUsers = useGetUserStatistics(current); + const previousUsers = useGetUserStatistics(previous); + const currentPosts = useGetPostStatistics(current); + const previousPosts = useGetPostStatistics(previous); + + const currentError = currentUsers.error ?? currentPosts.error; + // 비교 기간은 실패하면 null로 두고 비교 없이 보여준다. + const isReady = + !currentError && + Boolean(currentUsers.data && currentPosts.data) && + !previousUsers.isPending && + !previousPosts.isPending; + const users = isReady ? currentUsers.data?.result : undefined; + const posts = isReady ? currentPosts.data?.result : undefined; + const prevUsers = previousUsers.data?.result; + const prevPosts = previousPosts.data?.result; + const isStale = [currentUsers, previousUsers, currentPosts, previousPosts].some( + (query) => query.isPlaceholderData + ); + + const cards = ( + + ); + + if (!currentError) return cards; + + // 안내로 바꿔도 높이가 그대로이도록 보이지 않는 카드 위에 안내를 겹친다. + return ( +
+
+ {cards} +
+
+ {isBeforeAvailableDate(currentError) ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default ServiceStatisticsCards; diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/index.ts b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/index.ts index f4afcf0..6c767cc 100644 --- a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/index.ts +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/index.ts @@ -2,3 +2,4 @@ export { default as AnalyticsRangeTabs } from "./AnalyticsRangeTabs/AnalyticsRan export { default as AnalyticsSummaryCards } from "./AnalyticsSummaryCards/AnalyticsSummaryCards"; export { default as VisitorsBarChart } from "./VisitorsBarChart/VisitorsBarChart"; export { default as AnalyticsRankList } from "./AnalyticsRankList/AnalyticsRankList"; +export { default as ServiceStatisticsCards } from "./ServiceStatisticsCards/ServiceStatisticsCards"; diff --git a/src/app/(admin)/admin/_constants/ANALYTICS_RANGES.ts b/src/app/(admin)/admin/_constants/ANALYTICS_RANGES.ts index 0121f79..8a84739 100644 --- a/src/app/(admin)/admin/_constants/ANALYTICS_RANGES.ts +++ b/src/app/(admin)/admin/_constants/ANALYTICS_RANGES.ts @@ -1,7 +1,16 @@ +/** + * 서비스 통계 조회 기간입니다. `days`가 있으면 오늘을 포함한 최근 N일이고, 없으면 달력 기준 기간입니다. + * `previousLabel`은 증감 비교에 쓰는 기간의 이름입니다. + */ export const ANALYTICS_RANGES = [ - { value: "7d", label: "7일", days: 7 }, - { value: "30d", label: "30일", days: 30 }, - { value: "90d", label: "90일", days: 90 }, + { value: "1d", label: "최근 1일", previousLabel: "이전 1일", days: 1 }, + { value: "3d", label: "최근 3일", previousLabel: "이전 3일", days: 3 }, + { value: "7d", label: "최근 7일", previousLabel: "이전 7일", days: 7 }, + { value: "30d", label: "최근 30일", previousLabel: "이전 30일", days: 30 }, + { value: "90d", label: "최근 90일", previousLabel: "이전 90일", days: 90 }, + { value: "thisWeek", label: "이번 주", previousLabel: "지난주 같은 기간" }, + { value: "thisMonth", label: "이번 달", previousLabel: "지난달 같은 기간" }, + { value: "lastMonth", label: "저번 달", previousLabel: "전전달" }, ] as const; export const DEFAULT_ANALYTICS_RANGE = "7d"; diff --git a/src/app/(admin)/admin/_utils/fetchAnalyticsReport/fetchAnalyticsReport.ts b/src/app/(admin)/admin/_utils/fetchAnalyticsReport/fetchAnalyticsReport.ts index d630dd2..f291a26 100644 --- a/src/app/(admin)/admin/_utils/fetchAnalyticsReport/fetchAnalyticsReport.ts +++ b/src/app/(admin)/admin/_utils/fetchAnalyticsReport/fetchAnalyticsReport.ts @@ -2,9 +2,10 @@ import "server-only"; import { unstable_cache } from "next/cache"; import { BetaAnalyticsDataClient } from "@google-analytics/data"; import { AnalyticsReport, AnalyticsRange } from "../../_types/AnalyticsReport"; -import { ANALYTICS_RANGES, ANALYTICS_WEEKLY_COUNT } from "../../_constants/ANALYTICS_RANGES"; +import { ANALYTICS_WEEKLY_COUNT } from "../../_constants/ANALYTICS_RANGES"; import { RANKING_LIMIT, toAnalyticsReport } from "../toAnalyticsReport/toAnalyticsReport"; import { getWeeklyRangeStart } from "../getWeeklyRangeStart/getWeeklyRangeStart"; +import { getAnalyticsPeriods } from "../getAnalyticsPeriods/getAnalyticsPeriods"; /** GA 조회에 필요한 서버 env가 모두 있는지 확인합니다. 키는 브라우저로 내려가지 않도록 `NEXT_PUBLIC_`을 붙이지 않습니다. */ export const hasAnalyticsConfig = () => @@ -20,13 +21,14 @@ const createClient = () => }); const requestAnalyticsReport = async (range: AnalyticsRange): Promise => { - const { days } = ANALYTICS_RANGES.find((item) => item.value === range) ?? ANALYTICS_RANGES[0]; const property = `properties/${process.env.GA_PROPERTY_ID}`; - const dateRanges = [{ startDate: `${days - 1}daysAgo`, endDate: "today" }]; + // GA 속성 시간대가 한국 시간이라 같은 날짜 문자열을 서버 통계 API와 함께 쓴다. + const { current, previous } = getAnalyticsPeriods(new Date(), range); + const dateRanges = [current]; // 이름을 붙이면 GA가 응답에 dateRange 측정기준으로 어느 기간의 값인지 알려준다. const comparedDateRanges = [ - { ...dateRanges[0], name: "current" }, - { startDate: `${days * 2 - 1}daysAgo`, endDate: `${days}daysAgo`, name: "previous" }, + { ...current, name: "current" }, + { ...previous, name: "previous" }, ]; const client = createClient(); @@ -82,7 +84,9 @@ const requestAnalyticsReport = async (range: AnalyticsRange): Promise new Date(time).toISOString().slice(0, 10); + +const toPeriod = (start: number, end: number): StatisticsPeriod => ({ + startDate: toYmd(start), + endDate: toYmd(end), +}); + +/** + * 한국 시간 기준으로 선택한 기간과 증감 비교에 쓰는 기간을 `YYYY-MM-DD`로 구합니다. 양끝 날짜를 모두 포함합니다. + * + * @remarks + * - 최근 N일은 오늘을 포함한 N일이고, 비교 기간은 바로 앞 N일입니다. + * - 이번 주(월요일부터)와 이번 달은 지난주, 지난달의 같은 날까지와 비교합니다. 지난달에 같은 날이 없으면 말일까지입니다. + * - 저번 달은 그 앞 달 전체와 비교합니다. + */ +export const getAnalyticsPeriods = ( + now: Date, + range: AnalyticsRange +): { current: StatisticsPeriod; previous: StatisticsPeriod } => { + const kstNow = new Date(now.getTime() + KST_OFFSET_MS); + const year = kstNow.getUTCFullYear(); + const month = kstNow.getUTCMonth(); + const date = kstNow.getUTCDate(); + const today = Date.UTC(year, month, date); + + const item = ANALYTICS_RANGES.find((option) => option.value === range) ?? ANALYTICS_RANGES[0]; + + if ("days" in item) { + const start = today - (item.days - 1) * DAY_MS; + return { + current: toPeriod(start, today), + previous: toPeriod(start - item.days * DAY_MS, start - DAY_MS), + }; + } + + switch (item.value) { + case "thisWeek": { + const weekday = (kstNow.getUTCDay() + 6) % 7; + const start = today - weekday * DAY_MS; + return { + current: toPeriod(start, today), + previous: toPeriod(start - 7 * DAY_MS, today - 7 * DAY_MS), + }; + } + case "thisMonth": + return { + current: toPeriod(Date.UTC(year, month, 1), today), + // Date.UTC(year, month, 0)은 지난달 말일이다. + previous: toPeriod( + Date.UTC(year, month - 1, 1), + Math.min(Date.UTC(year, month - 1, date), Date.UTC(year, month, 0)) + ), + }; + case "lastMonth": + return { + current: toPeriod(Date.UTC(year, month - 1, 1), Date.UTC(year, month, 0)), + previous: toPeriod(Date.UTC(year, month - 2, 1), Date.UTC(year, month - 1, 0)), + }; + } +}; diff --git a/src/app/(admin)/admin/analytics/_docs/plan.md b/src/app/(admin)/admin/analytics/_docs/plan.md index f17a113..64307c4 100644 --- a/src/app/(admin)/admin/analytics/_docs/plan.md +++ b/src/app/(admin)/admin/analytics/_docs/plan.md @@ -16,7 +16,7 @@ - [x] 서비스 계정(ga-reader@finditem-admin) 생성, GA 속성 뷰어 권한 부여, 로컬 env 등록, 실제 조회 확인 (최근 7일 방문자 30명) - [ ] dev 서버에서 화면으로 확인 - [ ] Vercel 환경 변수 등록 -- [ ] 가입 수를 백엔드 집계로 받을지 결정 (`sign_up` 미수집 문제) +- [x] 가입 수를 백엔드 집계로 받을지 결정 (`sign_up` 미수집 문제). 백엔드 통계 API로 바꿨다(아래 항목) ## 화면 피드백 반영 (2026-09-26) @@ -41,3 +41,27 @@ - [x] 사이드바를 다시 화면에 고정하고 메뉴 영역만 안에서 스크롤, 스크롤바 항상 표시 - [x] 유입 경로를 방문 횟수 대신 방문자 수(기간 중복 제거) 기준으로 변경 - [x] GA 웹 스트림 태그 설정의 "원치 않는 추천"에 `accounts.kakao.com` 등록 (카카오 로그인 복귀가 유입으로 잡히던 문제, 지난 30일 방문 52회 중 실제 방문자 4명) + +## 백엔드 통계 API 연결 (2026-10-18) + +- [x] `GET /admin/users/statistics`, `GET /admin/posts/statistics` 타입과 조회 훅 추가 (`startDate`, `endDate`는 한국 시간 `YYYY-MM-DD`, 양끝 포함) +- [x] 대시보드에 "회원과 게시글" 카드 6개 추가 (전체 회원, 가입, 탈퇴, 전체 게시글, 분실, 발견). 이전 같은 길이 기간과 증감 비교 +- [x] GA `sign_up`, `post_complete` 카드는 서버 집계와 겹쳐 화면에서 뺐다. GA 조회 자체는 그대로 둔다 +- [x] 관리자 토큰이 필요해 서버 컴포넌트가 아니라 클라이언트 조회(`useAppQuery`, 토큰 재발급 처리)로 받는다. GA 설정이 없어도 이 카드는 보인다 +- [x] dev 서버에서 화면으로 확인. 개발 서버 통계 도입일은 2026-10-16이고, 그 이전이 들어간 기간은 `ADMIN_STATISTICS400-BEFORE_AVAILABLE_DATE`(400)로 거절된다 +- [x] 조회 기간이 도입일 이전이면 원인 안내, 비교 기간만 거절되면 숫자만 보여주고 "비교할 값 없음" 표시 +- [ ] 백엔드에 도입일 이전 시작일을 도입일로 맞춰 집계하거나 도입일을 알려 달라고 요청할지 결정 + +## 기간 선택 확장 (2026-10-18) + +- [x] 최근 1일, 3일, 이번 주(월요일부터), 이번 달, 저번 달 추가 +- [x] 기간 계산을 `getAnalyticsPeriods`(한국 시간, 양끝 포함)로 모으고 GA 조회도 같은 날짜를 쓰도록 변경 (기존 `NdaysAgo`와 7일 기준 값이 같음을 화면에서 확인) +- [x] 비교 기간: 최근 N일은 바로 앞 N일, 이번 주와 이번 달은 지난주와 지난달의 같은 날까지, 저번 달은 전전달 전체 +- [x] 기간 아래 안내 문구에 실제 조회 날짜와 비교 날짜 표시 + +## 기간 전환 시 레이아웃 시프트 (2026-10-18) + +- [x] 서버 통계 조회에 `keepPreviousData`를 써서 새 기간을 받는 동안 이전 값을 흐리게 유지 +- [x] 첫 로딩은 짧은 문구 대신 같은 높이의 스켈레톤 카드로 자리 잡기 +- [x] 도입일 이전 안내와 오류 안내는 보이지 않는 카드 위에 겹쳐 카드와 같은 높이 유지 (`AdminNotice`에 `className` 추가) +- [x] 화면에서 1일, 3일, 7일, 이번 주, 이번 달을 차례로 눌러 회원과 게시글 영역 높이(289px)와 아래 방문 영역 위치가 그대로인 것을 확인 diff --git a/src/middleware.ts b/src/middleware.ts index 578b00a..bbee32a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,7 @@ import { NextResponse, NextRequest } from "next/server"; import { jwtDecode } from "jwt-decode"; import { ROLE_AREAS } from "@/constants/ROLE_AREAS"; +import { getAuthCookieDomain } from "@/utils/getAuthCookieDomain/getAuthCookieDomain"; interface JwtPayload { role?: string; @@ -23,9 +24,10 @@ const getHomeByRole = (role: string | null) => const findArea = (pathname: string) => ROLE_AREAS.find((area) => pathname === area.home || pathname.startsWith(`${area.home}/`)); -const clearTokens = (response: NextResponse) => { - response.cookies.set("access_token", "", { path: "/", maxAge: 0 }); - response.cookies.set("refresh_token", "", { path: "/", maxAge: 0 }); +const clearTokens = (request: NextRequest, response: NextResponse) => { + const domain = getAuthCookieDomain(request.headers.get("host")); + response.cookies.set("access_token", "", { path: "/", maxAge: 0, domain }); + response.cookies.set("refresh_token", "", { path: "/", maxAge: 0, domain }); return response; }; @@ -44,16 +46,17 @@ export function middleware(request: NextRequest) { return NextResponse.redirect(loginUrl); }; - // 권한이 없는 계정으로 로그인하면 토큰을 지우고 로그인 화면에 안내를 띄운다. + // 권한이 없는 계정이면 로그인 화면에 안내만 띄운다. + // 토큰은 운영 앱과 공유하므로 여기서 지우면 운영 앱 세션까지 끊긴다. const redirectForbidden = () => { const loginUrl = new URL(LOGIN_PATH, request.url); loginUrl.searchParams.set("reason", "forbidden"); - return clearTokens(NextResponse.redirect(loginUrl)); + return NextResponse.redirect(loginUrl); }; if (pathname.startsWith(LOGIN_PATH)) { const reason = request.nextUrl.searchParams.get("reason"); - if (reason === "session-expired") return clearTokens(NextResponse.next()); + if (reason === "session-expired") return clearTokens(request, NextResponse.next()); if (reason !== "forbidden" && accessToken && hasRefreshToken && home) return redirectTo(home); return NextResponse.next(); } diff --git a/src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts b/src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts new file mode 100644 index 0000000..5d0d333 --- /dev/null +++ b/src/utils/getAuthCookieDomain/getAuthCookieDomain.test.ts @@ -0,0 +1,21 @@ +import { getAuthCookieDomain } from "./getAuthCookieDomain"; + +describe("getAuthCookieDomain", () => { + it.each(["finditem.kr", "www.finditem.kr", "a.finditem.kr", "a.finditem.kr:443"])( + "%s이면 상위 도메인을 반환한다", + (host) => { + expect(getAuthCookieDomain(host)).toBe(".finditem.kr"); + } + ); + + it.each(["localhost:3000", "evilfinditem.kr", "finditem.kr.evil.com", ""])( + "%s이면 도메인을 붙이지 않는다", + (host) => { + expect(getAuthCookieDomain(host)).toBeUndefined(); + } + ); + + it("Host 헤더가 없으면 도메인을 붙이지 않는다", () => { + expect(getAuthCookieDomain(null)).toBeUndefined(); + }); +}); diff --git a/src/utils/getAuthCookieDomain/getAuthCookieDomain.ts b/src/utils/getAuthCookieDomain/getAuthCookieDomain.ts new file mode 100644 index 0000000..4eb24da --- /dev/null +++ b/src/utils/getAuthCookieDomain/getAuthCookieDomain.ts @@ -0,0 +1,25 @@ +const AUTH_COOKIE_PARENT_DOMAIN = "finditem.kr"; + +/** + * 토큰 쿠키를 지울 때 붙일 `domain` 값을 접속 호스트로 판정합니다. + * + * @remarks + * - 백엔드가 finditem.kr 계열 요청에는 토큰 쿠키를 상위 도메인으로 발급하므로, 지울 때도 같은 도메인을 줘야 지워집니다. + * - dev 서버는 middleware의 `nextUrl`을 자기 주소로 만들기 때문에 `Host` 헤더를 넘겨 판정합니다. + * + * @param host - 요청의 `Host` 헤더 값 (포트 포함 가능) + * @returns finditem.kr 또는 그 하위 도메인이면 `".finditem.kr"`, 아니면 `undefined` + * + * @example + * ```ts + * getAuthCookieDomain("a.finditem.kr"); // ".finditem.kr" + * getAuthCookieDomain("localhost:3000"); // undefined + * ``` + */ +export const getAuthCookieDomain = (host: string | null) => { + const hostname = host?.split(":")[0] ?? ""; + return hostname === AUTH_COOKIE_PARENT_DOMAIN || + hostname.endsWith(`.${AUTH_COOKIE_PARENT_DOMAIN}`) + ? `.${AUTH_COOKIE_PARENT_DOMAIN}` + : undefined; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 8b5e27a..2b7f453 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -14,3 +14,4 @@ export { applyFiltersToUrl } from "./applyFiltersToUrl/applyFiltersToUrl"; export { getCategoryLabel } from "./getCategoryLabel/getCategoryLabel"; export { getServiceUrl } from "./getServiceUrl/getServiceUrl"; export { isValidCallbackUrl } from "./isValidCallbackUrl/isValidCallbackUrl"; +export { getAuthCookieDomain } from "./getAuthCookieDomain/getAuthCookieDomain"; diff --git a/tests/e2e/auth-login.spec.ts b/tests/e2e/auth-login.spec.ts index c6c8cd7..21573a9 100644 --- a/tests/e2e/auth-login.spec.ts +++ b/tests/e2e/auth-login.spec.ts @@ -49,6 +49,10 @@ test.describe("관리자 로그인 (/login)", () => { await expect(page).toHaveURL(/\/login\?reason=forbidden$/); await expect(page.getByText("관리자 권한이 있는 계정으로 로그인해 주세요.")).toBeVisible(); + + // 토큰은 운영 앱과 공유하므로 권한이 없어도 지우지 않는다. + const cookieNames = (await context.cookies()).map((cookie) => cookie.name); + expect(cookieNames).toEqual(expect.arrayContaining(["access_token", "refresh_token"])); }); test("관리자 계정으로 로그인 화면에 접근하면 관리자 메인으로 이동한다", async ({