From fd996a6117f49997bfa86c9c5ef15e5ec6be851a Mon Sep 17 00:00:00 2001 From: wlrnjs Date: Sat, 26 Sep 2026 18:35:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(admin):=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=EB=A5=BC=20=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=95=98=EA=B3=A0=20=EB=A9=94=EB=89=B4=EA=B0=80=20?= =?UTF-8?q?=EA=B8=B8=EB=A9=B4=20=EB=A9=94=EB=89=B4=20=EC=98=81=EC=97=AD?= =?UTF-8?q?=EB=A7=8C=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PC 사이드바가 화면 높이보다 길면 아래 메뉴가 잘린 것처럼 보였습니다. macOS의 오버레이 스크롤바가 스크롤하기 전까지 보이지 않았기 때문입니다. 프로필은 위에 고정하고 메뉴 영역만 안에서 스크롤되게 했으며, 웹킷 스크롤바를 직접 그려 항상 보이게 했습니다. PC에서 메뉴와 프로필 간격도 줄여 800px 안팎 높이에 스크롤 없이 들어가게 했습니다. Co-Authored-By: Claude Opus 5.5 --- .../AdminMenuSection/AdminMenuSection.tsx | 4 ++-- .../admin/_components/AdminProfile/AdminProfile.tsx | 2 +- .../admin/_components/AdminSidebar/AdminSidebar.tsx | 12 +++++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/(admin)/admin/_components/AdminMenuSection/AdminMenuSection.tsx b/src/app/(admin)/admin/_components/AdminMenuSection/AdminMenuSection.tsx index bd0225d..87a9ae6 100644 --- a/src/app/(admin)/admin/_components/AdminMenuSection/AdminMenuSection.tsx +++ b/src/app/(admin)/admin/_components/AdminMenuSection/AdminMenuSection.tsx @@ -45,7 +45,7 @@ const AdminSectionNavItem = ({ label, items, footer }: AdminSectionNavItemProps) pathname !== null && (pathname === href || pathname.startsWith(`${href}/`)); return ( -
+

{label}

    @@ -54,7 +54,7 @@ const AdminSectionNavItem = ({ label, items, footer }: AdminSectionNavItemProps) {title} diff --git a/src/app/(admin)/admin/_components/AdminProfile/AdminProfile.tsx b/src/app/(admin)/admin/_components/AdminProfile/AdminProfile.tsx index a6fa8fd..3ef43c6 100644 --- a/src/app/(admin)/admin/_components/AdminProfile/AdminProfile.tsx +++ b/src/app/(admin)/admin/_components/AdminProfile/AdminProfile.tsx @@ -10,7 +10,7 @@ const AdminProfile = () => { const { email, nickname, profileImg } = data?.result || {}; return ( -
    +
    {isLoading ? ( ) : ( diff --git a/src/app/(admin)/admin/_components/AdminSidebar/AdminSidebar.tsx b/src/app/(admin)/admin/_components/AdminSidebar/AdminSidebar.tsx index 8938c3e..7c5e55a 100644 --- a/src/app/(admin)/admin/_components/AdminSidebar/AdminSidebar.tsx +++ b/src/app/(admin)/admin/_components/AdminSidebar/AdminSidebar.tsx @@ -6,16 +6,22 @@ import AdminProfile from "../AdminProfile/AdminProfile"; * * @remarks * - PC 미만에서는 렌더만 하고 숨기며, 그 크기에서는 `/admin` 메인이 같은 프로필과 메뉴를 보여줍니다. + * - 본문을 스크롤해도 화면에 고정됩니다. 메뉴가 화면 높이보다 길면 메뉴 영역만 안에서 스크롤됩니다. + * - macOS의 오버레이 스크롤바는 스크롤하기 전까지 보이지 않아 메뉴가 잘린 것처럼 보이므로, 웹킷 스크롤바를 직접 그려 항상 보이게 합니다. */ const AdminSidebar = () => { return ( ); }; From dd0344005b8ab79bd21d84da9cfa21fd81fdec2d Mon Sep 17 00:00:00 2001 From: wlrnjs Date: Sat, 26 Sep 2026 18:35:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(analytics):=20GA=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=ED=86=B5=EA=B3=84=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 앱의 Google Analytics 데이터를 서버에서 서비스 계정으로 조회해 보여줍니다. PC에서는 관리자 메인에, 모바일에서는 /admin/analytics에 표시합니다. 기간(7일, 30일, 90일)별 요약 카드와 이전 기간 대비 증감, 일별과 주간 방문자 차트, 인기 검색어, 유입 경로, 많이 본 페이지를 제공합니다. GA 조회 결과는 기간별로 10분 동안 캐시하고, gRPC를 쓰는 @google-analytics/data는 serverExternalPackages로 번들에서 뺐습니다. Co-Authored-By: Claude Opus 5.5 --- .env.example | 7 + next.config.ts | 3 + package.json | 1 + pnpm-lock.yaml | 375 +++++++++++++++++- .../AnalyticsDashboard/AnalyticsDashboard.tsx | 173 ++++++++ .../AnalyticsNotice/AnalyticsNotice.tsx | 16 + .../AnalyticsRangeTabs/AnalyticsRangeTabs.tsx | 39 ++ .../AnalyticsRankList/AnalyticsRankList.tsx | 82 ++++ .../AnalyticsSummaryCards.tsx | 78 ++++ .../VisitorsBarChart/VisitorsBarChart.tsx | 116 ++++++ .../AnalyticsDashboard/_internal/index.ts | 5 + .../admin/_constants/ADMIN_NAV_SECTIONS.ts | 5 + .../admin/_constants/ANALYTICS_RANGES.ts | 10 + .../(admin)/admin/_types/AnalyticsReport.ts | 81 ++++ .../fetchAnalyticsReport.ts | 128 ++++++ .../getChangeRate/getChangeRate.test.ts | 15 + .../_utils/getChangeRate/getChangeRate.ts | 7 + .../getWeeklyRangeStart.test.ts | 18 + .../getWeeklyRangeStart.ts | 14 + .../isoWeekToMonday/isoWeekToMonday.test.ts | 9 + .../_utils/isoWeekToMonday/isoWeekToMonday.ts | 16 + .../toAnalyticsReport.test.ts | 140 +++++++ .../toAnalyticsReport/toAnalyticsReport.ts | 147 +++++++ src/app/(admin)/admin/analytics/_docs/plan.md | 43 ++ src/app/(admin)/admin/analytics/layout.tsx | 13 + src/app/(admin)/admin/analytics/page.tsx | 26 ++ src/app/(admin)/admin/page.tsx | 22 +- 27 files changed, 1583 insertions(+), 6 deletions(-) create mode 100644 src/app/(admin)/admin/_components/AnalyticsDashboard/AnalyticsDashboard.tsx create mode 100644 src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsNotice/AnalyticsNotice.tsx create mode 100644 src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRangeTabs/AnalyticsRangeTabs.tsx create mode 100644 src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRankList/AnalyticsRankList.tsx create mode 100644 src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsSummaryCards/AnalyticsSummaryCards.tsx create mode 100644 src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/VisitorsBarChart/VisitorsBarChart.tsx create mode 100644 src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/index.ts create mode 100644 src/app/(admin)/admin/_constants/ANALYTICS_RANGES.ts create mode 100644 src/app/(admin)/admin/_types/AnalyticsReport.ts create mode 100644 src/app/(admin)/admin/_utils/fetchAnalyticsReport/fetchAnalyticsReport.ts create mode 100644 src/app/(admin)/admin/_utils/getChangeRate/getChangeRate.test.ts create mode 100644 src/app/(admin)/admin/_utils/getChangeRate/getChangeRate.ts create mode 100644 src/app/(admin)/admin/_utils/getWeeklyRangeStart/getWeeklyRangeStart.test.ts create mode 100644 src/app/(admin)/admin/_utils/getWeeklyRangeStart/getWeeklyRangeStart.ts create mode 100644 src/app/(admin)/admin/_utils/isoWeekToMonday/isoWeekToMonday.test.ts create mode 100644 src/app/(admin)/admin/_utils/isoWeekToMonday/isoWeekToMonday.ts create mode 100644 src/app/(admin)/admin/_utils/toAnalyticsReport/toAnalyticsReport.test.ts create mode 100644 src/app/(admin)/admin/_utils/toAnalyticsReport/toAnalyticsReport.ts create mode 100644 src/app/(admin)/admin/analytics/_docs/plan.md create mode 100644 src/app/(admin)/admin/analytics/layout.tsx create mode 100644 src/app/(admin)/admin/analytics/page.tsx diff --git a/.env.example b/.env.example index 59fe9da..baf6c75 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,10 @@ NEXT_PUBLIC_API_URL=your_api_url # 운영 앱(찾아줘!) 주소. 게시글 상세, 공지 상세, 비밀번호 변경 링크에 쓴다. 비우면 https://www.finditem.kr NEXT_PUBLIC_SERVICE_URL=https://www.finditem.kr + +# 서비스 통계(/admin/analytics)용 GA Data API 설정. 서버에서만 쓰므로 NEXT_PUBLIC_을 붙이지 않는다. +# GA 속성에 "뷰어" 권한을 준 Google Cloud 서비스 계정의 이메일과 비공개 키를 넣는다. +# 비공개 키는 줄바꿈을 \n으로 바꿔 한 줄로 넣는다. +GA_PROPERTY_ID=510805727 +GA_CLIENT_EMAIL=your_service_account@your_project.iam.gserviceaccount.com +GA_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" diff --git a/next.config.ts b/next.config.ts index abfc5b5..1d4b732 100644 --- a/next.config.ts +++ b/next.config.ts @@ -30,6 +30,9 @@ const securityHeaders = [ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") ?? ""; const nextConfig: NextConfig = { + // GA Data API 클라이언트는 gRPC(google-gax)를 써서 webpack으로 묶으면 내부 package.json을 찾지 못한다. + // 서버에서 node_modules 그대로 불러오도록 번들에서 뺀다. + serverExternalPackages: ["@google-analytics/data"], experimental: { reactCompiler: true, }, diff --git a/package.json b/package.json index aa31cdf..78145d7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@google-analytics/data": "^7.2.0", "@tanstack/react-query": "^5.101.4", "axios": "^1.19.0", "babel-plugin-react-compiler": "^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40bda3b..e7e204e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.8) + '@google-analytics/data': + specifier: ^7.2.0 + version: 7.2.0 '@tanstack/react-query': specifier: ^5.101.4 version: 5.101.4(react@19.2.8) @@ -47,7 +50,7 @@ importers: version: 4.0.0 next: specifier: ^15.5.25 - version: 15.5.25(@babel/core@7.29.7)(@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) + 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) react: specifier: ^19.2.8 version: 19.2.8 @@ -932,6 +935,19 @@ packages: '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@google-analytics/data@7.2.0': + resolution: {integrity: sha512-jYm/I/Yj30x3QEuAoK4lZGmx8WDbAHHTMcFrBJB4L0QQyaK4HnV+m/Ew57HnqPfAx0WgHxX6FHUhdQtlj7GLdw==} + engines: {node: '>=22'} + + '@grpc/grpc-js@1.14.5': + resolution: {integrity: sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1238,6 +1254,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@next/env@15.5.25': resolution: {integrity: sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==} @@ -1305,11 +1324,42 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@playwright/test@1.62.1': resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} hasBin: true + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@simple-libs/child-process-utils@1.0.2': resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} engines: {node: '>=18'} @@ -1627,11 +1677,17 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.11.25: resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} hasBin: true + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1658,6 +1714,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1854,6 +1913,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -1941,6 +2004,12 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.433: resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} @@ -1954,6 +2023,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2028,6 +2100,9 @@ packages: resolution: {integrity: sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2056,6 +2131,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2077,6 +2156,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -2110,6 +2193,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} + engines: {node: '>=18'} + + gcp-metadata@9.0.4: + resolution: {integrity: sha512-p6WvbXOhfwnVtv/MyPq5kBiVhfV/qglMIxwwTVNEI1Ha1HmOgz5uHYHtYO6SfGckFaXe8blA11IJJ3wqXmvmBA==} + engines: {node: '>=22'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2160,6 +2251,18 @@ packages: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} + google-auth-library@11.1.0: + resolution: {integrity: sha512-YIBq5KNoHQrZ9PM4PeodDBqqBGlSTIxh1zibN8u3zNIO9UJg8gvKuCpdKV7fWXhsZ2zRkU6FhGrgKNgLJ2IRgw==} + engines: {node: '>=22'} + + google-gax@6.7.0: + resolution: {integrity: sha512-v9QQ0c3MFCqG5K0/wfao7Bkk3knNxfnBHRb0UAssQvZJsf9DitRrguMrrSAfFcaEf3L6DHflbUyfW2NUt9aKFw==} + engines: {node: '>=22'} + + google-logging-utils@2.0.1: + resolution: {integrity: sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A==} + engines: {node: '>=22'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2549,6 +2652,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -2560,6 +2666,12 @@ packages: engines: {node: '>=6'} hasBin: true + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -2592,6 +2704,9 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -2602,6 +2717,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -2728,6 +2846,15 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -2970,6 +3097,14 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + proto3-json-serializer@4.0.2: + resolution: {integrity: sha512-VUQlb/J2WjOG8BKhdLwJUz+MKxSMtBGYh+TaNmRtiYVhlIzXjzeAGIx7gtc3lXlA2iURzfF2+cXaQvC2ofwzpg==} + engines: {node: '>=22'} + + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -3020,6 +3155,10 @@ packages: read-cache@1.0.2: resolution: {integrity: sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -3079,6 +3218,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retry-request@9.0.1: + resolution: {integrity: sha512-4kGHEBl0ME6gR+8QB1sPMyg58jUxLUcCZNeqLDrfqzk0eWQN8XJFmeu7fmZCjlZCRW0S2u64Ik1HkO/0sWgCYQ==} + engines: {node: '>=22'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3092,6 +3235,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3175,6 +3321,12 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -3195,6 +3347,9 @@ packages: resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3219,6 +3374,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3270,6 +3428,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + teeny-request@11.0.1: + resolution: {integrity: sha512-bNr5j2YjSdajgCVsp+8JVjRf1uHIbpNISLeKp/7V1NnVMX3Gh6H/kECNGlm2Q3I68ACODQ0iv6aZ3Rvm8WgLGA==} + engines: {node: '>=22'} + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -3409,6 +3571,10 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -4500,6 +4666,24 @@ snapshots: tslib: 2.8.1 optional: true + '@google-analytics/data@7.2.0': + dependencies: + google-gax: 6.7.0 + transitivePeerDependencies: + - supports-color + + '@grpc/grpc-js@1.14.5': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.3 + '@img/colour@1.1.0': optional: true @@ -4871,6 +5055,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@js-sdsl/ordered-map@4.4.2': {} + '@next/env@15.5.25': {} '@next/swc-darwin-arm64@15.5.25': @@ -4909,10 +5095,32 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 + '@opentelemetry/api@1.9.1': {} + '@playwright/test@1.62.1': dependencies: playwright: 1.62.1 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@simple-libs/child-process-utils@1.0.2': dependencies: '@simple-libs/stream-utils': 1.2.0 @@ -5305,8 +5513,12 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.11.25: {} + bignumber.js@9.3.1: {} + binary-extensions@2.3.0: {} boolbase@1.0.0: {} @@ -5336,6 +5548,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} call-bind-apply-helpers@1.0.2: @@ -5526,6 +5740,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -5595,6 +5811,17 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.433: {} emittery@0.13.1: {} @@ -5603,6 +5830,10 @@ snapshots: emoji-regex@8.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@4.5.0: {} entities@6.0.1: {} @@ -5673,6 +5904,8 @@ snapshots: jest-mock: 30.5.2 jest-util: 30.5.1 + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -5699,6 +5932,11 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -5718,6 +5956,10 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + fraction.js@5.3.4: {} framer-motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): @@ -5739,6 +5981,22 @@ snapshots: function-bind@1.1.2: {} + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@9.0.4: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 2.0.1 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -5796,6 +6054,35 @@ snapshots: dependencies: ini: 6.0.0 + google-auth-library@11.1.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 9.0.4 + google-logging-utils: 2.0.1 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-gax@6.7.0: + dependencies: + '@grpc/grpc-js': 1.14.5 + '@grpc/proto-loader': 0.8.1 + '@opentelemetry/api': 1.9.1 + duplexify: 4.1.3 + google-auth-library: 11.1.0 + google-logging-utils: 2.0.1 + node-fetch: 3.3.2 + object-hash: 3.0.0 + proto3-json-serializer: 4.0.2 + protobufjs: 7.6.6 + retry-request: 9.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@2.0.1: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -6407,12 +6694,27 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-parse-even-better-errors@2.3.1: {} json-schema-traverse@1.0.0: {} json5@2.2.3: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + jwt-decode@4.0.0: {} kleur@3.0.3: {} @@ -6445,6 +6747,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash.camelcase@4.3.0: {} + lodash.debounce@4.0.8: {} lodash.memoize@4.1.2: {} @@ -6457,6 +6761,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + long@5.3.2: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -6536,7 +6842,7 @@ snapshots: neo-async@2.6.2: {} - next@15.5.25(@babel/core@7.29.7)(@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.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): dependencies: '@next/env': 15.5.25 '@swc/helpers': 0.5.15 @@ -6554,6 +6860,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.25 '@next/swc-win32-arm64-msvc': 15.5.25 '@next/swc-win32-x64-msvc': 15.5.25 + '@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) @@ -6567,6 +6874,14 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-int64@0.4.0: {} node-releases@2.0.56: {} @@ -6733,6 +7048,24 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + proto3-json-serializer@4.0.2: + dependencies: + protobufjs: 7.6.6 + + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 20.19.43 + long: 5.3.2 + proxy-from-env@2.1.0: {} punycode@2.3.1: {} @@ -6766,6 +7099,12 @@ snapshots: read-cache@1.0.2: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -6822,6 +7161,13 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + retry-request@9.0.1: + dependencies: + extend: 3.0.2 + teeny-request: 11.0.1 + transitivePeerDependencies: + - supports-color + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -6832,6 +7178,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} sax@1.6.1: {} @@ -6924,6 +7272,12 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + + stream-shift@1.0.3: {} + string-argv@0.3.2: {} string-length@4.0.2: @@ -6948,6 +7302,10 @@ snapshots: get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -6966,6 +7324,8 @@ snapshots: strip-json-comments@3.1.1: {} + stubs@3.0.0: {} + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8): dependencies: client-only: 0.0.1 @@ -7037,6 +7397,15 @@ snapshots: - tsx - yaml + teeny-request@11.0.1: + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + stream-events: 1.0.5 + transitivePeerDependencies: + - supports-color + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.6 @@ -7150,6 +7519,8 @@ snapshots: dependencies: makeerror: 1.0.12 + web-streams-polyfill@3.3.3: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/AnalyticsDashboard.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/AnalyticsDashboard.tsx new file mode 100644 index 0000000..4a66f78 --- /dev/null +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/AnalyticsDashboard.tsx @@ -0,0 +1,173 @@ +import { ANALYTICS_RANGES, DEFAULT_ANALYTICS_RANGE } from "../../_constants/ANALYTICS_RANGES"; +import { AnalyticsRange, AnalyticsReport } from "../../_types/AnalyticsReport"; +import { + fetchAnalyticsReport, + hasAnalyticsConfig, +} from "../../_utils/fetchAnalyticsReport/fetchAnalyticsReport"; +import { getServiceUrl } from "@/utils"; +import { + AnalyticsNotice, + AnalyticsRangeTabs, + AnalyticsRankList, + AnalyticsSummaryCards, + VisitorsBarChart, +} from "./_internal"; + +interface AnalyticsDashboardProps { + /** `?range=` 쿼리 값. 목록에 없는 값이면 기본 기간을 쓴다. */ + rangeParam?: string; + /** 기간 링크가 가리킬 경로 */ + basePath: string; +} + +const toRange = (value?: string): AnalyticsRange => + ANALYTICS_RANGES.find((item) => item.value === value)?.value ?? DEFAULT_ANALYTICS_RANGE; + +const toShortDate = (isoDate: string) => isoDate.slice(5).replace("-", "."); + +const addDays = (isoDate: string, days: number) => { + const date = new Date(`${isoDate}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +}; + +/** GA 기본 채널 그룹 이름을 한국어로 바꿉니다. 목록에 없는 값은 그대로 보여줍니다. */ +const CHANNEL_LABELS: Record = { + Direct: "직접 방문", + "Organic Search": "검색", + "Organic Social": "소셜", + Referral: "외부 링크", + "Paid Search": "검색 광고", + Email: "이메일", + Unassigned: "미분류", + "Cross-network": "광고 네트워크", + "AI Assistant": "AI 서비스", +}; + +const SOURCE_LABELS: Record = { + "(direct)": "직접 입력 또는 즐겨찾기", + "(not set)": "알 수 없음", + "(data not available)": "알 수 없음", +}; + +const AnalyticsCharts = ({ + report, + previousLabel, +}: { + report: AnalyticsReport; + previousLabel: string; +}) => { + const dailyItems = report.daily.map(({ date, activeUsers }) => ({ + key: date, + axisLabel: toShortDate(date), + tooltipLabel: toShortDate(date), + value: activeUsers, + })); + + const weeklyItems = report.weekly.map(({ weekStart, activeUsers }) => ({ + key: weekStart, + axisLabel: toShortDate(weekStart), + tooltipLabel: `${toShortDate(weekStart)} ~ ${toShortDate(addDays(weekStart, 6))}`, + value: activeUsers, + })); + + return ( + <> + + +
    + + +
    + +
    + ({ + key: term, + label: term, + value: count, + }))} + /> + ({ + key: `${source}-${channel}`, + label: SOURCE_LABELS[source] ?? source, + sublabel: CHANNEL_LABELS[channel] ?? channel, + value: users, + }))} + /> + ({ + key: path, + label: title || path, + sublabel: path, + value: views, + href: getServiceUrl(path), + }))} + /> +
    + + ); +}; + +/** 운영 앱 GA 데이터를 보여주는 서비스 통계 대시보드입니다. 관리자 메인(PC)과 서비스 통계 화면에서 함께 씁니다. */ +const AnalyticsDashboard = async ({ rangeParam, basePath }: AnalyticsDashboardProps) => { + const range = toRange(rangeParam); + const rangeLabel = ANALYTICS_RANGES.find((item) => item.value === range)?.label ?? ""; + const previousLabel = `이전 ${rangeLabel}`; + + let content; + if (!hasAnalyticsConfig()) { + content = ( + + ); + } else { + try { + content = ( + + ); + } catch (error) { + console.error("[analytics] GA 조회 실패", error); + content = ( + + ); + } + } + + return ( +
    +
    + +

    + 운영 앱의 Google Analytics 기준이며 10분마다 갱신돼요. 증감은 {previousLabel}과 비교한 값이에요. +

    +
    + {content} +
    + ); +}; + +export default AnalyticsDashboard; diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsNotice/AnalyticsNotice.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsNotice/AnalyticsNotice.tsx new file mode 100644 index 0000000..8a5bbd5 --- /dev/null +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsNotice/AnalyticsNotice.tsx @@ -0,0 +1,16 @@ +interface AnalyticsNoticeProps { + title: string; + description: string; +} + +/** GA 설정이 없거나 조회에 실패했을 때 보여주는 안내입니다. */ +const AnalyticsNotice = ({ title, description }: AnalyticsNoticeProps) => { + return ( +
    +

    {title}

    +

    {description}

    +
    + ); +}; + +export default AnalyticsNotice; diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRangeTabs/AnalyticsRangeTabs.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRangeTabs/AnalyticsRangeTabs.tsx new file mode 100644 index 0000000..5befeb8 --- /dev/null +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRangeTabs/AnalyticsRangeTabs.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import { cn } from "@/utils"; +import { ANALYTICS_RANGES } from "../../../../_constants/ANALYTICS_RANGES"; +import { AnalyticsRange } from "../../../../_types/AnalyticsReport"; + +interface AnalyticsRangeTabsProps { + range: AnalyticsRange; + /** 기간 링크가 가리킬 경로. 관리자 메인과 서비스 통계 화면에서 각자 자기 경로를 넘긴다. */ + basePath: string; +} + +/** 조회 기간을 고르는 링크 묶음입니다. 기간은 `?range=` 쿼리로 서버 컴포넌트에 전달됩니다. */ +const AnalyticsRangeTabs = ({ range, basePath }: AnalyticsRangeTabsProps) => { + return ( + + ); +}; + +export default AnalyticsRangeTabs; diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRankList/AnalyticsRankList.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRankList/AnalyticsRankList.tsx new file mode 100644 index 0000000..d3faaca --- /dev/null +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsRankList/AnalyticsRankList.tsx @@ -0,0 +1,82 @@ +export interface AnalyticsRankItem { + key: string; + label: string; + sublabel?: string; + value: number; + /** 있으면 새 탭으로 여는 링크가 된다. */ + href?: string; +} + +interface AnalyticsRankListProps { + title: string; + items: AnalyticsRankItem[]; + /** 값 뒤에 붙는 단위. 예: "회" */ + unit: string; + emptyText: string; +} + +/** 검색어, 유입 경로, 많이 본 페이지처럼 순위가 있는 값을 보여주는 목록입니다. */ +const AnalyticsRankList = ({ title, items, unit, emptyText }: AnalyticsRankListProps) => { + const titleId = `analytics-rank-${title}`; + const max = Math.max(...items.map((item) => item.value), 1); + + return ( +
    +

    + {title} +

    + + {items.length === 0 ? ( +

    {emptyText}

    + ) : ( +
      + {items.map((item, index) => { + const label = ( + + {item.label} + + ); + + return ( +
    1. + + {index + 1} + +
      +
      +
      + {item.href ? ( + + {label} + + ) : ( + label + )} + {item.sublabel && ( + + {item.sublabel} + + )} +
      + + {item.value.toLocaleString("ko-KR")} + {unit} + +
      +
      +
      +
      +
      +
    2. + ); + })} +
    + )} +
    + ); +}; + +export default AnalyticsRankList; diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsSummaryCards/AnalyticsSummaryCards.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsSummaryCards/AnalyticsSummaryCards.tsx new file mode 100644 index 0000000..5fda11f --- /dev/null +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/AnalyticsSummaryCards/AnalyticsSummaryCards.tsx @@ -0,0 +1,78 @@ +import { cn } from "@/utils"; +import { AnalyticsSummary, AnalyticsSummaryValues } from "../../../../_types/AnalyticsReport"; +import { getChangeRate } from "../../../../_utils/getChangeRate/getChangeRate"; + +interface AnalyticsSummaryCardsProps { + summary: AnalyticsSummary; + /** 비교 기간 라벨. 예: "이전 7일" */ + previousLabel: string; +} + +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); + + if (rate === null) { + return 비교할 값 없음; + } + + return ( + 0 && "text-system-success", + rate < 0 && "text-system-warning", + rate === 0 && "text-layout-body-default" + )} + > + {rate > 0 ? `+${rate}%` : `${rate}%`} + + ); +}; + +/** 기간 합계 지표를 카드로 보여주고, 바로 앞 같은 길이 기간과 비교한 증감률을 함께 표시합니다. */ +const AnalyticsSummaryCards = ({ summary, previousLabel }: AnalyticsSummaryCardsProps) => { + return ( +
    +
    + {CARDS.map(({ key, label, help }) => { + const current = summary.current[key]; + const previous = summary.previous[key]; + + return ( +
    +
    + {label} + {help} +
    +
    + {formatCount(current)} + + + + {previousLabel} {formatCount(previous)} + + +
    +
    + ); + })} +
    +
    + ); +}; + +export default AnalyticsSummaryCards; diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/VisitorsBarChart/VisitorsBarChart.tsx b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/VisitorsBarChart/VisitorsBarChart.tsx new file mode 100644 index 0000000..d38586a --- /dev/null +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/VisitorsBarChart/VisitorsBarChart.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/utils"; + +export interface VisitorsBarChartItem { + key: string; + /** 가로축 양 끝에 쓰는 짧은 라벨 */ + axisLabel: string; + /** 마우스를 올렸을 때 보여주는 기간 라벨 */ + tooltipLabel: string; + value: number; +} + +interface VisitorsBarChartProps { + title: string; + items: VisitorsBarChartItem[]; + emptyText: string; +} + +const formatCount = (value: number) => `${value.toLocaleString("ko-KR")}명`; + +/** + * 방문자 수 막대 차트입니다. 막대에 마우스를 올리면 그 기간의 방문자 수를 보여줍니다. + * 스크린 리더에는 같은 값을 표로 제공합니다. + */ +const VisitorsBarChart = ({ title, items, emptyText }: VisitorsBarChartProps) => { + const [hoveredIndex, setHoveredIndex] = useState(null); + + const titleId = `visitors-chart-${title}`; + + if (items.length === 0) { + return ( +
    +

    + {title} +

    +

    {emptyText}

    +
    + ); + } + + const max = Math.max(...items.map((item) => item.value), 1); + const hovered = hoveredIndex === null ? null : items[hoveredIndex]; + + return ( +
    +

    + {title} +

    + +
    + {hovered && hoveredIndex !== null && ( +
    + {hovered.tooltipLabel} · {formatCount(hovered.value)} +
    + )} + +
    setHoveredIndex(null)} + > + {items.map((item, index) => ( +
    setHoveredIndex(index)} + > +
    +
    + ))} +
    + +
    + {items[0].axisLabel} + 최대 {formatCount(max)} + {items[items.length - 1].axisLabel} +
    +
    + + {/* 표에 sr-only를 바로 주면 표 높이가 1px 제한을 무시하고 레이아웃을 늘리므로 div로 감싼다. */} +
    + + + + + + + + + + {items.map((item) => ( + + + + + ))} + +
    {title}
    기간방문자
    {item.tooltipLabel}{formatCount(item.value)}
    +
    +
    + ); +}; + +export default VisitorsBarChart; diff --git a/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/index.ts b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/index.ts new file mode 100644 index 0000000..340a3f9 --- /dev/null +++ b/src/app/(admin)/admin/_components/AnalyticsDashboard/_internal/index.ts @@ -0,0 +1,5 @@ +export { default as AnalyticsRangeTabs } from "./AnalyticsRangeTabs/AnalyticsRangeTabs"; +export { default as AnalyticsSummaryCards } from "./AnalyticsSummaryCards/AnalyticsSummaryCards"; +export { default as AnalyticsNotice } from "./AnalyticsNotice/AnalyticsNotice"; +export { default as VisitorsBarChart } from "./VisitorsBarChart/VisitorsBarChart"; +export { default as AnalyticsRankList } from "./AnalyticsRankList/AnalyticsRankList"; diff --git a/src/app/(admin)/admin/_constants/ADMIN_NAV_SECTIONS.ts b/src/app/(admin)/admin/_constants/ADMIN_NAV_SECTIONS.ts index 3225072..f731d28 100644 --- a/src/app/(admin)/admin/_constants/ADMIN_NAV_SECTIONS.ts +++ b/src/app/(admin)/admin/_constants/ADMIN_NAV_SECTIONS.ts @@ -1,6 +1,11 @@ import { getServiceUrl } from "@/utils"; export const ADMIN_NAV_SECTIONS = [ + { + id: "analytics", + label: "통계", + items: [{ href: "/admin/analytics", title: "서비스 통계" }], + }, { id: "notice", label: "공지사항", diff --git a/src/app/(admin)/admin/_constants/ANALYTICS_RANGES.ts b/src/app/(admin)/admin/_constants/ANALYTICS_RANGES.ts new file mode 100644 index 0000000..0121f79 --- /dev/null +++ b/src/app/(admin)/admin/_constants/ANALYTICS_RANGES.ts @@ -0,0 +1,10 @@ +export const ANALYTICS_RANGES = [ + { value: "7d", label: "7일", days: 7 }, + { value: "30d", label: "30일", days: 30 }, + { value: "90d", label: "90일", days: 90 }, +] as const; + +export const DEFAULT_ANALYTICS_RANGE = "7d"; + +/** 주간 방문자 차트가 보여주는 주 수. 기간 선택과 관계없이 최근 12주를 보여준다. */ +export const ANALYTICS_WEEKLY_COUNT = 12; diff --git a/src/app/(admin)/admin/_types/AnalyticsReport.ts b/src/app/(admin)/admin/_types/AnalyticsReport.ts new file mode 100644 index 0000000..e9e98da --- /dev/null +++ b/src/app/(admin)/admin/_types/AnalyticsReport.ts @@ -0,0 +1,81 @@ +import { ANALYTICS_RANGES } from "../_constants/ANALYTICS_RANGES"; + +export type AnalyticsRange = (typeof ANALYTICS_RANGES)[number]["value"]; + +export interface AnalyticsSummaryValues { + activeUsers: number; + newUsers: number; + sessions: number; + pageViews: number; + /** GA `sign_up` 이벤트 수 */ + signUps: number; + /** GA `post_complete` 이벤트 수 */ + postCompletes: number; +} + +export interface AnalyticsSummary { + current: AnalyticsSummaryValues; + /** 선택한 기간 바로 앞의 같은 길이 기간 */ + previous: AnalyticsSummaryValues; +} + +export interface DailyActiveUsers { + /** `YYYY-MM-DD` */ + date: string; + activeUsers: number; +} + +export interface WeeklyActiveUsers { + /** 그 주 월요일, `YYYY-MM-DD` */ + weekStart: string; + activeUsers: number; +} + +export interface SearchTermCount { + term: string; + count: number; +} + +export interface TrafficSourceCount { + source: string; + /** GA 기본 채널 그룹 (Direct, Organic Search 등) */ + channel: string; + /** 기간 중 이 경로로 들어온 방문자 수. 같은 사람이 여러 번 들어와도 1명으로 센다. */ + users: number; +} + +export interface TopPageView { + path: string; + title: string; + views: number; +} + +export interface AnalyticsReport { + summary: AnalyticsSummary; + daily: DailyActiveUsers[]; + weekly: WeeklyActiveUsers[]; + searchTerms: SearchTermCount[]; + trafficSources: TrafficSourceCount[]; + topPages: TopPageView[]; +} + +/** GA Data API 응답에서 쓰는 부분만 좁힌 타입입니다. */ +export interface GaReportRow { + dimensionValues?: { value?: string | null }[] | null; + metricValues?: { value?: string | null }[] | null; +} + +export interface GaReport { + dimensionHeaders?: { name?: string | null }[] | null; + rows?: GaReportRow[] | null; +} + +export interface GaReports { + summary: GaReport; + events: GaReport; + daily: GaReport; + weekly: GaReport; + searchTerms: GaReport; + trafficSources: GaReport; + topPages: GaReport; +} diff --git a/src/app/(admin)/admin/_utils/fetchAnalyticsReport/fetchAnalyticsReport.ts b/src/app/(admin)/admin/_utils/fetchAnalyticsReport/fetchAnalyticsReport.ts new file mode 100644 index 0000000..d630dd2 --- /dev/null +++ b/src/app/(admin)/admin/_utils/fetchAnalyticsReport/fetchAnalyticsReport.ts @@ -0,0 +1,128 @@ +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 { RANKING_LIMIT, toAnalyticsReport } from "../toAnalyticsReport/toAnalyticsReport"; +import { getWeeklyRangeStart } from "../getWeeklyRangeStart/getWeeklyRangeStart"; + +/** GA 조회에 필요한 서버 env가 모두 있는지 확인합니다. 키는 브라우저로 내려가지 않도록 `NEXT_PUBLIC_`을 붙이지 않습니다. */ +export const hasAnalyticsConfig = () => + Boolean(process.env.GA_PROPERTY_ID && process.env.GA_CLIENT_EMAIL && process.env.GA_PRIVATE_KEY); + +const createClient = () => + new BetaAnalyticsDataClient({ + credentials: { + client_email: process.env.GA_CLIENT_EMAIL, + // env에는 줄바꿈이 `\n` 문자열로 들어오므로 실제 줄바꿈으로 되돌린다. + private_key: process.env.GA_PRIVATE_KEY?.replace(/\\n/g, "\n"), + }, + }); + +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가 응답에 dateRange 측정기준으로 어느 기간의 값인지 알려준다. + const comparedDateRanges = [ + { ...dateRanges[0], name: "current" }, + { startDate: `${days * 2 - 1}daysAgo`, endDate: `${days}daysAgo`, name: "previous" }, + ]; + + const client = createClient(); + + // batchRunReports는 한 번에 보고서 5개까지만 받으므로 두 번으로 나눠 동시에 요청한다. + const [[trendResponse], [rankingResponse]] = await Promise.all([ + client.batchRunReports({ + property, + requests: [ + { + dateRanges: comparedDateRanges, + metrics: [ + { name: "activeUsers" }, + { name: "newUsers" }, + { name: "sessions" }, + { name: "screenPageViews" }, + ], + }, + { + dateRanges: comparedDateRanges, + dimensions: [{ name: "eventName" }], + metrics: [{ name: "eventCount" }], + dimensionFilter: { + filter: { + fieldName: "eventName", + inListFilter: { values: ["sign_up", "post_complete"] }, + }, + }, + }, + { + dateRanges, + dimensions: [{ name: "date" }], + metrics: [{ name: "activeUsers" }], + }, + { + // 주간 방문자는 주 단위로 중복을 뺀 값이라 일별 값을 더한 것과 다르다. + dateRanges: [ + { + startDate: getWeeklyRangeStart(new Date(), ANALYTICS_WEEKLY_COUNT), + endDate: "today", + }, + ], + dimensions: [{ name: "isoYearIsoWeek" }], + metrics: [{ name: "activeUsers" }], + }, + ], + }), + client.batchRunReports({ + property, + requests: [ + { + // 운영 앱의 search 이벤트가 보내는 search_term이 searchTerm 측정기준에 담긴다. + dateRanges, + dimensions: [{ name: "searchTerm" }], + metrics: [{ name: "eventCount" }], + dimensionFilter: { filter: { fieldName: "eventName", stringFilter: { value: "search" } } }, + orderBys: [{ metric: { metricName: "eventCount" }, desc: true }], + limit: RANKING_LIMIT + 5, + }, + { + dateRanges, + // 같은 사람이 여러 번 들어와도 한 번만 세도록 방문 횟수 대신 방문자 수를 쓴다. + dimensions: [{ name: "sessionSource" }, { name: "sessionDefaultChannelGroup" }], + metrics: [{ name: "activeUsers" }], + orderBys: [{ metric: { metricName: "activeUsers" }, desc: true }], + limit: RANKING_LIMIT, + }, + { + // 같은 경로가 제목별로 나뉘어 오므로 넉넉히 받아 경로 기준으로 합친다. + dateRanges, + dimensions: [{ name: "pagePath" }, { name: "pageTitle" }], + metrics: [{ name: "screenPageViews" }], + orderBys: [{ metric: { metricName: "screenPageViews" }, desc: true }], + limit: 100, + }, + ], + }), + ]); + + const [summary = {}, events = {}, daily = {}, weekly = {}] = trendResponse.reports ?? []; + const [searchTerms = {}, trafficSources = {}, topPages = {}] = rankingResponse.reports ?? []; + + return toAnalyticsReport({ + summary, + events, + daily, + weekly, + searchTerms, + trafficSources, + topPages, + }); +}; + +/** GA 할당량을 아끼기 위해 기간별로 10분 동안 결과를 캐시합니다. */ +export const fetchAnalyticsReport = unstable_cache( + requestAnalyticsReport, + ["admin-analytics-report-v3"], + { revalidate: 600 } +); diff --git a/src/app/(admin)/admin/_utils/getChangeRate/getChangeRate.test.ts b/src/app/(admin)/admin/_utils/getChangeRate/getChangeRate.test.ts new file mode 100644 index 0000000..185caf3 --- /dev/null +++ b/src/app/(admin)/admin/_utils/getChangeRate/getChangeRate.test.ts @@ -0,0 +1,15 @@ +import { getChangeRate } from "./getChangeRate"; + +describe("getChangeRate", () => { + it("이전 값 대비 증감률을 정수 퍼센트로 구함", () => { + expect(getChangeRate(30, 24)).toBe(25); + expect(getChangeRate(18, 24)).toBe(-25); + expect(getChangeRate(24, 24)).toBe(0); + expect(getChangeRate(1, 3)).toBe(-67); + }); + + it("이전 값이 0이면 null", () => { + expect(getChangeRate(5, 0)).toBeNull(); + expect(getChangeRate(0, 0)).toBeNull(); + }); +}); diff --git a/src/app/(admin)/admin/_utils/getChangeRate/getChangeRate.ts b/src/app/(admin)/admin/_utils/getChangeRate/getChangeRate.ts new file mode 100644 index 0000000..46e7730 --- /dev/null +++ b/src/app/(admin)/admin/_utils/getChangeRate/getChangeRate.ts @@ -0,0 +1,7 @@ +/** + * 이전 값 대비 증감률(%)을 정수로 구합니다. 이전 값이 0이면 비율을 낼 수 없으므로 `null`을 돌려줍니다. + */ +export const getChangeRate = (current: number, previous: number): number | null => { + if (previous === 0) return null; + return Math.round(((current - previous) / previous) * 100); +}; diff --git a/src/app/(admin)/admin/_utils/getWeeklyRangeStart/getWeeklyRangeStart.test.ts b/src/app/(admin)/admin/_utils/getWeeklyRangeStart/getWeeklyRangeStart.test.ts new file mode 100644 index 0000000..0f0d255 --- /dev/null +++ b/src/app/(admin)/admin/_utils/getWeeklyRangeStart/getWeeklyRangeStart.test.ts @@ -0,0 +1,18 @@ +import { getWeeklyRangeStart } from "./getWeeklyRangeStart"; + +describe("getWeeklyRangeStart", () => { + it("이번 주를 포함한 최근 N주의 첫 월요일을 구함", () => { + // 2026-09-26(토) 한국 시간 오후 + const now = new Date("2026-09-26T07:00:00Z"); + + expect(getWeeklyRangeStart(now, 1)).toBe("2026-09-21"); + expect(getWeeklyRangeStart(now, 12)).toBe("2026-07-06"); + }); + + it("UTC로는 일요일이어도 한국 시간으로 월요일이면 그 주부터 셈", () => { + // 2026-09-28(월) 00:30 KST = 2026-09-27(일) 15:30 UTC + const now = new Date("2026-09-27T15:30:00Z"); + + expect(getWeeklyRangeStart(now, 1)).toBe("2026-09-28"); + }); +}); diff --git a/src/app/(admin)/admin/_utils/getWeeklyRangeStart/getWeeklyRangeStart.ts b/src/app/(admin)/admin/_utils/getWeeklyRangeStart/getWeeklyRangeStart.ts new file mode 100644 index 0000000..1f3269c --- /dev/null +++ b/src/app/(admin)/admin/_utils/getWeeklyRangeStart/getWeeklyRangeStart.ts @@ -0,0 +1,14 @@ +const DAY_MS = 24 * 60 * 60 * 1000; +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +/** + * 한국 시간 기준으로 이번 주를 포함한 최근 `weeks`주의 첫 월요일(`YYYY-MM-DD`)을 구합니다. + * 주간 차트의 첫 주가 중간에 잘리지 않도록 GA 조회 시작일을 월요일에 맞추는 데 씁니다. + */ +export const getWeeklyRangeStart = (now: Date, weeks: number) => { + const kstNow = new Date(now.getTime() + KST_OFFSET_MS); + const weekday = (kstNow.getUTCDay() + 6) % 7; + const start = kstNow.getTime() - (weekday + (weeks - 1) * 7) * DAY_MS; + + return new Date(start).toISOString().slice(0, 10); +}; diff --git a/src/app/(admin)/admin/_utils/isoWeekToMonday/isoWeekToMonday.test.ts b/src/app/(admin)/admin/_utils/isoWeekToMonday/isoWeekToMonday.test.ts new file mode 100644 index 0000000..a2c9a87 --- /dev/null +++ b/src/app/(admin)/admin/_utils/isoWeekToMonday/isoWeekToMonday.test.ts @@ -0,0 +1,9 @@ +import { isoWeekToMonday } from "./isoWeekToMonday"; + +describe("isoWeekToMonday", () => { + it("ISO 주차를 그 주 월요일로 바꿈", () => { + expect(isoWeekToMonday("202639")).toBe("2026-09-21"); + expect(isoWeekToMonday("202601")).toBe("2025-12-29"); + expect(isoWeekToMonday("202053")).toBe("2020-12-28"); + }); +}); diff --git a/src/app/(admin)/admin/_utils/isoWeekToMonday/isoWeekToMonday.ts b/src/app/(admin)/admin/_utils/isoWeekToMonday/isoWeekToMonday.ts new file mode 100644 index 0000000..e62ff07 --- /dev/null +++ b/src/app/(admin)/admin/_utils/isoWeekToMonday/isoWeekToMonday.ts @@ -0,0 +1,16 @@ +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * GA `isoYearIsoWeek` 값(`YYYYWW`)을 그 주 월요일 날짜(`YYYY-MM-DD`)로 바꿉니다. + * ISO 주차는 1월 4일이 들어 있는 주를 1주차로 셉니다. + */ +export const isoWeekToMonday = (isoYearIsoWeek: string) => { + const year = Number(isoYearIsoWeek.slice(0, 4)); + const week = Number(isoYearIsoWeek.slice(4)); + + const jan4 = Date.UTC(year, 0, 4); + const jan4Weekday = (new Date(jan4).getUTCDay() + 6) % 7; + const firstMonday = jan4 - jan4Weekday * DAY_MS; + + return new Date(firstMonday + (week - 1) * 7 * DAY_MS).toISOString().slice(0, 10); +}; diff --git a/src/app/(admin)/admin/_utils/toAnalyticsReport/toAnalyticsReport.test.ts b/src/app/(admin)/admin/_utils/toAnalyticsReport/toAnalyticsReport.test.ts new file mode 100644 index 0000000..a832a86 --- /dev/null +++ b/src/app/(admin)/admin/_utils/toAnalyticsReport/toAnalyticsReport.test.ts @@ -0,0 +1,140 @@ +import { toAnalyticsReport } from "./toAnalyticsReport"; + +const EMPTY = { rows: [] }; + +const row = (dimensions: string[], metrics: string[]) => ({ + dimensionValues: dimensions.map((value) => ({ value })), + metricValues: metrics.map((value) => ({ value })), +}); + +const headers = (...names: string[]) => names.map((name) => ({ name })); + +describe("toAnalyticsReport", () => { + it("현재 기간과 이전 기간 요약을 dateRange 이름으로 나눔", () => { + const result = toAnalyticsReport({ + summary: { + dimensionHeaders: headers("dateRange"), + rows: [row(["previous"], ["24", "22", "40", "70"]), row(["current"], ["30", "24", "55", "81"])], + }, + events: { + dimensionHeaders: headers("eventName", "dateRange"), + rows: [ + row(["post_complete", "current"], ["1"]), + row(["post_complete", "previous"], ["3"]), + row(["sign_up", "current"], ["2"]), + ], + }, + daily: EMPTY, + weekly: EMPTY, + searchTerms: EMPTY, + trafficSources: EMPTY, + topPages: EMPTY, + }); + + expect(result.summary.current).toEqual({ + activeUsers: 30, + newUsers: 24, + sessions: 55, + pageViews: 81, + signUps: 2, + postCompletes: 1, + }); + expect(result.summary.previous).toEqual({ + activeUsers: 24, + newUsers: 22, + sessions: 40, + pageViews: 70, + signUps: 0, + postCompletes: 3, + }); + }); + + it("일별, 주별 값을 날짜 순으로 정렬함", () => { + const result = toAnalyticsReport({ + summary: EMPTY, + events: EMPTY, + daily: { rows: [row(["20260926"], ["40"]), row(["20260924"], ["30"])] }, + weekly: { rows: [row(["202639"], ["90"]), row(["202638"], ["80"])] }, + searchTerms: EMPTY, + trafficSources: EMPTY, + topPages: EMPTY, + }); + + expect(result.daily).toEqual([ + { date: "2026-09-24", activeUsers: 30 }, + { date: "2026-09-26", activeUsers: 40 }, + ]); + expect(result.weekly).toEqual([ + { weekStart: "2026-09-14", activeUsers: 80 }, + { weekStart: "2026-09-21", activeUsers: 90 }, + ]); + }); + + it("검색어와 유입 경로에서 빈 값을 빼고 많은 순으로 정렬함", () => { + const result = toAnalyticsReport({ + summary: EMPTY, + events: EMPTY, + daily: EMPTY, + weekly: EMPTY, + searchTerms: { + rows: [row(["에어팟"], ["2"]), row(["(not set)"], ["9"]), row(["지갑 "], ["5"])], + }, + trafficSources: { + dimensionHeaders: headers("sessionSource", "sessionDefaultChannelGroup"), + rows: [row(["google", "Organic Search"], ["52"]), row(["(direct)", "Direct"], ["67"])], + }, + topPages: EMPTY, + }); + + expect(result.searchTerms).toEqual([ + { term: "지갑", count: 5 }, + { term: "에어팟", count: 2 }, + ]); + expect(result.trafficSources).toEqual([ + { source: "(direct)", channel: "Direct", users: 67 }, + { source: "google", channel: "Organic Search", users: 52 }, + ]); + }); + + it("같은 경로의 페이지는 합치고 조회수가 많은 제목을 쓰며 서비스 이름을 뗌", () => { + const result = toAnalyticsReport({ + summary: EMPTY, + events: EMPTY, + daily: EMPTY, + weekly: EMPTY, + searchTerms: EMPTY, + trafficSources: EMPTY, + topPages: { + dimensionHeaders: headers("pagePath", "pageTitle"), + rows: [ + row(["/", "우리 동네 분실물 찾기 | 찾아줘!"], ["149"]), + row(["/list/7", "수내동 검정 숄더백 분실 | 수내동 | 찾아줘! 분실"], ["26"]), + row(["/", "성수동 검색결과 | 찾아줘!"], ["5"]), + ], + }, + }); + + expect(result.topPages).toEqual([ + { path: "/", title: "우리 동네 분실물 찾기", views: 154 }, + { path: "/list/7", title: "수내동 검정 숄더백 분실 | 수내동", views: 26 }, + ]); + }); + + it("응답이 비어 있으면 0과 빈 목록으로 채움", () => { + const result = toAnalyticsReport({ + summary: {}, + events: {}, + daily: { rows: null }, + weekly: {}, + searchTerms: {}, + trafficSources: {}, + topPages: {}, + }); + + expect(result.summary.current.activeUsers).toBe(0); + expect(result.summary.previous.signUps).toBe(0); + expect(result.daily).toEqual([]); + expect(result.searchTerms).toEqual([]); + expect(result.topPages).toEqual([]); + }); +}); diff --git a/src/app/(admin)/admin/_utils/toAnalyticsReport/toAnalyticsReport.ts b/src/app/(admin)/admin/_utils/toAnalyticsReport/toAnalyticsReport.ts new file mode 100644 index 0000000..40773a2 --- /dev/null +++ b/src/app/(admin)/admin/_utils/toAnalyticsReport/toAnalyticsReport.ts @@ -0,0 +1,147 @@ +import { + AnalyticsReport, + AnalyticsSummaryValues, + GaReport, + GaReports, + TopPageView, +} from "../../_types/AnalyticsReport"; +import { isoWeekToMonday } from "../isoWeekToMonday/isoWeekToMonday"; + +const toNumber = (value?: string | null) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +/** GA의 `YYYYMMDD` 날짜를 `YYYY-MM-DD`로 바꿉니다. */ +const toIsoDate = (gaDate: string) => + `${gaDate.slice(0, 4)}-${gaDate.slice(4, 6)}-${gaDate.slice(6, 8)}`; + +const dimensionIndex = (report: GaReport, name: string) => + (report.dimensionHeaders ?? []).findIndex((header) => header.name === name); + +const dimension = (row: NonNullable[number], index: number) => + index < 0 ? "" : (row.dimensionValues?.[index]?.value ?? ""); + +/** 운영 앱 페이지 제목 끝의 서비스 이름을 떼어 목록에서 짧게 보이게 합니다. */ +const trimServiceName = (title: string) => title.replace(/\s*\|\s*찾아줘!.*$/, "").trim(); + +const IGNORED_VALUES = new Set(["", "(not set)", "(data not available)"]); + +/** + * 기간 비교가 들어간 요약 응답과 이벤트 응답에서 `current`, `previous` 두 기간의 값을 꺼냅니다. + * 여러 기간을 한 번에 조회하면 GA가 `dateRange` 측정기준을 붙여 기간 이름을 알려줍니다. + */ +const toSummaryValues = ( + summaryReport: GaReport, + eventReport: GaReport, + rangeName: string +): AnalyticsSummaryValues => { + const summaryRangeIndex = dimensionIndex(summaryReport, "dateRange"); + const summaryRow = (summaryReport.rows ?? []).find( + (row) => dimension(row, summaryRangeIndex) === rangeName + ); + const metrics = summaryRow?.metricValues ?? []; + + const eventNameIndex = dimensionIndex(eventReport, "eventName"); + const eventRangeIndex = dimensionIndex(eventReport, "dateRange"); + const eventCount = (eventName: string) => + toNumber( + (eventReport.rows ?? []).find( + (row) => + dimension(row, eventNameIndex) === eventName && + dimension(row, eventRangeIndex) === rangeName + )?.metricValues?.[0]?.value + ); + + return { + activeUsers: toNumber(metrics[0]?.value), + newUsers: toNumber(metrics[1]?.value), + sessions: toNumber(metrics[2]?.value), + pageViews: toNumber(metrics[3]?.value), + signUps: eventCount("sign_up"), + postCompletes: eventCount("post_complete"), + }; +}; + +/** 같은 경로가 제목만 다르게 여러 줄로 오므로 경로 기준으로 합치고, 조회수가 가장 많은 제목을 씁니다. */ +const toTopPages = (report: GaReport, limit: number): TopPageView[] => { + const pathIndex = dimensionIndex(report, "pagePath"); + const titleIndex = dimensionIndex(report, "pageTitle"); + const pages = new Map(); + + (report.rows ?? []).forEach((row) => { + const path = dimension(row, pathIndex); + if (IGNORED_VALUES.has(path)) return; + + const title = trimServiceName(dimension(row, titleIndex)); + const views = toNumber(row.metricValues?.[0]?.value); + const page = pages.get(path); + + if (!page) { + pages.set(path, { path, title, views, titleViews: views }); + return; + } + + page.views += views; + if (views > page.titleViews) { + page.title = title; + page.titleViews = views; + } + }); + + return [...pages.values()] + .sort((a, b) => b.views - a.views) + .slice(0, limit) + .map(({ path, title, views }) => ({ path, title, views })); +}; + +export const RANKING_LIMIT = 10; + +/** GA Data API 응답들을 화면에서 쓰는 형태로 바꿉니다. 값이 없는 지표는 0으로 채웁니다. */ +export const toAnalyticsReport = (reports: GaReports): AnalyticsReport => { + const daily = (reports.daily.rows ?? []) + .map((row) => ({ + date: toIsoDate(row.dimensionValues?.[0]?.value ?? ""), + activeUsers: toNumber(row.metricValues?.[0]?.value), + })) + .sort((a, b) => a.date.localeCompare(b.date)); + + const weekly = (reports.weekly.rows ?? []) + .map((row) => ({ + weekStart: isoWeekToMonday(row.dimensionValues?.[0]?.value ?? ""), + activeUsers: toNumber(row.metricValues?.[0]?.value), + })) + .sort((a, b) => a.weekStart.localeCompare(b.weekStart)); + + const searchTerms = (reports.searchTerms.rows ?? []) + .map((row) => ({ + term: (row.dimensionValues?.[0]?.value ?? "").trim(), + count: toNumber(row.metricValues?.[0]?.value), + })) + .filter((item) => !IGNORED_VALUES.has(item.term)) + .sort((a, b) => b.count - a.count) + .slice(0, RANKING_LIMIT); + + const sourceIndex = dimensionIndex(reports.trafficSources, "sessionSource"); + const channelIndex = dimensionIndex(reports.trafficSources, "sessionDefaultChannelGroup"); + const trafficSources = (reports.trafficSources.rows ?? []) + .map((row) => ({ + source: dimension(row, sourceIndex), + channel: dimension(row, channelIndex), + users: toNumber(row.metricValues?.[0]?.value), + })) + .sort((a, b) => b.users - a.users) + .slice(0, RANKING_LIMIT); + + return { + summary: { + current: toSummaryValues(reports.summary, reports.events, "current"), + previous: toSummaryValues(reports.summary, reports.events, "previous"), + }, + daily, + weekly, + searchTerms, + trafficSources, + topPages: toTopPages(reports.topPages, RANKING_LIMIT), + }; +}; diff --git a/src/app/(admin)/admin/analytics/_docs/plan.md b/src/app/(admin)/admin/analytics/_docs/plan.md new file mode 100644 index 0000000..f17a113 --- /dev/null +++ b/src/app/(admin)/admin/analytics/_docs/plan.md @@ -0,0 +1,43 @@ +# analytics 작업 계획 + +## GA 서비스 통계 대시보드 (2026-09-26) + +- [x] `@google-analytics/data` 설치와 서버 전용 GA 조회 유틸 추가 (env: `GA_PROPERTY_ID`, `GA_CLIENT_EMAIL`, `GA_PRIVATE_KEY`) +- [x] 기간 선택(7일, 30일, 90일)을 쿼리 파라미터로 받아 요약 지표 조회: 활성 사용자, 신규 사용자, 세션, 페이지뷰, 가입(`sign_up`), 게시글 작성(`post_complete`) +- [x] 일별 활성 사용자 추이 차트 +- [x] env가 없거나 조회에 실패하면 설정 안내와 오류 문구 표시 +- [x] 사이드 메뉴에 "서비스 통계" 항목 추가 +- [x] 요약 지표 변환 유틸 단위 테스트 작성 (2026-09-26 Jest, tsc 통과) + +## 확인된 사실 + +- GA 속성 ID는 510805727(FMI-Analytics)이다. +- 2026-09-26 기준 최근 28일 이벤트에 `sign_up`이 없다. 운영 앱은 가입 완료 시 `sign_up`을 보내도록 되어 있지만 수집되지 않고 있어, 가입 수는 백엔드 집계로 바꾸는 것을 검토해야 한다. +- [x] 서비스 계정(ga-reader@finditem-admin) 생성, GA 속성 뷰어 권한 부여, 로컬 env 등록, 실제 조회 확인 (최근 7일 방문자 30명) +- [ ] dev 서버에서 화면으로 확인 +- [ ] Vercel 환경 변수 등록 +- [ ] 가입 수를 백엔드 집계로 받을지 결정 (`sign_up` 미수집 문제) + +## 화면 피드백 반영 (2026-09-26) + +- [x] 선택된 기간 버튼 글자가 배경과 같은 색으로 보이던 문제 수정 (필터 버튼과 같은 토큰 사용) +- [x] PC 관리자 메인(`/admin`)에 서비스 통계 표시. 모바일 메인은 기존 프로필과 메뉴 유지 +- [x] 대시보드를 관리자 메인과 이 화면에서 함께 쓰도록 `admin/_components/AnalyticsDashboard`로 올림. 서버 전용이라 `_components/index.ts` 묶음에는 넣지 않음 +- [x] 사이드바 하단 메뉴가 화면 높이에서 잘리던 문제 수정 (고정 대신 본문과 함께 스크롤) +- [x] 주간 방문자(최근 12주, ISO 주 기준) 차트 추가 +- [x] 막대에 마우스를 올리면 기간과 방문자 수 표시 +- [x] 스크린 리더용 표가 페이지 높이를 늘리던 문제 수정 +- [ ] 모바일 폭 화면 확인 + +## 대시보드 확장 (2026-09-26) + +- [x] 요약 카드에 바로 앞 같은 길이 기간 대비 증감률 표시 (`getChangeRate`) +- [x] 인기 검색어 (GA `search` 이벤트의 `searchTerm`) +- [x] 유입 경로 (`sessionSource`, `sessionDefaultChannelGroup` 한국어 표기) +- [x] 많이 본 페이지 (`pagePath` 기준으로 합치고 운영 앱 링크 연결) +- [x] PC 배치: 카드 3열, 일별과 주간 그래프 2열, 순위 목록 3열 +- [x] `@google-analytics/data`를 `serverExternalPackages`로 번들에서 제외 (google-gax 경고 해소) +- [x] 로그인한 화면에서 새 배치 확인 +- [x] 사이드바를 다시 화면에 고정하고 메뉴 영역만 안에서 스크롤, 스크롤바 항상 표시 +- [x] 유입 경로를 방문 횟수 대신 방문자 수(기간 중복 제거) 기준으로 변경 +- [x] GA 웹 스트림 태그 설정의 "원치 않는 추천"에 `accounts.kakao.com` 등록 (카카오 로그인 복귀가 유입으로 잡히던 문제, 지난 30일 방문 52회 중 실제 방문자 4명) diff --git a/src/app/(admin)/admin/analytics/layout.tsx b/src/app/(admin)/admin/analytics/layout.tsx new file mode 100644 index 0000000..16b77f7 --- /dev/null +++ b/src/app/(admin)/admin/analytics/layout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "서비스 통계", + other: { "page-type": "admin-analytics" }, +}; + +const layout = ({ children }: { children: ReactNode }) => { + return <>{children}; +}; + +export default layout; diff --git a/src/app/(admin)/admin/analytics/page.tsx b/src/app/(admin)/admin/analytics/page.tsx new file mode 100644 index 0000000..e6d58c4 --- /dev/null +++ b/src/app/(admin)/admin/analytics/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; +import { DetailHeader } from "@/components"; +import { hasValidToken } from "@/utils/hasValidToken/hasValidToken"; +import AnalyticsDashboard from "../_components/AnalyticsDashboard/AnalyticsDashboard"; + +interface PageProps { + searchParams: Promise<{ range?: string }>; +} + +const page = async ({ searchParams }: PageProps) => { + const hasToken = await hasValidToken(); + if (!hasToken) redirect("/login"); + + const { range } = await searchParams; + + return ( +
    + +

    서비스 통계

    + + +
    + ); +}; + +export default page; diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index 0068069..460010d 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -1,11 +1,18 @@ import { hasValidToken } from "@/utils/hasValidToken/hasValidToken"; import { AdminMenuSection, AdminProfile } from "./_components"; +import AnalyticsDashboard from "./_components/AnalyticsDashboard/AnalyticsDashboard"; import { redirect } from "next/navigation"; -const page = async () => { +interface PageProps { + searchParams: Promise<{ range?: string }>; +} + +const page = async ({ searchParams }: PageProps) => { const hasToken = await hasValidToken(); if (!hasToken) redirect("/login"); + const { range } = await searchParams; + return (

    관리자 페이지

    @@ -15,9 +22,16 @@ const page = async () => {
    -

    - 왼쪽 메뉴에서 관리할 항목을 선택해 주세요. -

    + {/* PC에서는 사이드바가 메뉴를 맡으므로 메인에 서비스 통계를 바로 보여준다. */} +
    +

    + 서비스 통계 +

    + +
    ); };