From 91923da7a11c5543d1d15174cd612a88a14298e8 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 23 Aug 2026 21:02:35 +0900 Subject: [PATCH 01/20] =?UTF-8?q?chore(deps):=20=EC=84=A0=EC=96=B8=20?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20=EC=93=B0=EB=8D=98=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=AA=85=EC=8B=9C=ED=95=98=EA=B3=A0=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit import 하면서 package.json 에 없던 패키지가 6개 있었다. framer-motion 은 motion 의, radix 서브패키지 5개는 radix-ui 모놀리스의 전이 의존성 호이스팅으로만 동작했다. npm 에서는 우연히 되지만 pnpm·yarn PnP 로 옮기면 그날로 빌드가 깨진다. - 직접 import 하는 radix 서브패키지 5개를 선언하고 쓰지 않는 radix-ui 를 제거 - 코드·CSS 어디에서도 쓰지 않는 @skeletonlabs/skeleton 2종 제거 - firebase 를 정확 버전으로 고정. 서비스 워커가 CDN URL 에 버전을 박아 쓰므로 캐럿을 두면 번들과 조용히 어긋난다 - next lint 는 15.3 에서 deprecated 라 eslint 를 직접 호출. flat config 는 node_modules 만 기본 제외해서, .next 를 무시하지 않으면 빌드 산출물까지 훑느라 린트가 끝나지 않는다 - CI(typecheck·lint·test·build)와 pre-commit 훅 추가. README 는 pre-commit 이 있다고 적혀 있었지만 실제로는 commit-msg 뿐이었다 - .env.example 추가. .gitignore 의 .env* 때문에 커밋 자체가 불가능했다 --- .env.example | 27 + .github/workflows/ci.yml | 39 ++ .gitignore | 2 + .husky/pre-commit | 1 + eslint.config.mjs | 9 + next.config.ts | 4 +- package-lock.json | 1438 +++++--------------------------------- package.json | 16 +- 8 files changed, 276 insertions(+), 1260 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .husky/pre-commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9ca14b9 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# CareCode 백엔드 베이스 URL +# 백엔드의 CORS_ALLOWED_ORIGINS 에 이 앱의 주소(http://localhost:3000)가 들어 있어야 한다. +NEXT_PUBLIC_API_URL=http://localhost:8080 + +# ── 웹 푸시(FCM) — 선택 ─────────────────────────────────────────────── +# 아래 6개가 "모두" 채워져야 푸시 기능이 켜진다(isPushConfigured). +# 하나라도 비어 있으면 알림 설정 화면에서 푸시 토글이 잠기고 기기 등록 버튼이 숨는다. +# FCM 웹 설정값은 원래 공개되는 값이라 비밀이 아니다. +NEXT_PUBLIC_FIREBASE_API_KEY= +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_PROJECT_ID= +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= +NEXT_PUBLIC_FIREBASE_APP_ID= +NEXT_PUBLIC_FIREBASE_VAPID_KEY= + +# ── 개발용 빠른 로그인 — 선택 (개발 서버에서만 동작) ────────────────── +# 카카오 로그인은 실제 앱 키가 있어야 해서 로컬에서는 쓸 수 없다. +# 아래 둘을 채우면 로그인 화면에 [DEV] 버튼이 생겨 그 계정으로 바로 들어간다. +# 둘 중 하나라도 비면 버튼이 렌더되지 않고, 프로덕션 빌드에서는 코드째 빠진다. +# +# 계정은 백엔드에 직접 만든다: +# curl -X POST http://localhost:8082/auth/register \ +# -H 'Content-Type: application/json' \ +# -d '{"email":"dev@carecode.local","password":"devpassword123!","name":"dev","role":"PARENT"}' +# role 을 ADMIN 으로 주면 /admin 화면까지 확인할 수 있다. +NEXT_PUBLIC_DEV_LOGIN_EMAIL= +NEXT_PUBLIC_DEV_LOGIN_PASSWORD= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..19186cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + # 단계를 나눠 두면 어디서 깨졌는지 로그를 뒤지지 않아도 보인다. + # 앞 단계가 실패해도 뒤 단계 결과를 함께 보려면 `if: always()` 를 쓴다 — + # PR 한 번에 고칠 거리를 모두 알려 주는 편이 왕복을 줄인다. + - name: Typecheck + run: npm run typecheck + + - name: Lint + if: always() + run: npm run lint + + - name: Test + if: always() + run: npm test + + # 스키마 계약 테스트가 통과해도 빌드가 깨질 수 있다(서버 컴포넌트 경계 등). + - name: Build + run: npm run build + env: + NEXT_PUBLIC_API_URL: http://localhost:8080 diff --git a/.gitignore b/.gitignore index 7f64c1a..65ec269 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# 값이 없는 템플릿은 커밋한다. README 가 `cp .env.example .env.local` 을 안내한다. +!.env.example # vercel .vercel diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..3867a0f --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run lint diff --git a/eslint.config.mjs b/eslint.config.mjs index fc29375..0fa33d0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,15 @@ const compat = new FlatCompat({ }) const eslintConfig = [ + { + /** + * flat config 는 `node_modules` 만 기본으로 건너뛴다. + * 빌드 산출물을 빼 두지 않으면 `eslint .` 이 `.next/` 안의 생성 코드까지 훑어 + * 몇 분씩 걸린다. + */ + ignores: ['.next/**', 'out/**', 'build/**', 'coverage/**', 'next-env.d.ts'], + }, + // Next.js 기본 설정 ...fixupConfigRules(compat.extends('next/core-web-vitals', 'next/typescript')), diff --git a/next.config.ts b/next.config.ts index 467929d..2418a7d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,7 +8,9 @@ const nextConfig: NextConfig = { * 컴포넌트 갤러리(/component-test) 처럼 내부 확인용 화면이 프로덕션 번들에 * 섞여 나가지 않도록 확장자로 걸러낸다. */ - pageExtensions: isDevelopment ? ['tsx', 'ts', 'jsx', 'js', 'dev.tsx'] : ['tsx', 'ts', 'jsx', 'js'], + pageExtensions: isDevelopment + ? ['tsx', 'ts', 'jsx', 'js', 'dev.tsx'] + : ['tsx', 'ts', 'jsx', 'js'], turbopack: { rules: { '*.svg': { diff --git a/package-lock.json b/package-lock.json index 1480e62..7817bf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,21 @@ "version": "0.1.0", "dependencies": { "@lukemorales/query-key-factory": "^1.3.4", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.18", "@tanstack/react-query": "^5.85.3", "@tanstack/react-query-devtools": "^5.85.3", "axios": "^1.11.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "firebase": "^12.17.1", + "firebase": "12.17.1", "motion": "12.23.0", "next": "15.3.5", - "radix-ui": "^1.4.2", "react": "19.0.0", "react-dom": "19.0.0", "react-hook-form": "7.60.0", @@ -31,8 +35,6 @@ "@commitlint/config-conventional": "^19.8.1", "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", - "@skeletonlabs/skeleton": "3.1.4", - "@skeletonlabs/skeleton-react": "1.2.3", "@svgr/webpack": "^8.1.0", "@tailwindcss/postcss": "4", "@testing-library/dom": "^10.4.1", @@ -44,6 +46,7 @@ "@types/react-dom": "19", "@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/parser": "^8.39.1", + "axios-mock-adapter": "^2.1.0", "eslint": "9", "eslint-config-next": "15.3.5", "eslint-config-prettier": "10.1.5", @@ -4684,68 +4687,11 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==" }, - "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", - "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", - "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-accordion": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.11.tgz", - "integrity": "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collapsible": "1.1.11", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-alert-dialog": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz", @@ -4795,112 +4741,6 @@ } } }, - "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", - "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", - "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", - "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", - "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz", - "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -4954,33 +4794,6 @@ } } }, - "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.15.tgz", - "integrity": "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-menu": "2.1.15", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", @@ -5086,402 +4899,26 @@ }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", - "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-form": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.7.tgz", - "integrity": "sha512-IXLKFnaYvFg/KkeV5QfOX7tRnwHXp127koOFUjLWMTrRv5Rny3DQcAtIFFeA/Cli4HHM8DuJCXAUsgnFVJndlw==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.14.tgz", - "integrity": "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-popper": "1.2.7", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", - "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", - "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.7", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.10", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menubar": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.15.tgz", - "integrity": "sha512-Z71C7LGD+YDYo3TV81paUs8f3Zbmkvg6VLRQpKYfzioOE6n7fOhA3ApK/V/2Odolxjoc4ENk8AYCjohCNayd5A==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.15", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz", - "integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.7.tgz", - "integrity": "sha512-w1vm7AGI8tNXVovOK7TYQHrAGpRF7qQL+ENpT1a743De5Zmay2RbWGKAiYDKIyIuqptns+znCKwNztE2xl1n0Q==", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.2.tgz", - "integrity": "sha512-F90uYnlBsLPU1UbSLciLsWQmk8+hdWa6SFw4GXaIdNWxFxI5ITKVdAG64f+Twaa9ic6xE7pqxPyUmodrGjT4pQ==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", - "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.7", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", - "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", - "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -5498,34 +4935,28 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-progress": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", - "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", "dependencies": { - "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { @@ -5543,21 +4974,29 @@ } } }, - "node_modules/@radix-ui/react-radio-group": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz", - "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==", + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", "dependencies": { "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -5574,20 +5013,21 @@ } } }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", - "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.7", + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -5604,19 +5044,12 @@ } } }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", - "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { @@ -5634,32 +5067,13 @@ } } }, - "node_modules/@radix-ui/react-select": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", - "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.7", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -5676,12 +5090,12 @@ } } }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", - "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -5698,22 +5112,20 @@ } } }, - "node_modules/@radix-ui/react-slider": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", - "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==", + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", "dependencies": { - "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -5862,23 +5274,15 @@ } } }, - "node_modules/@radix-ui/react-toast": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", - "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -5895,14 +5299,34 @@ } } }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.9.tgz", - "integrity": "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -5919,91 +5343,73 @@ } } }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.10.tgz", - "integrity": "sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.10", - "@radix-ui/react-toggle": "1.1.9", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.10.tgz", - "integrity": "sha512-jiwQsduEL++M4YBIurjSa+voD86OIytCod0/dbIxFZDLD8NfO1//keXYMfsW8BPcfqwoNjt+y06XcJqAb4KR7A==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.10", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-toggle-group": "1.1.10" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", - "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.7", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { + } + } + }, + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { "optional": true } } @@ -6074,23 +5480,6 @@ } } }, - "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", - "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", - "dependencies": { - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -6139,39 +5528,17 @@ "node_modules/@radix-ui/react-use-size": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, @@ -6583,41 +5950,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@skeletonlabs/skeleton": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@skeletonlabs/skeleton/-/skeleton-3.1.4.tgz", - "integrity": "sha512-sp7+FZN9bYR4ULtVop39bZ7a7l9tbu/VCwlgJxh2YQ9hrx85Rh0If+VYajPL18eD4CsnIOmsltEspBuRSjQYVw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "tailwindcss": "^4.0.0" - } - }, - "node_modules/@skeletonlabs/skeleton-react": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@skeletonlabs/skeleton-react/-/skeleton-react-1.2.3.tgz", - "integrity": "sha512-oJrd+qmvOILUEpDt3wMleLswBr3E7wz86WRUumy2ti0OhBtgz9CZNBybKvUyDYCOXooJcdT+Nsy2eBZuIwSUVw==", - "dev": true, - "dependencies": { - "@zag-js/accordion": "^1.7.0", - "@zag-js/avatar": "^1.7.0", - "@zag-js/file-upload": "^1.7.0", - "@zag-js/pagination": "^1.7.0", - "@zag-js/progress": "^1.7.0", - "@zag-js/radio-group": "^1.7.0", - "@zag-js/rating-group": "^1.7.0", - "@zag-js/react": "^1.7.0", - "@zag-js/slider": "^1.7.0", - "@zag-js/switch": "^1.7.0", - "@zag-js/tabs": "^1.7.0", - "@zag-js/tags-input": "^1.7.0", - "@zag-js/toast": "^1.7.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -8148,324 +7480,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@zag-js/accordion": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/accordion/-/accordion-1.18.2.tgz", - "integrity": "sha512-d9hCE7ECTPk1YrEq/6DwedArWUkSFzB/av9ocensXs2QTq9tr/FOEIWpkG+2YnIAwm9HneXV5R+9APRPqMS7ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/anatomy": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-1.18.2.tgz", - "integrity": "sha512-GxwOUfSDrnwU4oROohKBy0TRKPlYjD0dhuFHo52ZJLSPDkr8H8DlE/y3rFlb6BaGVO/bHjCUeJlaZzZgIpFK0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@zag-js/auto-resize": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/auto-resize/-/auto-resize-1.18.2.tgz", - "integrity": "sha512-0q6MponcybbcMVVPg1uFoTadvL1Zk3yYvsgC20Jm0sg98MdhwELnX3rpePYrPyxYZD1Z6OdOc4ZEdV4drTsosw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/dom-query": "1.18.2" - } - }, - "node_modules/@zag-js/avatar": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/avatar/-/avatar-1.18.2.tgz", - "integrity": "sha512-CADyLk6T436zRrZcfRBuqX5tcjzBZuDq1PYhHGY2+3buPvZVb77Zc2S/fE5oD89Wv89H7FBi6gs5JKPs+FTrxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/core": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-1.18.2.tgz", - "integrity": "sha512-feKLPL8OMJIegwiGwQwoKI4iB9vA/Gf4d5IOZ+KH0X/5S4lCJ3dswmki+Jtu2Ce2PiyYc7oClvG3CM5mfywtfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/dom-query": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/dismissable": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/dismissable/-/dismissable-1.18.2.tgz", - "integrity": "sha512-uv4FE62TuxWR/wSdr3wfQ9GRW2EHJYt4/HvhVH+mFno2JVRwm9/rSHDUc6QILabXrDVfnp/PdkPJ1rtsIzoOGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/dom-query": "1.18.2", - "@zag-js/interact-outside": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/dom-query": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.18.2.tgz", - "integrity": "sha512-/yUfu4u527vL32mDYwoziEWfLLWfIBenwBo/v8JcDVJwrtBw/1OEPFU7lK9iDa7BAKaIBAGhY0pwsiFLT5UxzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/types": "1.18.2" - } - }, - "node_modules/@zag-js/file-upload": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/file-upload/-/file-upload-1.18.2.tgz", - "integrity": "sha512-R1wG9svz0zyhQ2WAZ2Vahk2LSSXi2e3IOOyvelnblsnW4vSbtxtY84nVA1qsvi9WRNq29JqUh13SH66KKYQftQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/file-utils": "1.18.2", - "@zag-js/i18n-utils": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/file-utils": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/file-utils/-/file-utils-1.18.2.tgz", - "integrity": "sha512-7zKji+vCMWB0xinUDNaVUq1AqewaCLMu9hWHbsbqajmd80VeCy7wfwm6i18ETCHmh1iMA4AYQoINjDB7+7TJ7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/i18n-utils": "1.18.2" - } - }, - "node_modules/@zag-js/focus-visible": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-1.18.2.tgz", - "integrity": "sha512-6l9bW3yLGKpFM250i/ecn86hPiysAHi0JDjs5V47W2cwHnK0VkeNtE4289ko1s70hZ5YFcLQkSS1OOHGPhzPJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/dom-query": "1.18.2" - } - }, - "node_modules/@zag-js/i18n-utils": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/i18n-utils/-/i18n-utils-1.18.2.tgz", - "integrity": "sha512-Q4pDT2Km4ZHzZ1CufU1K3ZJFctDiPBmAYmuoRrU3QiVsqlDer0siZijRnHKf0VH5cqF6qlstRchA8qNDlzYfQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/dom-query": "1.18.2" - } - }, - "node_modules/@zag-js/interact-outside": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/interact-outside/-/interact-outside-1.18.2.tgz", - "integrity": "sha512-X2S3h/+MM5I83EnWihR2eHJYd1xbqfWeCO+Lz05V6+aWmJHpRPrniHGoVKyKocpSqmgQPrUMqY9ONmVuq4EPRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/dom-query": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/live-region": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/live-region/-/live-region-1.18.2.tgz", - "integrity": "sha512-V1VCv/f3j3YLzNxYGFzLYFQI7dW94UGryqwb3jAWfmqC8rlndupq44QN8KLn3xI/i/zyUK27Dq9gYGMKFEJwSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@zag-js/pagination": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/pagination/-/pagination-1.18.2.tgz", - "integrity": "sha512-iT0GYwMKYfWjAtL/mDIuWp9fib/wJ2OMsdb1reMhpQkm7OHaE9Xij3x4SrvqtCCiNNFpxL7APQe5MBLC9YyXYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/progress": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/progress/-/progress-1.18.2.tgz", - "integrity": "sha512-O8CUVbunMutBWuHyuX5LnbI1dpwqJahiRSWecV7Lv3z2zvuMIUFNQ1MhOpNN0UowF+GXIDZR7Iv7Vw+hm1LAjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/radio-group": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/radio-group/-/radio-group-1.18.2.tgz", - "integrity": "sha512-GD0gIpFx4NXCUp94/w2/JXpMB/AailsqKRG4FCZoXx6MeBTw05O7/Uhg+TvRSwCvxDRgOCWoRr4n/RirtqCDBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/focus-visible": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/rating-group": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/rating-group/-/rating-group-1.18.2.tgz", - "integrity": "sha512-17ax62srNLXG2X5l3+zWpbKa3TGDdNPJxIX1Zvj7vfNcHR4JJGMUUKLfUVKnrhj7aKWltfETQ9yuPRcEBtjmtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/react": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/react/-/react-1.18.2.tgz", - "integrity": "sha512-J7xPcls/Bw2j2U3VArpJDfMHv2DTH3aULCqdl6IDv+ekngWnzqxCXISaSOt4fFEWs7YKhA2XqA7vQEjkyh3YSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/core": "1.18.2", - "@zag-js/store": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, - "node_modules/@zag-js/slider": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/slider/-/slider-1.18.2.tgz", - "integrity": "sha512-Mcq/WPMWL84AAGwGM72aQJH3JBW9SFCFcL9fEMlFhDcAMAiewzGYyEDxeHq2mdiIFJuCLUog4gZR3r72HtbTeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/store": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-1.18.2.tgz", - "integrity": "sha512-3oqkRjRz7dRb0fqkp6rCvfTiQBEURi79AG46B9XJzdK8ntRI5xHw5kFkGtVXK/OjTaN0WTs5zjBi5LxF+7UYdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "proxy-compare": "3.0.1" - } - }, - "node_modules/@zag-js/switch": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/switch/-/switch-1.18.2.tgz", - "integrity": "sha512-QA/aP+dmhK4N1pZoHA0nCzPnI+IOmBT4TzG66Cb/nMFpJrFMndVSLZznlMySThR+dMnkhDH44pR7v+hyAJh7cA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/focus-visible": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/tabs": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/tabs/-/tabs-1.18.2.tgz", - "integrity": "sha512-ZjJtngFsKHOX+achg8eNo9xeTv7XtNFF/6zoNhfu8uT0C7pBDSL8LmPVAcv4lkSKGRnyVnY14pIio5so4CkLLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/tags-input": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/tags-input/-/tags-input-1.18.2.tgz", - "integrity": "sha512-Y8mDNzTOrabQxSgxhTSlNqof60nUDGn7UP1zbvoJHU+G6I7U9ApS3vKllBgdozCMnoLCzsWITI7SaiSFDhYDjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/auto-resize": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/interact-outside": "1.18.2", - "@zag-js/live-region": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/toast": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/toast/-/toast-1.18.2.tgz", - "integrity": "sha512-ithIftfa18XaGYoPw/q7vhJ3/R32Aq/0dbk7znueVD4bNVqD+XOJ0DoviKufu2WnlK7OpnmddPgxmqcjIIxEEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@zag-js/anatomy": "1.18.2", - "@zag-js/core": "1.18.2", - "@zag-js/dismissable": "1.18.2", - "@zag-js/dom-query": "1.18.2", - "@zag-js/types": "1.18.2", - "@zag-js/utils": "1.18.2" - } - }, - "node_modules/@zag-js/types": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-1.18.2.tgz", - "integrity": "sha512-iyKwrhRLbMs+y22j8PdqdW7waIo98jbneNI4MmXOVbQUe2AgSfDnapL/JuO58hJ7vshdYrmkcoMzehwNSkYKXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "3.1.3" - } - }, - "node_modules/@zag-js/utils": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.18.2.tgz", - "integrity": "sha512-tGrG2Qnm5qf95VJEBHunrEDHO0OJZGU81FoZU2VNC+YkRmD4C1phcvyxVLklDFT569rNxIHmFn/6gr9D7HmPrQ==", - "dev": true, - "license": "MIT" - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -8795,6 +7809,20 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -11431,6 +10459,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -13401,13 +12453,6 @@ "node": ">=12.0.0" } }, - "node_modules/proxy-compare": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", - "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", - "dev": true, - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -13445,111 +12490,6 @@ ], "license": "MIT" }, - "node_modules/radix-ui": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.2.tgz", - "integrity": "sha512-fT/3YFPJzf2WUpqDoQi005GS8EpCi+53VhcLaHUj5fwkPYiZAjk1mSxFvbMA8Uq71L03n+WysuYC+mlKkXxt/Q==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-accessible-icon": "1.1.7", - "@radix-ui/react-accordion": "1.2.11", - "@radix-ui/react-alert-dialog": "1.1.14", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-aspect-ratio": "1.1.7", - "@radix-ui/react-avatar": "1.1.10", - "@radix-ui/react-checkbox": "1.3.2", - "@radix-ui/react-collapsible": "1.1.11", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-context-menu": "2.2.15", - "@radix-ui/react-dialog": "1.1.14", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-dropdown-menu": "2.1.15", - "@radix-ui/react-focus-guards": "1.1.2", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-form": "0.1.7", - "@radix-ui/react-hover-card": "1.1.14", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-menu": "2.1.15", - "@radix-ui/react-menubar": "1.1.15", - "@radix-ui/react-navigation-menu": "1.2.13", - "@radix-ui/react-one-time-password-field": "0.1.7", - "@radix-ui/react-password-toggle-field": "0.1.2", - "@radix-ui/react-popover": "1.1.14", - "@radix-ui/react-popper": "1.2.7", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-progress": "1.1.7", - "@radix-ui/react-radio-group": "1.3.7", - "@radix-ui/react-roving-focus": "1.1.10", - "@radix-ui/react-scroll-area": "1.2.9", - "@radix-ui/react-select": "2.2.5", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-slider": "1.3.5", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-switch": "1.2.5", - "@radix-ui/react-tabs": "1.1.12", - "@radix-ui/react-toast": "1.2.14", - "@radix-ui/react-toggle": "1.1.9", - "@radix-ui/react-toggle-group": "1.1.10", - "@radix-ui/react-toolbar": "1.1.10", - "@radix-ui/react-tooltip": "1.2.7", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-escape-keydown": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/radix-ui/node_modules/@radix-ui/react-tabs": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz", - "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", - "dependencies": { - "@radix-ui/primitive": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.4", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/re2js": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", @@ -15200,14 +14140,6 @@ } } }, - "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", diff --git a/package.json b/package.json index 109b2b9..3836166 100644 --- a/package.json +++ b/package.json @@ -6,25 +6,30 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "prepare": "husky install" + "prepare": "husky", + "lint:fix": "eslint . --fix" }, "dependencies": { "@lukemorales/query-key-factory": "^1.3.4", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.18", "@tanstack/react-query": "^5.85.3", "@tanstack/react-query-devtools": "^5.85.3", "axios": "^1.11.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "firebase": "^12.17.1", + "firebase": "12.17.1", "motion": "12.23.0", "next": "15.3.5", - "radix-ui": "^1.4.2", "react": "19.0.0", "react-dom": "19.0.0", "react-hook-form": "7.60.0", @@ -36,8 +41,6 @@ "@commitlint/config-conventional": "^19.8.1", "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", - "@skeletonlabs/skeleton": "3.1.4", - "@skeletonlabs/skeleton-react": "1.2.3", "@svgr/webpack": "^8.1.0", "@tailwindcss/postcss": "4", "@testing-library/dom": "^10.4.1", @@ -49,6 +52,7 @@ "@types/react-dom": "19", "@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/parser": "^8.39.1", + "axios-mock-adapter": "^2.1.0", "eslint": "9", "eslint-config-next": "15.3.5", "eslint-config-prettier": "10.1.5", From 081a2e5a069a3f3ec7fa88a000c4fa7b1444134a Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 23 Aug 2026 21:03:55 +0900 Subject: [PATCH 02/20] =?UTF-8?q?fix(security):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=8D=95=EC=85=98=20=EB=A1=9C=EA=B7=B8=EC=99=80=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EC=B0=BD=EC=9C=BC=EB=A1=9C=20=EC=83=88=EB=8D=98=20?= =?UTF-8?q?=EB=AF=BC=EA=B0=90=EC=A0=95=EB=B3=B4=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 응답 인터셉터만 isDevelopment 가드가 빠져 있어 모든 API 응답 본문이 프로덕션 콘솔에 찍혔다. 이 앱은 건강기록·개인정보를 다루므로 그대로 두면 안 된다. - printResponseConsole 을 개발 환경으로 제한 (요청·에러는 이미 그랬다) - 개발 로그의 Authorization 헤더를 마스킹. 붙었는지만 알면 되고, 전문을 찍으면 화면 공유·녹화에 그대로 남는다 - 카카오 인가 코드 console.log 제거하고 history.replaceState 로 주소창에서 제거. 일회용이지만 크리덴셜이라 히스토리·리퍼러에 남길 이유가 없다 - 콜백에서 push 대신 replace 를 쓴다. 뒤로 가기로 돌아오면 소진된 코드로 재시도한다 --- src/apis/interceptor.ts | 4 +++- src/app/auth/kakao/callback/page.tsx | 35 +++++++++++----------------- src/utils/console.ts | 11 ++++++++- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/apis/interceptor.ts b/src/apis/interceptor.ts index aa534ff..0747cf8 100644 --- a/src/apis/interceptor.ts +++ b/src/apis/interceptor.ts @@ -57,7 +57,9 @@ const redirectToLogin = (): void => { // 401 발생 시 refresh + 재시도 CareCode.interceptors.response.use( (res: AxiosResponse) => { - printResponseConsole(res) + // 요청·에러와 마찬가지로 개발 환경에서만 찍는다. + // 이 앱은 건강기록·개인정보를 다루므로 응답 본문이 프로덕션 콘솔에 남으면 안 된다. + if (isDevelopment) printResponseConsole(res) return res }, async (error) => { diff --git a/src/app/auth/kakao/callback/page.tsx b/src/app/auth/kakao/callback/page.tsx index 7c99b04..73f4c56 100644 --- a/src/app/auth/kakao/callback/page.tsx +++ b/src/app/auth/kakao/callback/page.tsx @@ -14,11 +14,10 @@ const KakaoCallbackContent = (): ReactElement | null => { useEffect(() => { const code = searchParams.get('code') - console.log('Kakao authorization code:', code) if (!code) { - console.error('No authorization code found') - router.push('/') + console.error('카카오 인가 코드가 없습니다.') + router.replace('/') return } @@ -30,6 +29,10 @@ const KakaoCallbackContent = (): ReactElement | null => { // 코드 처리 시작 표시 processedRef.current = code + // 인가 코드는 크리덴셜이다. 주소창·히스토리·리퍼러에 남기지 않는다. + // (교환은 이미 시작됐으므로 지워도 흐름에 영향이 없다) + window.history.replaceState({}, '', window.location.pathname) + postKakaoAuth( { code }, { @@ -38,31 +41,21 @@ const KakaoCallbackContent = (): ReactElement | null => { // 리프레시 토큰은 서버가 HttpOnly 쿠키로 심어 주므로 여기서 다루지 않는다. setTokens(data.accessToken, data.user.userId, data.expiresIn) - // URL에서 code 파라미터 제거 - // window.history.replaceState({}, '', window.location.pathname) - + // 콜백은 히스토리에 남기지 않는다. 뒤로가기로 돌아오면 소진된 코드로 재시도하게 된다. // 회원가입이 완료되지 않은 경우 회원가입 페이지로 - if (data.isNewUser) { - router.push('/signup') - } else { - router.push('/home') - } + router.replace(data.isNewUser ? '/signup' : '/home') } else { - console.error('Authentication failed:', data.message) + console.error('카카오 로그인 실패:', data.message) processedRef.current = null // 실패 시 재시도 가능하도록 초기화 - router.push('/') + router.replace('/') } }, onError: (error) => { - console.error('Kakao authentication error:', error) - - // authorization code 관련 에러인 경우 재시도하지 않음 - // const isAuthCodeError = (error as any)?.response?.data?.message?.includes('authorization code') - // if (!isAuthCodeError) { - // processedRef.current = null // 다른 에러의 경우 재시도 가능하도록 초기화 - // } + console.error('카카오 로그인 오류:', error) - router.push('/') + // 인가 코드는 일회용이라 같은 코드로 재시도해봐야 계속 실패한다. + // processedRef 를 되돌리지 않고 로그인 화면에서 새 코드를 받게 한다. + router.replace('/') }, }, ) diff --git a/src/utils/console.ts b/src/utils/console.ts index 8b74fbf..7e0c75b 100644 --- a/src/utils/console.ts +++ b/src/utils/console.ts @@ -13,6 +13,15 @@ const COLORS = { type HttpMethod = keyof typeof COLORS // "GET" | "POST" | "PUT" | "DELETE" | "RESET" | ... +/** + * 토큰은 붙었는지 여부만 알면 된다. + * 전문을 찍으면 화면 공유·녹화·스크린샷에 그대로 남는다. + */ +const maskToken = (authorization: unknown): string => { + if (typeof authorization !== 'string' || !authorization) return '(none)' + return `${authorization.slice(0, 13)}…(${authorization.length})` +} + /** * HTTP 요청 정보를 콘솔에 출력하는 함수 */ @@ -27,7 +36,7 @@ export const printRequestConsole = (config: { [key: string]: any }): void => { - URL : ${config.baseURL}${config.url} - Data : ${JSON.stringify(config.data, null, 2)} - Params : ${JSON.stringify(config.params, null, 2)} - - Header : ${config.headers.Authorization} + - Auth : ${maskToken(config.headers?.Authorization)} ================================= `) } From 24c264683e51d3dd971020dbd2eb3f6373671294 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 23 Aug 2026 21:04:33 +0900 Subject: [PATCH 03/20] =?UTF-8?q?fix(api):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EA=B3=BC=20=EC=96=B4=EA=B8=8B=EB=82=98=20?= =?UTF-8?q?=EC=A1=B0=EC=9A=A9=ED=9E=88=20=EC=8B=A4=ED=8C=A8=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 TokenDto 는 최상위 userId/email/role 을 채우지 않는다. 신원은 항상 중첩된 user 안에 있는데(AuthServiceImpl.issueTokenForUser) 스키마가 최상위를 필수로 요구해 두 가지가 망가져 있었다. - 일반 로그인: 200 과 토큰을 받고도 zod 파싱에서 실패해 한 번도 성공한 적이 없다 - 세션 복구: /auth/refresh 도 같은 모양이라 SessionBootstrap 이 파싱 실패를 세션 만료로 보고 clearTokens 를 불렀다. 새로고침할 때마다 로그아웃됐고 인터셉터의 401 재시도도 마지막 단계에서 항상 무너졌다 그 밖에 같은 부류로 드러난 것들: - 역할 enum 이 ['PARENT','CHILD'] 였다. 서버에 CHILD 는 없고 CAREGIVER·ADMIN·GUEST 를 거부해 관리자는 로그인 자체가 불가능했다. user.ts 의 USER_ROLE 을 정본으로 모았다 - 프로필 완성도는 completionPercentage 와 불리언 맵으로 오는데 completionRate 와 string[] 을 기다렸다. 전 필드가 nullish 라 파싱은 통과하고 값만 전부 undefined 였다 - 챗봇 기록은 배열인데 { success, content } 래퍼를 기다렸고, isHelpful 은 null 이 온다. userId 는 서버가 토큰에서 꺼내므로 파라미터로 보내도 무시된다 - 아이 응답에 specialNeeds 추가 (백엔드 a877d76 과 짝) SessionBootstrap 의 첫 렌더도 함께 고쳤다. useState(() => hasStoredSession() ...) 는 서버에서 localStorage 를 읽을 수 없어 서버는 children, 클라이언트는 대기 화면을 그려 모든 페이지에서 hydration 이 깨졌다. 저장소 판단은 effect 안에서만 한다. --- src/apis/auth.ts | 42 +++---------- src/apis/chatbot.ts | 28 ++++----- src/components/common/SessionBootstrap.tsx | 14 ++++- src/queries/chatbot.ts | 57 +++++++---------- src/types/apis/admin.ts | 14 ++--- src/types/apis/auth.ts | 73 ++++++---------------- src/types/apis/chatbot.ts | 55 ++++------------ src/types/apis/child.ts | 2 + src/types/apis/user.ts | 46 +++++++++++--- 9 files changed, 130 insertions(+), 201 deletions(-) diff --git a/src/apis/auth.ts b/src/apis/auth.ts index 72e7071..3085d10 100644 --- a/src/apis/auth.ts +++ b/src/apis/auth.ts @@ -7,8 +7,6 @@ import { PostRegisterBody, PostRegisterResponse, PostKakaoRegisterBody, - PostSignupBody, - PostSignupResponse, PostRefreshTokenResponse, postKakaoLoginBodySchema, postKakaoLoginResponseSchema, @@ -19,8 +17,6 @@ import { postRefreshTokenResponseSchema, postRegisterBodySchema, postRegisterResponseSchema, - postSignupBodySchema, - postSignupResponseSchema, getKakaoAuthUrlResponseSchema, GetKakaoAuthUrlResponse, postKakaoAuthBodySchema, @@ -63,22 +59,6 @@ export const PostKakaoRegister = async ( return postKakaoRegisterResponseSchema.parse(res.data) } -// POST /users - 새로운 회원가입 API (role과 nickname 중심) -export const postSignup = async (body: PostSignupBody): Promise => { - const parsedBody = postSignupBodySchema.parse(body) - - // 기본값 설정 - const requestBody = { - ...parsedBody, - // phoneNumber: parsedBody.phoneNumber || '010-0000-0000', - // birthDate: parsedBody.birthDate || '1990-01-01', - // gender: parsedBody.gender || 'MALE', - } - - const res = await CareCode.post('/users', requestBody) - return postSignupResponseSchema.parse(res.data) -} - let refreshTimer: NodeJS.Timeout | null = null /** @@ -155,7 +135,7 @@ export async function refreshAccessToken(): Promise { const res = await CareCode.post('/auth/refresh') const parsed = postRefreshTokenResponseSchema.parse(res.data) - setTokens(parsed.accessToken, parsed.userId, parsed.expiresIn) + setTokens(parsed.accessToken, parsed.user.userId, parsed.expiresIn) return parsed } @@ -170,20 +150,12 @@ export const getKakaoAuthUrl = async (redirectUri?: string): Promise => { const parsedBody = postKakaoAuthBodySchema.parse(body) - try { - const res = await CareCode.post('/auth/kakao/login', null, { - params: { code: parsedBody.code }, - }) - - // 성공 응답 처리 (200 또는 204 모두 허용) - if (res.status === 200 || res.status === 204) { - return postKakaoAuthResponseSchema.parse(res.data) - } else { - throw new Error('Unexpected response status: ' + res.status) - } - } catch (error) { - throw error - } + const res = await CareCode.post('/auth/kakao/login', null, { + params: { code: parsedBody.code }, + }) + + // 토큰이 없으면 로그인이 끝난 게 아니다. 본문이 비어 있으면 스키마가 여기서 잡아낸다. + return postKakaoAuthResponseSchema.parse(res.data) } // POST /users/auth/users/kakao/complete-registration diff --git a/src/apis/chatbot.ts b/src/apis/chatbot.ts index 2e9724f..0fd7941 100644 --- a/src/apis/chatbot.ts +++ b/src/apis/chatbot.ts @@ -1,9 +1,9 @@ import { CareCode } from './interceptor' import { - GetChatMessagesQuery, - getChatMessagesQuerySchema, - GetChatMessagesResponse, - getChatMessagesResponseSchema, + GetChatHistoryQuery, + getChatHistoryQuerySchema, + GetChatHistoryResponse, + getChatHistoryListSchema, GetChatSessionsQuery, getChatSessionsQuerySchema, GetChatSessionsResponse, @@ -22,22 +22,18 @@ export const postChatMessage = async ( return postChatMessageResponseSchema.parse(res.data) } -export const getChatMessages = async ( - query: GetChatMessagesQuery, -): Promise => { - const parsedQuery = getChatMessagesQuerySchema.parse(query) - const res = await CareCode.get(`/chatbot/history`, { - params: parsedQuery, - }) - return getChatMessagesResponseSchema.parse(res.data) +export const getChatHistory = async ( + query: GetChatHistoryQuery = {}, +): Promise => { + const parsedQuery = getChatHistoryQuerySchema.parse(query) + const res = await CareCode.get('/chatbot/history', { params: parsedQuery }) + return getChatHistoryListSchema.parse(res.data) } export const getChatSessions = async ( - query: GetChatSessionsQuery, + query: GetChatSessionsQuery = {}, ): Promise => { const parsedQuery = getChatSessionsQuerySchema.parse(query) - const res = await CareCode.get('/chatbot/sessions', { - params: parsedQuery, - }) + const res = await CareCode.get('/chatbot/sessions', { params: parsedQuery }) return getChatSessionsResponseSchema.parse(res.data) } diff --git a/src/components/common/SessionBootstrap.tsx b/src/components/common/SessionBootstrap.tsx index d97e376..427b20f 100644 --- a/src/components/common/SessionBootstrap.tsx +++ b/src/components/common/SessionBootstrap.tsx @@ -9,12 +9,21 @@ import { runRefresh } from '@/apis/interceptor' * 액세스 토큰은 메모리에만 두므로 새로고침하면 사라진다. 대신 HttpOnly 리프레시 쿠키가 * 남아 있으므로, 로그인 이력이 있으면 갱신을 한 번 시도해 로그인 상태를 이어붙인다. * 복구가 끝나기 전에 하위 화면이 요청을 보내면 불필요한 401 이 나므로 그 동안은 렌더를 미룬다. + * + * 첫 렌더는 **서버와 클라이언트가 반드시 같아야** 한다. 판단 근거인 localStorage 는 서버에서 + * 읽을 수 없으므로, 초기값을 `useState(() => hasStoredSession() ...)` 로 두면 서버는 children, + * 클라이언트는 대기 화면을 그려 매 페이지에서 hydration 이 깨진다. 그래서 양쪽 모두 대기 + * 화면으로 시작하고, 저장소를 읽는 판단은 effect 안에서만 한다. */ const SessionBootstrap = ({ children }: { children: ReactNode }): ReactElement => { - const [isRestoring, setIsRestoring] = useState(() => hasStoredSession() && !getAccessToken()) + const [isRestoring, setIsRestoring] = useState(true) useEffect(() => { - if (!isRestoring) return + // 복구할 세션이 없으면(로그아웃 상태이거나 토큰이 이미 메모리에 있으면) 그대로 통과시킨다. + if (!hasStoredSession() || getAccessToken()) { + setIsRestoring(false) + return + } let cancelled = false @@ -28,7 +37,6 @@ const SessionBootstrap = ({ children }: { children: ReactNode }): ReactElement = cancelled = true } // 최초 1회만 시도한다. - // eslint-disable-next-line react-hooks/exhaustive-deps }, []) if (isRestoring) { diff --git a/src/queries/chatbot.ts b/src/queries/chatbot.ts index 925a51e..643f05c 100644 --- a/src/queries/chatbot.ts +++ b/src/queries/chatbot.ts @@ -1,9 +1,10 @@ import { createQueryKeys } from '@lukemorales/query-key-factory' import { useMutation, useQuery, UseQueryResult, UseMutationResult } from '@tanstack/react-query' -import { getChatMessages, getChatSessions, postChatMessage } from '@/apis/chatbot' +import { getAccessToken } from '@/apis/auth' +import { getChatHistory, getChatSessions, postChatMessage } from '@/apis/chatbot' import { - GetChatMessagesQuery, - GetChatMessagesResponse, + GetChatHistoryQuery, + GetChatHistoryResponse, GetChatSessionsQuery, GetChatSessionsResponse, PostChatMessageBody, @@ -11,8 +12,14 @@ import { } from '@/types/apis/chatbot' export const chatbotQueryKeys = createQueryKeys('chatbot', { - messages: (query?: GetChatMessagesQuery) => [query], - sessions: (query?: GetChatSessionsQuery) => [query], + history: (query: GetChatHistoryQuery = {}) => ({ + queryKey: [query], + queryFn: () => getChatHistory(query), + }), + sessions: (query: GetChatSessionsQuery = {}) => ({ + queryKey: [query], + queryFn: () => getChatSessions(query), + }), }) export const usePostChatMessage = (): UseMutationResult< @@ -25,34 +32,14 @@ export const usePostChatMessage = (): UseMutationResult< }) } -export const useGetChatMessages = ( - query?: GetChatMessagesQuery, - enabled = false, -): UseQueryResult => { - return useQuery({ - queryKey: chatbotQueryKeys.messages(query).queryKey, - queryFn: () => { - if (!query?.userId) { - throw new Error('userId is required') - } - return getChatMessages(query) - }, - enabled: enabled && !!query?.userId, - }) -} +/** 지난 대화 세션 목록. 사용자는 서버가 토큰에서 꺼내므로 따로 넘기지 않는다. */ +export const useChatSessions = ( + query: GetChatSessionsQuery = {}, +): UseQueryResult => + useQuery({ ...chatbotQueryKeys.sessions(query), enabled: !!getAccessToken() }) -export const useGetChatSessions = ( - query?: GetChatSessionsQuery, - enabled = false, -): UseQueryResult => { - return useQuery({ - queryKey: chatbotQueryKeys.sessions(query).queryKey, - queryFn: () => { - if (!query?.userId) { - throw new Error('userId is required') - } - return getChatSessions(query) - }, - enabled: enabled && !!query?.userId, - }) -} +/** 한 세션의 문답 기록. sessionId 를 비우면 전체 기록을 최신순으로 받는다. */ +export const useChatHistory = ( + query: GetChatHistoryQuery = {}, +): UseQueryResult => + useQuery({ ...chatbotQueryKeys.history(query), enabled: !!getAccessToken() }) diff --git a/src/types/apis/admin.ts b/src/types/apis/admin.ts index 2492473..730a635 100644 --- a/src/types/apis/admin.ts +++ b/src/types/apis/admin.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { healthRecordSchema } from './health' import { hospitalSchema } from './hospital' import { reportSchema } from './moderation' +import { USER_ROLE, USER_ROLE_LABEL, type UserRoleValue } from './user' // ==================== 퍼널 ==================== @@ -136,15 +137,10 @@ export type AdminDashboard = z.infer // ==================== 사용자 관리 ==================== -export const UserRole = ['PARENT', 'CAREGIVER', 'ADMIN', 'GUEST'] as const -export type UserRole = (typeof UserRole)[number] - -export const USER_ROLE_LABEL: Record = { - PARENT: '부모', - CAREGIVER: '보육사', - ADMIN: '관리자', - GUEST: '게스트', -} +// 역할 목록·표기는 user.ts 가 정본이다. 두 벌로 두면 한쪽만 고쳐져 조용히 어긋난다. +export const UserRole = USER_ROLE +export type UserRole = UserRoleValue +export { USER_ROLE_LABEL } // 서버 AdminUserResponse 대응 export const adminUserSchema = z.object({ diff --git a/src/types/apis/auth.ts b/src/types/apis/auth.ts index 0e772fd..6c0b5dd 100644 --- a/src/types/apis/auth.ts +++ b/src/types/apis/auth.ts @@ -1,6 +1,14 @@ import { z } from 'zod' +import { userRoleSchema, userSchema } from './user' // login 공통 +/** + * 서버 TokenDto 대응. + * + * DTO 에 최상위 `userId`/`email`/`role` 필드가 있지만 `AuthServiceImpl.issueTokenForUser` + * 는 이 셋을 채우지 않는다 — 신원은 항상 중첩된 `user` 안에 있다(카카오 응답도 같은 모양). + * 최상위 값을 필수로 두고 있어서 그동안 일반 로그인은 200 을 받고도 파싱 단계에서 실패했다. + */ const loginSuccessSchema = z.object({ success: z.literal(true), message: z.string(), @@ -8,9 +16,8 @@ const loginSuccessSchema = z.object({ refreshToken: z.string(), tokenType: z.string(), expiresIn: z.number(), - userId: z.string(), - email: z.string().email(), - role: z.enum(['PARENT', 'CHILD']), + refreshExpiresIn: z.number().nullish(), + user: userSchema, }) const loginFailSchema = z.object({ success: z.literal(false), @@ -44,45 +51,6 @@ export type PostKakaoLoginBody = z.infer export const postKakaoLoginResponseSchema = kakaoLoginSuccessSchema export type PostKakaoLoginResponse = z.infer -// users -const signupBodySchema = z.object({ - name: z - .string() - .min(2, '닉네임은 2글자 이상이어야 합니다') - .max(10, '닉네임은 10글자 이하여야 합니다'), - role: z.enum(['ADMIN', 'CAREGIVER', 'GUEST', 'PARENT'], { - required_error: '역할을 선택해주세요', - }), - // 현재 API 요구사항 (향후 제거 예정) - // email: z.string().email('유효한 이메일 주소를 입력해주세요'), - // password: z.string().min(6, '비밀번호는 6글자 이상이어야 합니다'), - // phoneNumber: z.string().optional(), - // birthDate: z.string().optional(), - // gender: z.enum(['MALE', 'FEMALE']).optional(), - // address: z.string().optional(), -}) - -const signupResponseSchema = z.object({ - id: z.number(), - userId: z.string(), - email: z.string(), - password: z.string().nullable(), - name: z.string(), - phoneNumber: z.string().nullable(), - birthDate: z.string().nullable(), - gender: z.string().nullable(), - address: z.string().nullable(), - latitude: z.number().nullable(), - longitude: z.number().nullable(), - profileImageUrl: z.string().nullable(), - role: z.string(), - isActive: z.boolean(), - emailVerified: z.boolean(), - lastLoginAt: z.string().nullable(), - createdAt: z.string(), - updatedAt: z.string(), -}) - const registerBodySchema = z.object({ kakaoAccessToken: z.string(), email: z.string().email(), @@ -109,21 +77,22 @@ export type PostRegisterBody = z.infer export const postRegisterResponseSchema = registerResponseSchema export type PostRegisterResponse = z.infer -// /users -export const postSignupBodySchema = signupBodySchema -export type PostSignupBody = z.infer -export const postSignupResponseSchema = signupResponseSchema -export type PostSignupResponse = z.infer - // /auth/refresh // 리프레시 토큰은 HttpOnly 쿠키로 오가므로 요청 본문이 없다. // 응답의 refreshToken 도 쿠키를 쓰지 않는 클라이언트를 위한 값이라 읽지 않는다. +/** + * 갱신 응답도 로그인과 같은 TokenDto 다 — 최상위 userId 는 채워지지 않는다. + * + * 여기를 `userId: z.string()` 으로 두는 바람에 서버가 200 과 새 토큰을 줘도 파싱에서 + * 버려졌고, SessionBootstrap 이 그 실패를 세션 만료로 보고 clearTokens() 를 불렀다. + * 결과적으로 **새로고침할 때마다 로그아웃**됐고 401 재시도도 한 번도 성공하지 못했다. + */ export const postRefreshTokenResponseSchema = z.object({ success: z.boolean(), accessToken: z.string(), tokenType: z.string(), expiresIn: z.number(), - userId: z.string(), + user: userSchema, }) export type PostRefreshTokenResponse = z.infer @@ -158,7 +127,7 @@ const kakaoAuthSuccessSchema = z.object({ user: z.object({ userId: z.string(), email: z.string(), - role: z.enum(['PARENT', 'CHILD']), + role: userRoleSchema, name: z.string(), registrationCompleted: z.boolean().optional(), }), @@ -177,9 +146,7 @@ export const kakaoRegistrationRequestSchema = z.object({ .string() .min(2, '닉네임은 2글자 이상이어야 합니다') .max(10, '닉네임은 10글자 이하여야 합니다'), - role: z.enum(['ADMIN', 'CAREGIVER', 'GUEST', 'PARENT'], { - required_error: '역할을 선택해주세요', - }), + role: userRoleSchema, }) export type KakaoRegistrationRequest = z.infer diff --git a/src/types/apis/chatbot.ts b/src/types/apis/chatbot.ts index 13108c5..ce1a730 100644 --- a/src/types/apis/chatbot.ts +++ b/src/types/apis/chatbot.ts @@ -18,40 +18,17 @@ export const postChatMessageResponseSchema = z.object({ timestamp: z.string(), // suggestion: z.array(z.string()), // relatedTopics: z.array(z.string()), - // sessionId: z.string(), - // createdAt: z.string(), }) export type PostChatMessageResponse = z.infer -// /chatbot/history 해당 세션의 챗봇 메세지 기록 조회 -export const getChatMessagesQuerySchema = z.object({ - userId: z.string(), - sessionId: z.string().optional(), - page: z.string().optional(), - size: z.string().optional(), -}) -export type GetChatMessagesQuery = z.infer -export const getChatMessagesResponseSchema = z.object({ - success: z.boolean(), - content: z.object({ - messageId: z.number(), - userMessage: z.string(), - botResponse: z.string(), - confidence: z.number(), - isHelpful: z.boolean(), - sessionId: z.string(), - createAt: z.string(), - page: z.number(), - size: z.number(), - totalElements: z.number(), - totalPages: z.number(), - }), -}) -export type GetChatMessagesResponse = z.infer - -// /chatbot/history 해당 세션의 챗봇 메세지 기록 조회 +/** + * GET /chatbot/history — 대화 기록. + * + * 사용자는 서버가 토큰에서 꺼내 쓴다(`currentUserFacade.requireCurrentUserId()`). + * 예전에는 `userId` 를 쿼리로 보냈지만 서버는 그 값을 읽지 않는다. + * 응답도 `{ success, content }` 래퍼가 아니라 **배열**이다. + */ export const getChatHistoryQuerySchema = z.object({ - userId: z.string(), sessionId: z.string().optional(), page: z.number().optional(), size: z.number().optional(), @@ -65,15 +42,17 @@ export const getChatHistoryResponseSchema = z.object({ intentType: z.string(), confidence: z.number(), sessionId: z.string(), - isHelpful: z.boolean(), + // 사용자가 아직 도움 여부를 남기지 않으면 null 이다. + isHelpful: z.boolean().nullish(), createdAt: z.string(), }) -export const GetChatHistoryResponseSchema = z.array(getChatHistoryResponseSchema) -export type GetChatHistoryResponse = z.infer +export type ChatHistoryItem = z.infer +export const getChatHistoryListSchema = z.array(getChatHistoryResponseSchema) +export type GetChatHistoryResponse = z.infer // /chatbot/sessions 챗봇 대화 리스트 가져오기 +/** 사용자는 서버가 토큰에서 꺼낸다. 여기에 userId 를 넣어도 무시된다. */ export const getChatSessionsQuerySchema = z.object({ - userId: z.string(), page: z.number().optional(), size: z.number().optional(), }) @@ -86,14 +65,6 @@ export const sessionResponseSchema = z.object({ messageCount: z.number(), lastActivityAt: z.string(), createdAt: z.string(), - // sessionId: z.string(), - // userId: z.string(), - // title: z.string(), - // description: z.string(), - // status: z.string(), - // messageCount: z.number(), - // lastActivityAt: z.string(), - // createAt: z.string(), }) export const getChatSessionsResponseSchema = z.array(sessionResponseSchema) export type GetChatSessionsResponse = z.infer diff --git a/src/types/apis/child.ts b/src/types/apis/child.ts index 30cbdfc..f3aa914 100644 --- a/src/types/apis/child.ts +++ b/src/types/apis/child.ts @@ -7,6 +7,8 @@ export const childSchema = z.object({ name: z.string(), birthDate: z.string().nullish(), // yyyy-MM-dd gender: z.string().nullish(), + // 수정 화면이 현재 값을 읽어와야 한다. 수정은 전체 교체라 못 읽으면 저장할 때마다 지워진다. + specialNeeds: z.string().nullish(), createdAt: z.string().nullish(), updatedAt: z.string().nullish(), }) diff --git a/src/types/apis/user.ts b/src/types/apis/user.ts index 632290a..11e6289 100644 --- a/src/types/apis/user.ts +++ b/src/types/apis/user.ts @@ -1,5 +1,21 @@ import { z } from 'zod' +/** + * 서버 `UserRole` enum 과 1:1 로 맞춘다. + * 여기 없는 값을 다른 스키마가 쓰면 그 응답은 파싱 단계에서 통째로 실패한다. + * (실제로 로그인 응답이 `['PARENT', 'CHILD']` 로 좁혀져 있어 관리자·보육사는 로그인이 깨졌다) + */ +export const USER_ROLE = ['PARENT', 'CAREGIVER', 'ADMIN', 'GUEST'] as const +export const userRoleSchema = z.enum(USER_ROLE) +export type UserRoleValue = z.infer + +export const USER_ROLE_LABEL: Record = { + PARENT: '부모', + CAREGIVER: '보육사', + ADMIN: '관리자', + GUEST: '게스트', +} + /** * 서버 UserDto 대응 스키마. * 카카오 가입 직후에는 식별자를 제외한 대부분이 비어 있으므로 nullish 로 둔다. @@ -47,16 +63,30 @@ export type PutUserInfoBody = z.infer export const putUserInfoResponseSchema = userSchema export type PutUserInfoResponse = z.infer -// PATCH /users/profile/nickname -export const patchNicknameBodySchema = z.object({ - nickname: z.string().min(1, '닉네임을 입력해주세요').max(20, '닉네임은 20자 이내로 입력해주세요'), +// GET /users/profile/completion +/** + * GET /users/profile/completion — 서버 UserProfileCompletionResponse 대응. + * + * 예전 스키마는 `completionRate` / `missingFields: string[]` 를 기다렸지만 서버는 + * `completionPercentage` 와 **불리언 맵**을 준다. 모든 필드가 nullish 라 파싱은 통과했고, + * 값은 전부 undefined 가 되어 "완성도 0%, 빠진 항목 없음" 처럼 조용히 틀렸다. + * (`complete` 도 못 읽어 이미 다 채운 사용자에게도 안내가 계속 떴다) + */ +export const profileMissingFieldsSchema = z.object({ + needsRealName: z.boolean().nullish(), + needsPhoneNumber: z.boolean().nullish(), + needsBirthDate: z.boolean().nullish(), + needsGender: z.boolean().nullish(), + needsAddress: z.boolean().nullish(), }) -export type PatchNicknameBody = z.infer +export type ProfileMissingFields = z.infer -// GET /users/profile/completion export const getProfileCompletionResponseSchema = z.object({ - completionRate: z.number().nullish(), - completed: z.boolean().nullish(), - missingFields: z.array(z.string()).nullish(), + complete: z.boolean().nullish(), + completionPercentage: z.number().nullish(), + message: z.string().nullish(), + missingFields: profileMissingFieldsSchema.nullish(), + completedFields: z.number().nullish(), + totalFields: z.number().nullish(), }) export type GetProfileCompletionResponse = z.infer From 6a97cb17cc96249d98341d3921461b04afb1f35b Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 23 Aug 2026 21:05:02 +0900 Subject: [PATCH 04/20] =?UTF-8?q?fix(query):=20=EC=86=90=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=94=EB=8D=98=20=EB=AC=B4=ED=9A=A8=ED=99=94=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20=ED=8C=A9=ED=86=A0=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B2=8C=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문자열로 적은 무효화 키가 실제 키와 어긋나 아무것도 잡지 못하는 곳이 있었다. - ['admin','health-records'] 는 실제 ['admin','healthRecords',...] 와 달라 관리자 건강기록을 지워도 목록이 갱신되지 않았다 - ['facility','my-bookings'] 도 ['facility','myBookings',...] 와 어긋나 예약을 확정·반려해도 사용자의 내 예약이 그대로였다 나머지는 우연히 맞고 있었지만 키 이름을 바꾸는 순간 같은 방식으로 깨진다. 전부 팩토리 참조(_def / .queryKey)로 바꿔 이름이 바뀌면 컴파일 에러가 나게 했다. 정책을 고칠 때 검증률 지표도 함께 무효화한다. --- src/components/common/PushListener.tsx | 3 +- src/queries/admin.ts | 46 +++++++++++++++----------- src/queries/facility.ts | 9 ----- src/queries/health.ts | 13 ++++---- src/queries/hospital.ts | 2 +- src/queries/moderation.ts | 7 ++-- src/queries/notification.ts | 38 ++++++++++++--------- src/queries/policy.ts | 16 +-------- src/queries/user.ts | 24 +------------- src/queries/waitlist.ts | 2 +- 10 files changed, 65 insertions(+), 95 deletions(-) diff --git a/src/components/common/PushListener.tsx b/src/components/common/PushListener.tsx index c4b9d3f..3c0b0d0 100644 --- a/src/components/common/PushListener.tsx +++ b/src/components/common/PushListener.tsx @@ -2,6 +2,7 @@ import { useQueryClient } from '@tanstack/react-query' import { ReactNode, useEffect } from 'react' import { onForegroundPush } from '@/apis/push' +import { notificationQueries } from '@/queries/notification' /** * 앱이 열려 있는 동안 도착한 푸시를 화면에 반영한다. @@ -19,7 +20,7 @@ const PushListener = ({ children }: { children: ReactNode }): ReactNode => { let isCancelled = false onForegroundPush(() => { - queryClient.invalidateQueries({ queryKey: ['notification'] }) + queryClient.invalidateQueries({ queryKey: notificationQueries._def }) }).then((cleanup) => { // 구독이 완료되기 전에 언마운트됐다면 바로 해제한다. if (isCancelled) cleanup() diff --git a/src/queries/admin.ts b/src/queries/admin.ts index fdcea30..c7715fe 100644 --- a/src/queries/admin.ts +++ b/src/queries/admin.ts @@ -39,6 +39,11 @@ import { patchAdminPolicy, } from '@/apis/admin' import { useIsAdmin } from '@/hooks/useIsAdmin' +import { communityQueries } from '@/queries/community' +import { facilityQueries } from '@/queries/facility' +import { healthQueries } from '@/queries/health' +import { hospitalQueries } from '@/queries/hospital' +import { policyQueryKeys } from '@/queries/policy' import { AdminBookingSearch, AdminBookingSearchQuery, @@ -192,7 +197,7 @@ export const useVerifyPolicy = (): UseMutationResult< onSuccess: () => { queryClient.invalidateQueries({ queryKey: adminQueries.verificationStatus().queryKey }) // 검증 여부가 사용자 화면의 금액 신뢰도 표기에 반영된다. - queryClient.invalidateQueries({ queryKey: ['policy'] }) + queryClient.invalidateQueries({ queryKey: policyQueryKeys._def }) }, }) } @@ -204,7 +209,7 @@ export const useUnverifyPolicy = (): UseMutationResult => { mutationFn: deletePolicyVerify, onSuccess: () => { queryClient.invalidateQueries({ queryKey: adminQueries.verificationStatus().queryKey }) - queryClient.invalidateQueries({ queryKey: ['policy'] }) + queryClient.invalidateQueries({ queryKey: policyQueryKeys._def }) }, }) } @@ -229,7 +234,7 @@ export const useUpdateAdminUser = (): UseMutationResult< return useMutation({ mutationFn: ({ id, body }) => patchAdminUser(id, body), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.users._def }) }, }) } @@ -240,7 +245,7 @@ export const useDeleteAdminUser = (): UseMutationResult => return useMutation({ mutationFn: deleteAdminUser, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.users._def }) }, }) } @@ -251,9 +256,9 @@ export const useDeleteAdminPost = (): UseMutationResult => return useMutation({ mutationFn: deleteAdminPost, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'posts'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.posts._def }) // 관리자 삭제는 사용자 화면의 목록에도 반영돼야 한다. - queryClient.invalidateQueries({ queryKey: ['community'] }) + queryClient.invalidateQueries({ queryKey: communityQueries._def }) }, }) } @@ -282,9 +287,9 @@ export const useUpdateBookingStatus = (): UseMutationResult< return useMutation({ mutationFn: ({ bookingId, body }) => patchBookingStatus(bookingId, body), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'bookings'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.bookings._def }) // 사용자의 "내 예약" 화면에도 즉시 반영돼야 한다. - queryClient.invalidateQueries({ queryKey: ['facility', 'my-bookings'] }) + queryClient.invalidateQueries({ queryKey: facilityQueries.myBookings().queryKey }) }, }) } @@ -299,8 +304,8 @@ export const useDeleteAdminBooking = (): UseMutationResult return useMutation({ mutationFn: deleteAdminBooking, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'bookings'] }) - queryClient.invalidateQueries({ queryKey: ['facility', 'my-bookings'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.bookings._def }) + queryClient.invalidateQueries({ queryKey: facilityQueries.myBookings().queryKey }) }, }) } @@ -314,8 +319,9 @@ export const useAdminPolicies = (page = 0): UseQueryResult): void => { - queryClient.invalidateQueries({ queryKey: ['admin', 'policies'] }) - queryClient.invalidateQueries({ queryKey: ['policy'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.policies._def }) + queryClient.invalidateQueries({ queryKey: adminQueries.verificationStatus().queryKey }) + queryClient.invalidateQueries({ queryKey: policyQueryKeys._def }) } export const useCreateAdminPolicy = (): UseMutationResult< @@ -370,8 +376,8 @@ export const useDeleteAdminHospital = (): UseMutationResult return useMutation({ mutationFn: deleteAdminHospital, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'hospitals'] }) - queryClient.invalidateQueries({ queryKey: ['hospital'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.hospitals._def }) + queryClient.invalidateQueries({ queryKey: hospitalQueries._def }) }, }) } @@ -387,8 +393,8 @@ export const useDeleteAdminHealthRecord = (): UseMutationResult { - queryClient.invalidateQueries({ queryKey: ['admin', 'health-records'] }) - queryClient.invalidateQueries({ queryKey: ['health'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.healthRecords._def }) + queryClient.invalidateQueries({ queryKey: healthQueries._def }) }, }) } @@ -408,7 +414,7 @@ export const useCreateAdminNotification = (): UseMutationResult< return useMutation({ mutationFn: postAdminNotification, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'notifications'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.notifications._def }) }, }) } @@ -419,7 +425,7 @@ export const useDeleteAdminNotification = (): UseMutationResult { - queryClient.invalidateQueries({ queryKey: ['admin', 'notifications'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.notifications._def }) }, }) } @@ -451,9 +457,9 @@ export const useResolveReport = (): UseMutationResult< return useMutation({ mutationFn: ({ reportId, status, note }) => patchReportStatus(reportId, status, note), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin', 'reports'] }) + queryClient.invalidateQueries({ queryKey: adminQueries.reports._def }) // 처리 결과에 따라 게시글이 숨겨지므로 커뮤니티 목록도 다시 받는다. - queryClient.invalidateQueries({ queryKey: ['community'] }) + queryClient.invalidateQueries({ queryKey: communityQueries._def }) }, }) } diff --git a/src/queries/facility.ts b/src/queries/facility.ts index 133dc32..b87258f 100644 --- a/src/queries/facility.ts +++ b/src/queries/facility.ts @@ -11,7 +11,6 @@ import { cancelBooking, deleteFacilityReview, getFacilities, - getFacilitiesByKeyword, getFacilitiesByLocation, getFacilitiesByType, getFacilityById, @@ -64,11 +63,6 @@ export const facilityQueries = createQueryKeys('facility', { queryFn: () => getFacilitiesByLocation({ location }), }), - keyword: (keyword: string) => ({ - queryKey: ['keyword', keyword], - queryFn: () => getFacilitiesByKeyword({ keyword }), - }), - search: (body: PostFacilitiesSearchBody) => ({ queryKey: ['search', body], queryFn: () => postSearchFacilities(body), @@ -102,9 +96,6 @@ export const useFacilitySearch = ( ): UseQueryResult => useQuery({ ...facilityQueries.search(body), enabled }) -export const useFacilitiesByKeyword = (keyword: string): UseQueryResult => - useQuery({ ...facilityQueries.keyword(keyword), enabled: keyword.trim().length > 0 }) - /** * 조건 기반 고급 검색. * 조건이 하나도 없으면 전체 조회와 다를 게 없어 켜지 않는다. diff --git a/src/queries/health.ts b/src/queries/health.ts index 85947ae..cdd6fdd 100644 --- a/src/queries/health.ts +++ b/src/queries/health.ts @@ -19,6 +19,7 @@ import { putHealthRecord, uploadAttachment, } from '@/apis/health' +import { childQueries } from '@/queries/child' import { Attachment, CreateHealthRecordBody, @@ -94,8 +95,8 @@ export const useCreateHealthRecord = (): UseMutationResult< return useMutation({ mutationFn: postHealthRecord, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['health', 'records'] }) - queryClient.invalidateQueries({ queryKey: ['child', 'growth'] }) + queryClient.invalidateQueries({ queryKey: healthQueries.records._def }) + queryClient.invalidateQueries({ queryKey: childQueries.growth._def }) }, }) } @@ -109,8 +110,8 @@ export const useUpdateHealthRecord = ( mutationFn: (body: UpdateHealthRecordBody) => putHealthRecord(recordId, body), onSuccess: (record) => { queryClient.setQueryData(healthQueries.record(recordId).queryKey, record) - queryClient.invalidateQueries({ queryKey: ['health', 'records'] }) - queryClient.invalidateQueries({ queryKey: ['child', 'growth'] }) + queryClient.invalidateQueries({ queryKey: healthQueries.records._def }) + queryClient.invalidateQueries({ queryKey: childQueries.growth._def }) }, }) } @@ -121,8 +122,8 @@ export const useDeleteHealthRecord = (): UseMutationResult return useMutation({ mutationFn: deleteHealthRecord, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['health', 'records'] }) - queryClient.invalidateQueries({ queryKey: ['child', 'growth'] }) + queryClient.invalidateQueries({ queryKey: healthQueries.records._def }) + queryClient.invalidateQueries({ queryKey: childQueries.growth._def }) }, }) } diff --git a/src/queries/hospital.ts b/src/queries/hospital.ts index 5749547..cd3916c 100644 --- a/src/queries/hospital.ts +++ b/src/queries/hospital.ts @@ -128,7 +128,7 @@ export const useToggleHospitalLike = ( onSettled: () => { queryClient.invalidateQueries({ queryKey: statusKey }) - queryClient.invalidateQueries({ queryKey: ['hospital', 'popular'] }) + queryClient.invalidateQueries({ queryKey: hospitalQueries.popular._def }) }, }) } diff --git a/src/queries/moderation.ts b/src/queries/moderation.ts index 6adc35d..25cc527 100644 --- a/src/queries/moderation.ts +++ b/src/queries/moderation.ts @@ -8,6 +8,7 @@ import { } from '@tanstack/react-query' import { getAccessToken } from '@/apis/auth' import { deleteBlockUser, getBlockedUsers, postBlockUser, postReport } from '@/apis/moderation' +import { communityQueries } from '@/queries/community' import { BlockedUserIds, Report, ReportCreateBody } from '@/types/apis/moderation' export const moderationQueries = createQueryKeys('moderation', { @@ -27,7 +28,7 @@ export const useReport = (): UseMutationResult mutationFn: postReport, onSuccess: () => { // 신고 누적으로 대상이 숨겨질 수 있으므로 목록을 다시 받는다. - queryClient.invalidateQueries({ queryKey: ['community'] }) + queryClient.invalidateQueries({ queryKey: communityQueries._def }) }, }) } @@ -39,7 +40,7 @@ export const useBlockUser = (): UseMutationResult => { mutationFn: postBlockUser, onSuccess: () => { queryClient.invalidateQueries({ queryKey: moderationQueries.blocks().queryKey }) - queryClient.invalidateQueries({ queryKey: ['community'] }) + queryClient.invalidateQueries({ queryKey: communityQueries._def }) }, }) } @@ -51,7 +52,7 @@ export const useUnblockUser = (): UseMutationResult => { mutationFn: deleteBlockUser, onSuccess: () => { queryClient.invalidateQueries({ queryKey: moderationQueries.blocks().queryKey }) - queryClient.invalidateQueries({ queryKey: ['community'] }) + queryClient.invalidateQueries({ queryKey: communityQueries._def }) }, }) } diff --git a/src/queries/notification.ts b/src/queries/notification.ts index e514936..82db0ed 100644 --- a/src/queries/notification.ts +++ b/src/queries/notification.ts @@ -8,7 +8,6 @@ import { } from '@tanstack/react-query' import { getAccessToken } from '@/apis/auth' import { - getNotificationById, getNotificationChannels, getNotificationList, getNotificationPreferences, @@ -17,14 +16,13 @@ import { putDisableAllNotifications, putNotificationChannel, postPushToken, + deleteNotification, putNotificationToRead, putResetNotificationPreferences, trackNotificationOpen, } from '@/apis/notification' import { requestPushToken } from '@/apis/push' import { - GetNotificationByIdPath, - GetNotificationByIdResponse, GetNotificationChannelsResponse, GetNotificationPreferencesResponse, GetNotificationsResponse, @@ -43,11 +41,6 @@ export const notificationQueries = createQueryKeys('notification', { queryFn: getUnreadNotifications, }), - detail: (notificationId: GetNotificationByIdPath['notificationId']) => ({ - queryKey: ['detail', notificationId], - queryFn: () => getNotificationById({ notificationId }), - }), - preferences: () => ({ queryKey: ['preferences'], queryFn: getNotificationPreferences, @@ -65,13 +58,16 @@ export const useNotifications = (): UseQueryResult => useQuery({ ...notificationQueries.unread(), enabled: !!getAccessToken() }) -export const useNotificationDetail = ( - notificationId: number, -): UseQueryResult => - useQuery({ - ...notificationQueries.detail(notificationId), - enabled: Number.isFinite(notificationId) && notificationId > 0, - }) +/** + * 종 아이콘에 표시할 안 읽음 여부. + * + * 여러 화면의 상단바가 같은 쿼리를 쓰지만 키가 같아 요청은 한 번만 나간다. + * 이게 없으면 알림이 도착해도 알림함에 들어가 보기 전까지 알 수 없다. + */ +export const useHasUnreadNotifications = (): boolean => { + const { data = [] } = useUnreadNotifications() + return data.length > 0 +} export const useNotificationPreferences = (): UseQueryResult< GetNotificationPreferencesResponse, @@ -151,7 +147,7 @@ export const useResetNotificationPreferences = (): UseMutationResult): void => { - queryClient.invalidateQueries({ queryKey: ['notification'] }) + queryClient.invalidateQueries({ queryKey: notificationQueries._def }) } /** @@ -178,6 +174,16 @@ export const useMarkNotificationRead = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: deleteNotification, + onSuccess: () => invalidateNotifications(queryClient), + }) +} + export const useMarkAllNotificationsRead = (): UseMutationResult => { const queryClient = useQueryClient() diff --git a/src/queries/policy.ts b/src/queries/policy.ts index 1a81bc0..c509e09 100644 --- a/src/queries/policy.ts +++ b/src/queries/policy.ts @@ -15,7 +15,6 @@ import { getBenefitAmountConsensus, getMissedBenefits, getPolicyBookmarks, - getPolicyList, getLatestPolicies, getPolicyRecommendations, getRegionalComparison, @@ -27,8 +26,6 @@ import { import { BenefitAmountConsensus, BenefitAmountReportBody, - GetPolicyListQuery, - GetPolicyListResponse, GetLatestPoliciesResponse, MissedBenefitSummary, PersonalizedPolicy, @@ -41,7 +38,6 @@ import { } from '@/types/apis/policy' export const policyQueryKeys = createQueryKeys('policy', { - list: (query?: GetPolicyListQuery) => [query], detail: (id: number) => [id], latest: () => ['latest'], search: (searchParams: Omit) => [searchParams], @@ -76,22 +72,12 @@ export const useReportBenefitAmount = ( queryClient.setQueryData(policyQueryKeys.amountConsensus(policyId).queryKey, consensus) // 합의가 확정되면 정책 금액이 채워지므로 상세·목록도 다시 받는다. if (consensus.confirmed) { - queryClient.invalidateQueries({ queryKey: ['policy'] }) + queryClient.invalidateQueries({ queryKey: policyQueryKeys._def }) } }, }) } -export const useGetPolicyList = ( - query?: GetPolicyListQuery, -): UseQueryResult => { - return useQuery({ - queryKey: policyQueryKeys.list(query).queryKey, - queryFn: () => getPolicyList(query || {}), - enabled: true, - }) -} - export const useGetPolicyById = ( policyId: number, ): UseQueryResult => { diff --git a/src/queries/user.ts b/src/queries/user.ts index 112eca2..4e6f108 100644 --- a/src/queries/user.ts +++ b/src/queries/user.ts @@ -8,17 +8,10 @@ import { } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { clearTokens, getAccessToken } from '@/apis/auth' -import { - getProfileCompletion, - getUserInfo, - patchNickname, - postLogout, - putUserInfo, -} from '@/apis/user' +import { getProfileCompletion, getUserInfo, postLogout, putUserInfo } from '@/apis/user' import { GetProfileCompletionResponse, GetUserInfoResponse, - PatchNicknameBody, PutUserInfoBody, PutUserInfoResponse, } from '@/types/apis/user' @@ -70,21 +63,6 @@ export const useUpdateProfile = (): UseMutationResult< }) } -export const useUpdateNickname = (): UseMutationResult< - PutUserInfoResponse, - Error, - PatchNicknameBody -> => { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: patchNickname, - onSuccess: (updated) => { - queryClient.setQueryData(userQueries.profile().queryKey, updated) - }, - }) -} - /** * 서버 세션 폐기 → 로컬 토큰 삭제 → 로그인 화면 이동. * 서버 호출이 실패해도 로컬 토큰은 반드시 지운다. diff --git a/src/queries/waitlist.ts b/src/queries/waitlist.ts index 1062d91..1fe4cc3 100644 --- a/src/queries/waitlist.ts +++ b/src/queries/waitlist.ts @@ -104,7 +104,7 @@ export const useResolveWaitlist = (): UseMutationResult< mutationFn: ({ waitlistId, status, resolvedAt, note }) => patchWaitlistResult(waitlistId, status, resolvedAt, note), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['waitlist'] }) + queryClient.invalidateQueries({ queryKey: waitlistQueries._def }) }, }) } From d8f31a4954ce14984d985301f80e0273a8c7a45c Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 23 Aug 2026 21:05:46 +0900 Subject: [PATCH 05/20] =?UTF-8?q?refactor(route):=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B7=B8=EB=A3=B9=EC=97=90=20=EB=84=A3=EA=B3=A0=20?= =?UTF-8?q?=ED=83=AD=20=EB=B0=94=EB=A5=BC=20nav=20=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13개 화면이 (with-tabs)·(without-tabs) 어디에도 속하지 않아 어떤 레이아웃도 받지 못했다. 그중 /chat 은 하단 탭의 목적지인데도 그룹 밖이라, 챗봇 탭을 누르는 순간 탭 바가 통째로 사라져 다른 탭으로 돌아갈 수 없었다. - /chat 을 (with-tabs) 로, notification·policy·search/policy·signup·mypage/edit 을 (without-tabs) 로 이동 - 화면이 아니라 통과 지점인 auth 콜백과 자체 레이아웃이 있는 admin 은 그대로 둔다 - 내용이
/register
뿐인 스텁 라우트 제거 탭 바는 Radix Tabs + router.push 였다. 페이지 이동을 tablist/tab 으로 표현해 스크린리더가 존재하지 않는 tabpanel 을 찾았고, Link 가 아니라 뷰포트 프리페치와 새 탭 열기가 동작하지 않았다. nav + Link 로 바꾸고 활성 탭은 aria-current 로 알린다. 활성 상태도 state + useEffect 대신 pathname 에서 파생해, 첫 렌더에 항상 첫 탭이 켜졌다가 뒤늦게 바뀌던 깜빡임을 없앴다. --- src/app/(with-tabs)/chat/page.tsx | 80 ++++++ src/app/(without-tabs)/mypage/edit/page.tsx | 241 ++++++++++++++++++ .../notification/page.tsx | 59 ++++- .../notification/settings/page.tsx | 0 .../{ => (without-tabs)}/policy/[id]/page.tsx | 0 .../search/policy/page.tsx | 13 +- src/app/(without-tabs)/signup/page.tsx | 93 +++++++ src/app/chat/page.tsx | 73 ------ src/app/mypage/edit/page.tsx | 170 ------------ src/app/register/page.tsx | 7 - src/app/signup/page.tsx | 182 ------------- src/components/common/tab-bar/TabItem.tsx | 53 ++-- src/components/common/tab-bar/index.tsx | 67 ++--- 13 files changed, 528 insertions(+), 510 deletions(-) create mode 100644 src/app/(with-tabs)/chat/page.tsx create mode 100644 src/app/(without-tabs)/mypage/edit/page.tsx rename src/app/{ => (without-tabs)}/notification/page.tsx (60%) rename src/app/{ => (without-tabs)}/notification/settings/page.tsx (100%) rename src/app/{ => (without-tabs)}/policy/[id]/page.tsx (100%) rename src/app/{ => (without-tabs)}/search/policy/page.tsx (89%) create mode 100644 src/app/(without-tabs)/signup/page.tsx delete mode 100644 src/app/chat/page.tsx delete mode 100644 src/app/mypage/edit/page.tsx delete mode 100644 src/app/register/page.tsx delete mode 100644 src/app/signup/page.tsx diff --git a/src/app/(with-tabs)/chat/page.tsx b/src/app/(with-tabs)/chat/page.tsx new file mode 100644 index 0000000..0c50a16 --- /dev/null +++ b/src/app/(with-tabs)/chat/page.tsx @@ -0,0 +1,80 @@ +'use client' +import { motion } from 'motion/react' +import { useRouter } from 'next/navigation' +import { ReactElement, useCallback, useState } from 'react' +import HistoryIcon from '@/assets/icons/clock_small.svg' +import AuthGuard from '@/components/common/AuthGuard' +import TopNavBar from '@/components/common/top-navbar' +import ChatContainer from '@/components/features/chat/ChatContainer' +import ChatInput from '@/components/features/chat/ChatInput' +import ChatRecommendationList from '@/components/features/chat/chat-recommnendation-list' +import { useChatMessages } from '@/components/features/chat/hooks/useChatMessages' + +const Chat = (): ReactElement => { + const router = useRouter() + const { messages, recommendations, sendMessage, isSending } = useChatMessages() + const [inputValue, setInputValue] = useState('') + + const handleSendMessage = useCallback(() => { + if (!inputValue.trim() || isSending) return + + sendMessage({ message: inputValue }) + setInputValue('') + }, [inputValue, isSending, sendMessage]) + + // 추천 메시지 클릭 핸들러 + const handleRecommendationClick = useCallback( + (text: string) => { + sendMessage({ message: text }) + }, + [sendMessage], + ) + + return ( + // 챗봇은 사용자별 대화 기록을 남긴다. 로그인 없이 들어오면 보낼 수 없다. + +
+ {/* 탭의 최상위 화면이라 뒤로 가기를 두지 않는다. */} + router.push('/chat/history'), + }, + ]} + /> + +
+ +
+ {/* 추천 메시지 리스트 */} + + + + + {/* 메시지 입력 영역 */} + +
+
+
+
+ ) +} + +export default Chat diff --git a/src/app/(without-tabs)/mypage/edit/page.tsx b/src/app/(without-tabs)/mypage/edit/page.tsx new file mode 100644 index 0000000..7e9429e --- /dev/null +++ b/src/app/(without-tabs)/mypage/edit/page.tsx @@ -0,0 +1,241 @@ +'use client' +import { useRouter } from 'next/navigation' +import { ReactElement, useEffect, useState } from 'react' +import { getErrorMessage } from '@/apis/errors' +import SearchIcon from '@/assets/icons/search.svg' +import AuthGuard from '@/components/common/AuthGuard' +import Button from '@/components/common/Button' +import ErrorView from '@/components/common/Error' +import Label from '@/components/common/Label' +import Layout from '@/components/common/Layout' +import Spacer from '@/components/common/Spacer' +import Input from '@/components/common/input' +import { useUpdateProfile, useUserProfile } from '@/queries/user' + +// Daum Postcode API 타입 정의 +interface DaumPostcodeData { + userSelectedType: string + roadAddress: string + jibunAddress: string + bname: string + buildingName: string + apartment: string +} + +declare global { + interface Window { + daum?: { + Postcode: new (options: { + oncomplete: (data: DaumPostcodeData) => void + width?: string + height?: string + }) => { + embed: (element: HTMLElement) => void + } + } + } +} + +/** 서버는 주소를 한 문자열로 들고 있다. 화면에서만 "기본 + 상세" 로 나눠 다룬다. */ +const SEPARATOR = ' | ' + +const splitAddress = (value?: string | null): { base: string; detail: string } => { + if (!value) return { base: '', detail: '' } + const index = value.indexOf(SEPARATOR) + if (index === -1) return { base: value, detail: '' } + return { base: value.slice(0, index), detail: value.slice(index + SEPARATOR.length) } +} + +const joinAddress = (base: string, detail: string): string => + detail.trim() ? `${base.trim()}${SEPARATOR}${detail.trim()}` : base.trim() + +const ProfileEditContent = (): ReactElement => { + const router = useRouter() + const { data: profile, isLoading, isError, refetch } = useUserProfile() + const { mutate: updateProfile, isPending, isError: isSaveError, error } = useUpdateProfile() + + const [name, setName] = useState('') + const [phoneNumber, setPhoneNumber] = useState('') + const [birthDate, setBirthDate] = useState('') + const [address, setAddress] = useState('') + const [detailAddress, setDetailAddress] = useState('') + const [showPostcode, setShowPostcode] = useState(false) + + // 저장된 값을 채워 넣는다. 빈 폼으로 시작하면 저장할 때 기존 값을 지우게 된다. + useEffect(() => { + if (!profile) return + const { base, detail } = splitAddress(profile.address) + setName(profile.name ?? '') + setPhoneNumber(profile.phoneNumber ?? '') + setBirthDate(profile.birthDate ?? '') + setAddress(base) + setDetailAddress(detail) + }, [profile]) + + // 다음 우편번호 서비스는 이 화면에서만 쓰므로 여기서 싣고 나갈 때 걷는다. + useEffect(() => { + if (window.daum) return + + const script = document.createElement('script') + script.src = 'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js' + script.async = true + document.head.appendChild(script) + + return () => { + script.remove() + } + }, []) + + useEffect(() => { + if (!showPostcode || !window.daum) return + const container = document.getElementById('postcode-container') + if (!container) return + + new window.daum.Postcode({ + oncomplete: (data) => { + const base = data.userSelectedType === 'R' ? data.roadAddress : data.jibunAddress + + let extra = '' + if (data.userSelectedType === 'R') { + if (data.bname && /[동로가]$/.test(data.bname)) extra += data.bname + if (data.buildingName && data.apartment === 'Y') { + extra += extra ? `, ${data.buildingName}` : data.buildingName + } + if (extra) extra = ` (${extra})` + } + + setAddress(base + extra) + setShowPostcode(false) + }, + width: '100%', + height: '400px', + }).embed(container) + }, [showPostcode]) + + const isNameValid = name.trim().length >= 2 && name.trim().length <= 10 + + const handleSave = () => { + if (!isNameValid) return + + updateProfile( + { + name: name.trim(), + // 서버가 보내지 않은 키는 건드리지 않으므로, 비운 값은 빈 문자열로 명시한다. + phoneNumber: phoneNumber.trim(), + birthDate: birthDate.trim(), + address: joinAddress(address, detailAddress), + }, + { onSuccess: () => router.back() }, + ) + } + + if (isLoading) { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ) + } + + if (isError) { + return refetch()} /> + } + + return ( + <> +
+ + + + + + +
+ +

+ 거주지를 넣으면 지역별 지원금과 가까운 시설을 찾아드려요. +

+ setShowPostcode(true)} + aria-label="주소 검색" + rightIcon={} + /> + + + {showPostcode && ( +
+
+ 주소 검색 + +
+
+
+ )} + + +
+
+ +
+ {isSaveError && ( +

+ {getErrorMessage(error, '저장하지 못했어요. 잠시 후 다시 시도해주세요.')} +

+ )} + +
+ + ) +} + +const ProfileEditPage = (): ReactElement => ( + + + + + +) + +export default ProfileEditPage diff --git a/src/app/notification/page.tsx b/src/app/(without-tabs)/notification/page.tsx similarity index 60% rename from src/app/notification/page.tsx rename to src/app/(without-tabs)/notification/page.tsx index 4b0b1c9..77881a7 100644 --- a/src/app/notification/page.tsx +++ b/src/app/(without-tabs)/notification/page.tsx @@ -3,13 +3,20 @@ import { formatDistanceToNow } from 'date-fns' import { ko } from 'date-fns/locale' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { ReactElement } from 'react' +import { ReactElement, useState } from 'react' +import AlertDialog from '@/components/common/AlertDialog' import AuthGuard from '@/components/common/AuthGuard' +import Button from '@/components/common/Button' import EmptyState from '@/components/common/EmptyState' import ErrorView from '@/components/common/Error' import Layout from '@/components/common/Layout' import NotificationCard from '@/components/features/notification/NotificationCard' -import { useNotifications, useOpenNotification } from '@/queries/notification' +import { + useDeleteNotification, + useMarkNotificationRead, + useNotifications, + useOpenNotification, +} from '@/queries/notification' import { useMarkAllNotificationsRead } from '@/queries/notification' import { Notification, NOTIFICATION_TARGET } from '@/types/apis/notification' import { toDate } from '@/utils/date' @@ -26,6 +33,9 @@ const NotificationPage = (): ReactElement => { const { data: notifications = [], isLoading, isError, refetch } = useNotifications() const { mutate: openNotification } = useOpenNotification() const { mutate: markAllRead, isPending: isMarkingAll } = useMarkAllNotificationsRead() + const { mutate: markRead } = useMarkNotificationRead() + const { mutate: removeNotification } = useDeleteNotification() + const [notificationToDelete, setNotificationToDelete] = useState(null) const unreadCount = notifications.filter((notification) => !notification.isRead).length @@ -80,7 +90,7 @@ const NotificationPage = (): ReactElement => {
    {notifications.map((notification) => ( -
  • +
  • { isRead={notification.isRead} onClick={() => handleClick(notification)} /> +
    + {/* 열지 않고도 읽음 처리할 수 있어야 한다. 여는 순간 딥링크로 이동하기 때문이다. */} + {!notification.isRead && ( + + )} + +
  • ))}
+ + setNotificationToDelete(null)} + cancelButton={ + + } + confirmButton={ + + } + /> )} diff --git a/src/app/notification/settings/page.tsx b/src/app/(without-tabs)/notification/settings/page.tsx similarity index 100% rename from src/app/notification/settings/page.tsx rename to src/app/(without-tabs)/notification/settings/page.tsx diff --git a/src/app/policy/[id]/page.tsx b/src/app/(without-tabs)/policy/[id]/page.tsx similarity index 100% rename from src/app/policy/[id]/page.tsx rename to src/app/(without-tabs)/policy/[id]/page.tsx diff --git a/src/app/search/policy/page.tsx b/src/app/(without-tabs)/search/policy/page.tsx similarity index 89% rename from src/app/search/policy/page.tsx rename to src/app/(without-tabs)/search/policy/page.tsx index 63a8000..5d50ef0 100644 --- a/src/app/search/policy/page.tsx +++ b/src/app/(without-tabs)/search/policy/page.tsx @@ -1,10 +1,11 @@ 'use client' +import { useRouter } from 'next/navigation' import { ReactElement, use } from 'react' import SearchIcon from '@/assets/icons/search.svg' import Layout from '@/components/common/Layout' import Spacer from '@/components/common/Spacer' -// import ToggleChip from '@/components/common/ToggleChip' import Input from '@/components/common/input' +import IconButton from '@/components/common/top-navbar/IconButton' import PolicyCard from '@/components/features/policy/PolicyCard' import useInfiniteScroll from '@/hooks/useInfiniteScroll' import { useSearchPolicy } from '@/hooks/useSearchPolicy' @@ -18,6 +19,7 @@ interface PolicySearchPageProps { } const PolicySearchPage = ({ searchParams }: PolicySearchPageProps): ReactElement => { + const router = useRouter() const { keyword } = use(searchParams) const { inputValue, handleInputChange, search } = useSearchPolicy(keyword || '') @@ -47,10 +49,11 @@ const PolicySearchPage = ({ searchParams }: PolicySearchPageProps): ReactElement placeholder="검색어를 입력하세요" onChange={handleInputChange} rightIcon={ - search()} /> } /> @@ -85,7 +88,7 @@ const PolicySearchPage = ({ searchParams }: PolicySearchPageProps): ReactElement console.log(`정책 ${policy.id} 클릭됨`)} + onClick={() => router.push(`/policy/${policy.id}`)} /> ) })} diff --git a/src/app/(without-tabs)/signup/page.tsx b/src/app/(without-tabs)/signup/page.tsx new file mode 100644 index 0000000..fc6e9f6 --- /dev/null +++ b/src/app/(without-tabs)/signup/page.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { ReactElement } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { getErrorMessage } from '@/apis/errors' + +import Button from '@/components/common/Button' +import Layout from '@/components/common/Layout' +import Input from '@/components/common/input' +import { usePostKakaoCompleteRegistration } from '@/queries/auth' +import { KakaoRegistrationRequest, kakaoRegistrationRequestSchema } from '@/types/apis/auth' +import { zodResolver } from '@/utils/zodResolver' + +const SignUpPage = (): ReactElement => { + const router = useRouter() + + const { + control, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + mode: 'onChange', + // 검증 규칙은 서버로 보내는 스키마 하나에서만 온다. + resolver: zodResolver(kakaoRegistrationRequestSchema), + defaultValues: { + name: '', + role: 'PARENT', + }, + }) + + const signupMutation = usePostKakaoCompleteRegistration() + + const onSubmit = (data: KakaoRegistrationRequest) => { + signupMutation.mutate(data, { + onSuccess: () => router.replace('/home'), + // 실패를 삼키면 사용자는 버튼이 먹통이 된 것으로 본다. 아래에 메시지를 띄운다. + onError: (error) => console.error('회원가입 실패:', error), + }) + } + + return ( + +
+
+
+
+ + { + '맘편한에 오신 걸 환영해요 :)\n함께하는 육아,\n이제 조금 더 편안하게 시작해볼까요?' + } + + + 회원가입을 위한 정보를 입력해주세요. + +
+ + {/* 닉네임 */} + ( + + )} + /> +
+
+ {signupMutation.isError && ( +

+ {getErrorMessage( + signupMutation.error, + '회원가입에 실패했어요. 잠시 후 다시 시도해주세요.', + )} +

+ )} + +
+
+ ) +} + +export default SignUpPage diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx deleted file mode 100644 index 0835a9a..0000000 --- a/src/app/chat/page.tsx +++ /dev/null @@ -1,73 +0,0 @@ -'use client' -import { motion } from 'motion/react' -import { ReactElement, useCallback, useState } from 'react' -// import HamburgerIcon from '@/assets/icons/hamburger.svg' -import TopNavBar from '@/components/common/top-navbar' -import ChatContainer from '@/components/features/chat/ChatContainer' -import ChatInput from '@/components/features/chat/ChatInput' -import ChatRecommendationList from '@/components/features/chat/chat-recommnendation-list' -import { useChatMessages } from '@/components/features/chat/hooks/useChatMessages' - -const Chat = (): ReactElement => { - const { messages, recommendations, sendMessage, isSending } = useChatMessages() - const [inputValue, setInputValue] = useState('') - - const handleSendMessage = useCallback(() => { - if (!inputValue.trim() || isSending) return - - sendMessage({ - message: inputValue, - userId: 'test-user-id', - }) - setInputValue('') - }, [inputValue, isSending, sendMessage]) - - // 추천 메시지 클릭 핸들러 - const handleRecommendationClick = useCallback( - (text: string) => { - sendMessage({ - message: text, - userId: 'test-user-id', - }) - }, - [sendMessage], - ) - - return ( -
- - -
- -
- {/* 추천 메시지 리스트 */} - - - - - {/* 메시지 입력 영역 */} - -
-
-
- ) -} - -export default Chat diff --git a/src/app/mypage/edit/page.tsx b/src/app/mypage/edit/page.tsx deleted file mode 100644 index 6bafb60..0000000 --- a/src/app/mypage/edit/page.tsx +++ /dev/null @@ -1,170 +0,0 @@ -'use client' -import { ReactElement, useState, useEffect } from 'react' -import SearchIcon from '@/assets/icons/search.svg' -import Button from '@/components/common/Button' -import Label from '@/components/common/Label' -import Layout from '@/components/common/Layout' -import Spacer from '@/components/common/Spacer' -import ToggleButton from '@/components/common/ToggleButton' - -import Input from '@/components/common/input' -import EditProfileImage from '@/components/features/mypage/EditProfileImage' - -// Daum Postcode API 타입 정의 -interface DaumPostcodeData { - userSelectedType: string - roadAddress: string - jibunAddress: string - bname: string - buildingName: string - apartment: string -} - -declare global { - interface Window { - daum: { - Postcode: new (options: { - oncomplete: (data: DaumPostcodeData) => void - width?: string - height?: string - }) => { - embed: (element: HTMLElement) => void - } - } - } -} - -const ProfileEditPage = (): ReactElement => { - const [profileImage, setProfileImage] = useState('') - const [address, setAddress] = useState('') - const [detailAddress, setDetailAddress] = useState('') - const [showPostcode, setShowPostcode] = useState(false) - - // 다음 우편번호 서비스 스크립트 로드 - useEffect(() => { - const script = document.createElement('script') - script.src = 'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js' - script.async = true - document.head.appendChild(script) - - return () => { - document.head.removeChild(script) - } - }, []) - - const handleImageChange = (base64: string) => { - setProfileImage(base64) - } - - const handleAddressSearch = () => { - setShowPostcode(true) - } - - const handleComplete = (data: DaumPostcodeData) => { - let addr = '' // 주소 변수 - let extraAddr = '' // 참고항목 변수 - - // 사용자가 선택한 주소 타입에 따라 해당 주소 값을 가져온다. - if (data.userSelectedType === 'R') { - // 사용자가 도로명 주소를 선택했을 경우 - addr = data.roadAddress - } else { - // 사용자가 지번 주소를 선택했을 경우(J) - addr = data.jibunAddress - } - - // 사용자가 선택한 주소가 도로명 타입일때 참고항목을 조합한다. - if (data.userSelectedType === 'R') { - // 법정동명이 있을 경우 추가한다. (법정리는 제외) - // 법정동의 경우 마지막 문자가 "동/로/가"로 끝난다. - if (data.bname !== '' && /[동|로|가]$/g.test(data.bname)) { - extraAddr += data.bname - } - // 건물명이 있고, 공동주택일 경우 추가한다. - if (data.buildingName !== '' && data.apartment === 'Y') { - extraAddr += extraAddr !== '' ? ', ' + data.buildingName : data.buildingName - } - // 표시할 참고항목이 있을 경우, 괄호까지 추가한 최종 문자열을 만든다. - if (extraAddr !== '') { - extraAddr = ' (' + extraAddr + ')' - } - } - - // 선택된 주소 정보를 해당 필드에 넣는다. - setAddress(addr + extraAddr) - setShowPostcode(false) - } - - // 우편번호 검색 컴포넌트 - useEffect(() => { - if (showPostcode && window.daum) { - const postcodeElement = document.getElementById('postcode-container') - if (postcodeElement) { - new window.daum.Postcode({ - oncomplete: handleComplete, - width: '100%', - height: '400px', - }).embed(postcodeElement) - } - } - }, [showPostcode]) - - return ( - -
- - -
- -
- 부모 - 자녀 -
-
-
- - } - /> - - - {/* 우편번호 검색 iframe 영역 */} - {showPostcode && ( -
-
- 주소 검색 - -
-
-
- )} - - setDetailAddress(value)} - placeholder="상세 주소를 입력해주세요" - /> -
-
-
- -
- - ) -} - -export default ProfileEditPage diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx deleted file mode 100644 index 97f4452..0000000 --- a/src/app/register/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React, { JSX } from 'react' - -const Register = (): JSX.Element => { - return
/register
-} - -export default Register diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx deleted file mode 100644 index e759432..0000000 --- a/src/app/signup/page.tsx +++ /dev/null @@ -1,182 +0,0 @@ -'use client' - -import { useRouter } from 'next/navigation' -import { ReactElement } from 'react' -import { Controller, useForm } from 'react-hook-form' - -import Button from '@/components/common/Button' -// import Label from '@/components/common/Label' -import Layout from '@/components/common/Layout' -// import ToggleButton from '@/components/common/ToggleButton' -import Input from '@/components/common/input' -import { usePostKakaoCompleteRegistration } from '@/queries/auth' -import { KakaoRegistrationRequest } from '@/types/apis/auth' - -const SignUpPage = (): ReactElement => { - const router = useRouter() - - const { - control, - handleSubmit, - formState: { errors, isValid }, - } = useForm({ - mode: 'onChange', - defaultValues: { - name: '', - role: 'PARENT', - }, - }) - - const signupMutation = usePostKakaoCompleteRegistration() - - const onSubmit = async (data: KakaoRegistrationRequest) => { - try { - await signupMutation.mutateAsync(data) - - router.push('/home') - } catch (error: unknown) { - console.error('회원가입 실패:', error) - } - } - - return ( - -
-
-
-
- - { - '맘편한에 오신 걸 환영해요 :)\n함께하는 육아,\n이제 조금 더 편안하게 시작해볼까요?' - } - - - 회원가입을 위한 정보를 입력해주세요. - -
- - {/* 닉네임 */} - !value.includes(' ') || '공백은 사용할 수 없습니다', - }, - }} - render={({ field }) => ( - - )} - /> - - {/* 역할 */} - {/*
- - ( -
- pressed && field.onChange('PARENT')} - > - 부모 - - pressed && field.onChange('CAREGIVER')} - > - 자녀 - -
- )} - /> -
*/} - - {/* 임시 필수 필드들 */} - {/*
- 임시 필수 정보 (추후 제거 예정) - - ( - - )} - /> - - ( - - )} - /> -
*/} -
-
- -
-
- ) -} - -export default SignUpPage diff --git a/src/components/common/tab-bar/TabItem.tsx b/src/components/common/tab-bar/TabItem.tsx index 0efdb5c..9639056 100644 --- a/src/components/common/tab-bar/TabItem.tsx +++ b/src/components/common/tab-bar/TabItem.tsx @@ -1,39 +1,36 @@ -import * as Tabs from '@radix-ui/react-tabs' import clsx from 'clsx' -import { useRouter } from 'next/navigation' +import Link from 'next/link' import { ComponentType, ReactElement } from 'react' -interface TabItemProps { +export interface TabItemProps { title: string icon: ComponentType> url: string - value: string // Radix는 value 기반으로 동작 - selected?: boolean + selected: boolean } -export const TabItem = ({ - title, - icon: Icon, - url, - value, - selected, -}: TabItemProps): ReactElement => { - const router = useRouter() - - const handleClick = () => { - router.push(url) - } - +export const TabItem = ({ title, icon: Icon, url, selected }: TabItemProps): ReactElement => { return ( - - - - {title} - - +
  • + {/* + router.push 를 붙인 button 이 아니라 Link 여야 한다. + - Next 가 뷰포트에 들어온 탭을 미리 받아둔다(체감 전환 속도) + - href 가 있어야 새 탭 열기·휠 클릭이 동작한다 + - 스크린리더에 "링크"로 읽히고, 현재 탭은 aria-current 로 전달된다 + */} + + + + {title} + + +
  • ) } diff --git a/src/components/common/tab-bar/index.tsx b/src/components/common/tab-bar/index.tsx index 5f121c5..aa53e67 100644 --- a/src/components/common/tab-bar/index.tsx +++ b/src/components/common/tab-bar/index.tsx @@ -1,8 +1,7 @@ 'use client' -import * as Tabs from '@radix-ui/react-tabs' import clsx from 'clsx' import { usePathname } from 'next/navigation' -import { ReactElement, useEffect, useState } from 'react' +import { ReactElement } from 'react' import { TabItem } from './TabItem' import SearchIcon from '@/assets/icons/book.svg' @@ -11,62 +10,46 @@ import HomeIcon from '@/assets/icons/home.svg' import CommunityIcon from '@/assets/icons/leaf.svg' import UserIcon from '@/assets/icons/user.svg' +/** + * 하단 탭. 여기 있는 경로는 모두 `(with-tabs)` 그룹 안에 있어야 한다. + * 그룹 밖 경로를 넣으면 그 탭을 누르는 순간 탭 바 자체가 사라진다. + */ const tabData = [ - { id: 1, title: '커뮤니티', icon: CommunityIcon, url: '/community' }, - { id: 2, title: '육아 정보', icon: SearchIcon, url: '/search' }, - { id: 3, title: '홈', icon: HomeIcon, url: '/home' }, - { id: 4, title: '챗봇 상담', icon: ChatIcon, url: '/chat' }, - { id: 5, title: '마이페이지', icon: UserIcon, url: '/mypage' }, + { title: '커뮤니티', icon: CommunityIcon, url: '/community' }, + { title: '육아 정보', icon: SearchIcon, url: '/search' }, + { title: '홈', icon: HomeIcon, url: '/home' }, + { title: '챗봇 상담', icon: ChatIcon, url: '/chat' }, + { title: '마이페이지', icon: UserIcon, url: '/mypage' }, ] export interface TabBarProps { - defaultIndex?: number className?: string - onChange?: (index: number) => void } -const TabBar = ({ defaultIndex = 0, onChange, className }: TabBarProps): ReactElement => { - const pathname = usePathname() - const [selectedIndex, setSelectedIndex] = useState(defaultIndex) +const TabBar = ({ className }: TabBarProps): ReactElement => { + const pathname = usePathname() ?? '' - useEffect(() => { - const currentPath = pathname - const currentIndex = tabData.findIndex((tab) => tab.url === currentPath) - if (currentIndex !== -1) { - setSelectedIndex(currentIndex) - } - }, [pathname]) - - const handleValueChange = (value: string) => { - const index = tabData.findIndex((tab) => tab.id.toString() === value) - if (index !== -1) { - setSelectedIndex(index) - onChange?.(index) - } - } - - const currentValue = tabData[selectedIndex]?.id.toString() || tabData[0].id.toString() + /** + * 활성 탭은 경로에서 곧바로 파생한다. + * state + useEffect 로 맞추면 첫 렌더에 항상 첫 탭이 켜졌다가 뒤늦게 바뀐다. + * 하위 경로(`/community/write` 등)도 해당 탭을 활성으로 본다. + */ + const isActive = (url: string): boolean => pathname === url || pathname.startsWith(`${url}/`) return ( - - - {tabData.map((item, index) => ( - +
      + {tabData.map((item) => ( + ))} - - +
    + ) } From 32bfecc2579d241779f32be563603064778def32 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 23 Aug 2026 21:06:19 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat(a11y):=20=EC=95=84=EC=9D=B4=EC=BD=98?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=EC=97=90=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EC=A3=BC=EA=B3=A0=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EB=90=98=EC=82=B4=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아이콘만 있는 버튼은 접근 가능한 이름이 없어 스크린리더에 "버튼" 하나로만 읽혔다. IconButton 의 aria-label 을 필수 prop 으로 올려 12개 호출부가 모두 이름을 갖게 했다. - focus-visible 링 복원. 저장소 전체에 focus-visible 이 0곳이고 focus:outline-none 만 6곳 있어 키보드 사용자는 지금 어디에 있는지 알 수 없었다(WCAG 2.4.7) - Button 에 type="button" 기본값. 폼 안의 보조 버튼이 제출을 일으켰다 - 테마에 없어 Tailwind 기본 팔레트로 새던 red-400 제거 - manifest 와 앱 아이콘 추가. 서비스 워커가 없는 /images/logo.png 를 가리키고 있었다 - 쓰지 않는 Skeleton CSS import 와 정의된 적 없는 --font-geist-* 제거, Pretendard 폰트 스택을 테마 토큰으로 올려 font-pretendard 유틸리티가 실제로 생기게 함 - React.VFC 는 @types/react 19 에서 삭제된 타입이다. FC 로 교체하고 파일명 오타(syg.d.ts)도 함께 고쳤다 --- public/file.svg | 1 - public/firebase-messaging-sw.js | 5 ++-- public/globe.svg | 1 - public/images/app-icon.svg | 9 +++++++ public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - src/app/component-test/page.dev.tsx | 13 ++++++---- src/app/layout.tsx | 6 +++++ src/app/manifest.ts | 26 +++++++++++++++++++ src/components/common/BackButton.tsx | 9 ++++++- src/components/common/Button.tsx | 19 ++++++++++---- src/components/common/Switch.tsx | 3 ++- src/components/common/input/index.tsx | 1 + src/components/common/menubox/MenuItem.tsx | 3 ++- .../common/top-navbar/IconButton.tsx | 25 +++++++++++++++--- src/components/common/top-navbar/index.tsx | 11 ++------ .../features/community/ReportDialog.tsx | 2 +- .../features/facility/ReviewForm.tsx | 2 +- .../features/mypage/EditProfileImage.tsx | 1 + src/styles/globals.css | 20 +++++--------- src/types/svg.d.ts | 7 +++++ src/types/syg.d.ts | 6 ----- 23 files changed, 119 insertions(+), 54 deletions(-) delete mode 100644 public/file.svg delete mode 100644 public/globe.svg create mode 100644 public/images/app-icon.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg create mode 100644 src/app/manifest.ts create mode 100644 src/types/svg.d.ts delete mode 100644 src/types/syg.d.ts diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js index c5c88d2..4d6fae5 100644 --- a/public/firebase-messaging-sw.js +++ b/public/firebase-messaging-sw.js @@ -1,10 +1,11 @@ -/* eslint-disable no-undef */ /** * 백그라운드 푸시 수신용 서비스 워커. * * 서비스 워커는 번들을 거치지 않아 `process.env` 를 읽을 수 없다. 그래서 설정은 등록할 때 * 쿼리 파라미터로 넘겨받는다 (apis/push.ts). FCM 웹 설정값은 원래 공개되는 값이라 문제없다. */ +// 이 버전은 package.json 의 `firebase` 와 같아야 한다. 서비스 워커는 번들을 거치지 않아 +// 버전을 읽어올 방법이 없으므로 여기에 박고, package.json 쪽은 캐럿 없이 고정해 둔다. importScripts('https://www.gstatic.com/firebasejs/12.17.1/firebase-app-compat.js') importScripts('https://www.gstatic.com/firebasejs/12.17.1/firebase-messaging-compat.js') @@ -25,7 +26,7 @@ messaging.onBackgroundMessage((payload) => { self.registration.showNotification(notification.title || '케어코드 알림', { body: notification.body || '', - icon: '/images/logo.png', + icon: '/images/app-icon.svg', // 알림함에서 열 때 어디로 갈지. 유형만 넘겨받아 앱에서 목적지를 정한다. data: { notificationType: (payload.data || {}).notificationType || '' }, }) diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/images/app-icon.svg b/public/images/app-icon.svg new file mode 100644 index 0000000..4402e3b --- /dev/null +++ b/public/images/app-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/component-test/page.dev.tsx b/src/app/component-test/page.dev.tsx index bb68379..d9b03c3 100644 --- a/src/app/component-test/page.dev.tsx +++ b/src/app/component-test/page.dev.tsx @@ -124,14 +124,17 @@ export default function ComponentTest(): ReactElement {
    - - - - + + + +
    {/* 탭바 테스트 */} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 41be3a2..c66a809 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,15 +9,21 @@ import QueryProvider from '@/queries/QueryProvider' export const metadata: Metadata = { title: '맘편한', description: '맘편한은 부모와 자녀를 위한 육아 정보 공유 플랫폼입니다.', + // public/ 의 파일을 그대로 쓴다. app/icon.svg 규약을 쓰면 next.config 의 svgr 규칙과 부딪힌다. + icons: { icon: '/images/app-icon.svg', apple: '/images/app-icon.svg' }, + manifest: '/manifest.webmanifest', + appleWebApp: { capable: true, title: '맘편한', statusBarStyle: 'default' }, } export const viewport: Viewport = { width: 'device-width', initialScale: 1, minimumScale: 1, + // 확대를 막으면 저시력 사용자가 본문을 읽을 방법이 없다(WCAG 1.4.4). maximumScale: 5, userScalable: true, viewportFit: 'cover', + themeColor: '#4fbe27', } export default function RootLayout({ diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..5279317 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,26 @@ +import type { MetadataRoute } from 'next' + +/** + * 홈 화면에 추가했을 때의 정보. + * 모바일 우선 웹 앱이라 브라우저 크롬 없이 열리는 편이 자연스럽다. + */ +export default function manifest(): MetadataRoute.Manifest { + return { + name: '맘편한', + short_name: '맘편한', + description: '맘편한은 부모와 자녀를 위한 육아 정보 공유 플랫폼입니다.', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#4fbe27', + lang: 'ko', + icons: [ + { + src: '/images/app-icon.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any', + }, + ], + } +} diff --git a/src/components/common/BackButton.tsx b/src/components/common/BackButton.tsx index 72521dc..6fd5818 100644 --- a/src/components/common/BackButton.tsx +++ b/src/components/common/BackButton.tsx @@ -20,5 +20,12 @@ export const BackButton = ({ } } - return + return ( + + ) } diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx index 07d43d3..b996569 100644 --- a/src/components/common/Button.tsx +++ b/src/components/common/Button.tsx @@ -4,7 +4,7 @@ import { ButtonHTMLAttributes, ReactElement, ReactNode } from 'react' interface ButtonProps extends ButtonHTMLAttributes { children: ReactNode size?: 'large' | 'small' - color: 'green' | 'gray' | 'red' + color?: 'green' | 'gray' | 'red' className?: string } @@ -13,21 +13,30 @@ const Button = ({ size = 'large', color = 'green', className, + type = 'button', ...props }: ButtonProps): ReactElement => { return ( ) diff --git a/src/components/common/top-navbar/index.tsx b/src/components/common/top-navbar/index.tsx index debf433..9c16765 100644 --- a/src/components/common/top-navbar/index.tsx +++ b/src/components/common/top-navbar/index.tsx @@ -26,15 +26,8 @@ const TopNavBar = ({
    {title}
    {/* action buttons */}
    - {actionButtons.map((button, index) => ( - + {actionButtons.map((button) => ( + ))}
    diff --git a/src/components/features/community/ReportDialog.tsx b/src/components/features/community/ReportDialog.tsx index 08a2d6b..4f4b88d 100644 --- a/src/components/features/community/ReportDialog.tsx +++ b/src/components/features/community/ReportDialog.tsx @@ -63,7 +63,7 @@ const ReportDialog = ({ maxLength={1000} rows={3} placeholder="상세 내용을 알려주세요 (선택)" - className="text-b1-regular mt-4 w-full resize-none rounded-md border border-gray-300 p-3 text-black placeholder:text-gray-400 focus:border-green-500 focus:outline-none" + className="text-b1-regular mt-4 w-full resize-none rounded-md border border-gray-300 p-3 text-black placeholder:text-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-600/40 focus:outline-none" />
    diff --git a/src/components/features/facility/ReviewForm.tsx b/src/components/features/facility/ReviewForm.tsx index 75346fc..600a1af 100644 --- a/src/components/features/facility/ReviewForm.tsx +++ b/src/components/features/facility/ReviewForm.tsx @@ -56,7 +56,7 @@ const ReviewForm = ({ isPending = false, onSubmit }: ReviewFormProps): ReactElem maxLength={1000} rows={3} placeholder="다른 부모님께 도움이 될 경험을 남겨주세요." - className="text-b1-regular resize-none rounded-md border border-gray-300 p-3 text-black placeholder:text-gray-400 focus:border-green-500 focus:outline-none" + className="text-b1-regular resize-none rounded-md border border-gray-300 p-3 text-black placeholder:text-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-600/40 focus:outline-none" /> + )} + - {searchFocused ? ( -
    -
    -
    -
    - 최근 검색어 -
    + {isSearching ? ( +
    +
    + 최근 검색어 + {recentSearches.length > 0 && ( -
    -
    - {recentKeywords.map((keyword, index) => ( + )} +
    + + {recentSearches.length === 0 ? ( +

    최근 검색어가 없어요.

    + ) : ( +
    + {recentSearches.map((keyword) => ( handleRecentKeywordClick(keyword)} - onDelete={() => handleDeleteKeyword(keyword)} + deletable + onClick={() => goToSearch(keyword)} + onDelete={() => removeSearch(keyword)} > {keyword} ))}
    -
    + )}
    + ) : isError ? ( + refetch()} /> + ) : isLoading ? ( +
      + {[0, 1, 2, 3].map((i) => ( +
    • + ))} +
    + ) : posts.length === 0 ? ( + router.push('/community/write')} + /> ) : (
    - {posts.map((post, index) => ( - + {posts.map((post) => ( + ))}
    {hasNextPage ? ( - + <> + + 게시글을 더 불러오는 중 + ) : ( '마지막 게시글입니다.' )} @@ -142,7 +164,8 @@ const Community = (): JSX.Element => { )}
    - {searchFocused && } + {/* 목록을 보고 있을 때만 띄운다. 검색 패널 위에 글쓰기 버튼이 뜰 이유가 없다. */} + {!isSearching && }
    ) } diff --git a/src/app/(with-tabs)/community/search/ClientCommunitySearchPage.tsx b/src/app/(with-tabs)/community/search/ClientCommunitySearchPage.tsx index 6db4d35..ffea641 100644 --- a/src/app/(with-tabs)/community/search/ClientCommunitySearchPage.tsx +++ b/src/app/(with-tabs)/community/search/ClientCommunitySearchPage.tsx @@ -1,98 +1,104 @@ 'use client' import { useRouter, useSearchParams } from 'next/navigation' -import { JSX, useEffect, useRef } from 'react' +import { JSX } from 'react' import { useSearchPosts } from './hooks/useSearchPosts' import BabyIcon from '@/assets/icons/baby.svg' import BellIcon from '@/assets/icons/bell.svg' import SearchIcon from '@/assets/icons/search.svg' +import EmptyState from '@/components/common/EmptyState' import Input from '@/components/common/input' import { useInput } from '@/components/common/input/hooks/useInput' import TopNavBar from '@/components/common/top-navbar' import IconButton from '@/components/common/top-navbar/IconButton' import CommunityPost from '@/components/features/community/community-post-list' +import { useRecentSearches } from '@/hooks/useRecentSearches' +import { useHasUnreadNotifications } from '@/queries/notification' export default function ClientCommunitySearchPage(): JSX.Element { const params = useSearchParams() const router = useRouter() + const hasUnread = useHasUnreadNotifications() const keyword = params.get('keyword') ?? '' // 없으면 빈 문자열 const searchInput = useInput(keyword) - const { posts, fetchNextPage, isFetchingNextPage, hasNextPage } = useSearchPosts(keyword) - const loader = useRef(null) + const { addSearch } = useRecentSearches() + const { posts, loadMoreRef, hasNextPage } = useSearchPosts(keyword) const handleSearch = () => { - router.push(`/community/search?keyword=${encodeURIComponent(searchInput.value)}&page=0&size=10`) - } - - useEffect(() => { - const currentLoader = loader.current - if (!currentLoader) return - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0] - if (entry.isIntersecting && !isFetchingNextPage) { - fetchNextPage() - } - }, - { - root: null, // viewport 기준 - rootMargin: '200px', // 화면 아래 200px에 들어오면 호출 - threshold: 0, // entry가 조금이라도 보이면 실행 - }, - ) + const trimmed = searchInput.value.trim() + if (!trimmed) return - observer.observe(currentLoader) + addSearch(trimmed) + router.push(`/community/search?keyword=${encodeURIComponent(trimmed)}`) + } - return () => { - observer.unobserve(currentLoader) - } - }, [fetchNextPage, isFetchingNextPage]) return (
    router.push('/notification'), + }, + ]} isSticky={true} hasBackButton onBackButtonClick={() => router.back()} /> -
    +
    { + event.preventDefault() + handleSearch() + }} + > } /> -
    + -
    - {posts.map((post, index) => ( - - ))} + {posts.length === 0 ? ( + + ) : ( +
    + {posts.map((post) => ( + + ))} -
    -
    - {posts.length === 0 ? ( - 게시글이 없습니다. - ) : hasNextPage ? ( - +
    + {hasNextPage ? ( + <> + + 검색 결과를 더 불러오는 중 + ) : ( '마지막 게시글입니다.' )}
    -
    + )}
    ) diff --git a/src/app/(with-tabs)/community/search/hooks/useSearchPosts.ts b/src/app/(with-tabs)/community/search/hooks/useSearchPosts.ts index 2fe5454..3ea5b90 100644 --- a/src/app/(with-tabs)/community/search/hooks/useSearchPosts.ts +++ b/src/app/(with-tabs)/community/search/hooks/useSearchPosts.ts @@ -1,5 +1,6 @@ import { UseSuspenseInfiniteQueryResult, InfiniteData } from '@tanstack/react-query' -import { useMemo } from 'react' +import { RefObject, useMemo } from 'react' +import useInfiniteScroll from '@/hooks/useInfiniteScroll' import { useGetCommunitySearch } from '@/queries/community' import { GetCommunitySearchQuery, @@ -9,21 +10,18 @@ import { export type UseSearchPostsReturn = { posts: PostListItem[] + /** 이 요소가 화면에 들어오면 다음 페이지를 불러온다. */ + loadMoreRef: RefObject } & Omit, Error>, 'data'> export function useSearchPosts(keyword: GetCommunitySearchQuery['keyword']): UseSearchPostsReturn { - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, ...rest } = useGetCommunitySearch({ - keyword, - size: 10, - }) + const query = useGetCommunitySearch({ keyword, size: 10 }) + // 화면마다 IntersectionObserver 를 다시 짜지 않는다. 옵션 객체를 모듈 스코프에 둬야 + // 매 렌더마다 옵저버가 다시 만들어지지 않는데, 그 처리는 이 훅 안에 있다. + const { loadMoreRef } = useInfiniteScroll(query) + const { data, ...rest } = query const posts = useMemo(() => data?.pages.flatMap((page) => page.content) ?? [], [data]) - return { - posts, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - ...rest, - } + return { posts, loadMoreRef, ...rest } } diff --git a/src/app/(with-tabs)/search/page.tsx b/src/app/(with-tabs)/search/page.tsx index 9deaa97..217a430 100644 --- a/src/app/(with-tabs)/search/page.tsx +++ b/src/app/(with-tabs)/search/page.tsx @@ -7,12 +7,15 @@ import Chip from '@/components/common/Chip' import Layout from '@/components/common/Layout' import Spacer from '@/components/common/Spacer' import Input from '@/components/common/input' +import IconButton from '@/components/common/top-navbar/IconButton' import { useRecentSearches } from '@/hooks/useRecentSearches' import { useSearchPolicy } from '@/hooks/useSearchPolicy' +import { useHasUnreadNotifications } from '@/queries/notification' const Search = (): ReactElement => { const { recentSearches, removeSearch, clearAllSearches } = useRecentSearches() const router = useRouter() + const hasUnread = useHasUnreadNotifications() const { inputValue, handleInputChange, search } = useSearchPolicy() const handleNotificationClick = () => router.push('/notification') const handleSubmit = (e: React.FormEvent) => { @@ -24,7 +27,14 @@ const Search = (): ReactElement => {
    @@ -33,10 +43,11 @@ const Search = (): ReactElement => { placeholder="검색어를 입력하세요" onChange={handleInputChange} rightIcon={ - search()} /> } /> diff --git a/src/app/(without-tabs)/community/[id]/edit/page.tsx b/src/app/(without-tabs)/community/[id]/edit/page.tsx index fa891ed..e8649d9 100644 --- a/src/app/(without-tabs)/community/[id]/edit/page.tsx +++ b/src/app/(without-tabs)/community/[id]/edit/page.tsx @@ -17,7 +17,6 @@ const CommunityPostEdit = (): JSX.Element => { const router = useRouter() const handleEditButton = () => { - console.log('Edit Confirm Button Pressed') editPost( { ...post, title, content }, { @@ -31,7 +30,7 @@ const CommunityPostEdit = (): JSX.Element => { ) } return ( -
    +
    { const [content, setContent] = useState('') const handleAddButton = () => { - console.log('Add Post Button Pressed') - if (!title || !content) return alert('제목과 내용을 입력해주세요.') + if (!title.trim() || !content.trim()) return addPost({ title, content } as PostCommunityPostBody, { onSuccess: () => router.back(), onError: (err) => { @@ -25,7 +24,7 @@ const PostAdd = (): JSX.Element => { }) } return ( -
    +
    {isPending && } @@ -47,7 +46,12 @@ const PostAdd = (): JSX.Element => { className="text-b1-regular scrollbar-hide w-full flex-1 resize-none rounded-lg border border-gray-500 p-3 whitespace-pre-wrap !outline-none focus:border-gray-800" disabled={isPending} /> -
    diff --git a/src/app/page.tsx b/src/app/page.tsx index e1d88a2..0166014 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,16 +1,16 @@ 'use client' import { JSX } from 'react' -// import KakaoLoginButton from '@/components/features/login/KakaoLoginButton' import Elipse from '@/assets/icons/characters/Ellipse.svg' import GroundIcon from '@/assets/icons/characters/ground.svg' import CharcacterIcon from '@/assets/icons/characters/login.svg' import KakaoIcon from '@/assets/icons/logo/kakao.svg' import LogoIcon from '@/assets/icons/logo/logo.svg' -import Error from '@/components/common/Error' +import ErrorView from '@/components/common/Error' +import DevLoginButton from '@/components/features/login/DevLoginButton' import { useGetKakaoAuthUrlMutation } from '@/queries/auth' export default function Home(): JSX.Element { - const { mutate: getKakaoAuthUrl, isPending, error } = useGetKakaoAuthUrlMutation() + const { mutate: getKakaoAuthUrl, isPending, error, reset } = useGetKakaoAuthUrlMutation() const handleKakaoLogin = () => { getKakaoAuthUrl(undefined, { @@ -24,28 +24,32 @@ export default function Home(): JSX.Element { } return ( -
    -
    -
    - - - - -
    + // 높이·너비를 특정 기기 크기로 못 박으면 그보다 작은 화면에서 가로 스크롤이 생긴다. +
    +
    + + + +
    {/* 카카오 로그인 버튼 */} -
    +
    + + {/* 개발 환경에서만, 그리고 개발 계정 환경변수가 있을 때만 렌더된다. */} +
    - {error && } + + {error && }
    ) } diff --git a/src/components/features/chat/hooks/useChatMessages.ts b/src/components/features/chat/hooks/useChatMessages.ts index ee3ead5..a19f858 100644 --- a/src/components/features/chat/hooks/useChatMessages.ts +++ b/src/components/features/chat/hooks/useChatMessages.ts @@ -1,68 +1,66 @@ +import { isAxiosError } from 'axios' import { useCallback } from 'react' +import { getUserId } from '@/apis/auth' import { usePostChatMessage } from '@/queries/chatbot' import { useChatStore } from '@/stores/useChatStore' import { PostChatMessageBody, PostChatMessageResponse } from '@/types/apis/chatbot' import { ChatMessage, SendMessageOptions, UseChatMessagesReturn } from '@/types/chat' +/** + * 대화 시작을 돕는 예시 질문. + * 서버에 추천 질문 엔드포인트가 없어 클라이언트 상수로 둔다. 생기면 이 배열만 교체하면 된다. + */ +const RECOMMENDATIONS = [ + '최근 육아정책', + '육아 꿀템을\n추천해줘', + '태교에 좋은\n노래 추천해줘', + '육아 관련 책\n추천해줘', + '아이 발달\n단계별 놀이', +] + +/** 실패 원인을 사용자가 할 수 있는 일로 바꿔 준다. */ +const toErrorMessage = (error: unknown): string => { + if (isAxiosError(error)) { + // 문자열 매칭(`error.message.includes('401')`)은 문구가 바뀌면 조용히 빗나간다. + if (!error.response) return '네트워크 연결을 확인해주세요. 인터넷 연결이 불안정합니다.' + if (error.code === 'ECONNABORTED') + return '응답 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.' + if (error.response.status === 401) return '인증이 만료되었습니다. 다시 로그인해주세요.' + if (error.response.status >= 500) + return '서버에 일시적인 문제가 발생했습니다. 잠시 후 다시 시도해주세요.' + } + return '죄송합니다. 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' +} + export const useChatMessages = (): UseChatMessagesReturn => { const { messages, currentSessionId, addMessage, updateMessage, setSessionId } = useChatStore() - // TODO: useRecommendationStore로 분리 예정 - 임시 더미 데이터 - const recommendations = [ - '최근 육아정책', - '육아 꿀템을\n추천해줘', - '태교에 좋은\n노래 추천해줘', - '육아 관련 책\n추천해줘', - '아이 발달\n단계별 놀이', - ] - // 메시지 전송 Mutation const sendMessageMutation = usePostChatMessage() // Mutation 성공/에러 처리를 위한 변수 const handleMutationSuccess = useCallback( - ( - response: PostChatMessageResponse, - variables: SendMessageOptions & { loadingMessageId: string }, - ) => { + (response: PostChatMessageResponse, loadingMessageId: string) => { // 세션 ID 저장 (첫 메시지거나 새로운 세션인 경우) if (!currentSessionId && response.sessionId) { setSessionId(response.sessionId) } - // API 응답을 내부 타입으로 변환하여 사용 - // const messageUpdates = convertApiResponseToMessage(response) const messageUpdates: Partial = { message: response.response, timestamp: response.timestamp, isMyMessage: false, } - updateMessage(variables.loadingMessageId, messageUpdates) + updateMessage(loadingMessageId, messageUpdates) }, [updateMessage, currentSessionId, setSessionId], ) const handleMutationError = useCallback( - (error: Error, variables: SendMessageOptions & { loadingMessageId: string }) => { - console.error('Chat error:', error) - - // 에러 유형별 메시지 제공 - let errorMessage = '죄송합니다. 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' - - if (error.message.includes('Network')) { - errorMessage = '네트워크 연결을 확인해주세요. 인터넷 연결이 불안정합니다.' - } else if (error.message.includes('timeout')) { - errorMessage = '응답 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.' - } else if (error.message.includes('401')) { - errorMessage = '인증이 만료되었습니다. 다시 로그인해주세요.' - } else if (error.message.includes('500')) { - errorMessage = '서버에 일시적인 문제가 발생했습니다. 잠시 후 다시 시도해주세요.' - } - + (error: unknown, loadingMessageId: string) => { + console.error('챗봇 응답 실패:', error) // 로딩 메시지를 에러 메시지로 교체 - updateMessage(variables.loadingMessageId, { - message: errorMessage, - }) + updateMessage(loadingMessageId, { message: toErrorMessage(error) }) }, [updateMessage], ) @@ -72,37 +70,43 @@ export const useChatMessages = (): UseChatMessagesReturn => { async (options: SendMessageOptions) => { if (!options.message.trim() || sendMessageMutation.isPending) return + // 사용자 식별자는 호출부가 넘기지 않는다. 화면마다 다른 값을 넣을 여지를 없앤다. + const userId = getUserId() + if (!userId) { + console.error('로그인 정보가 없어 챗봇에 보낼 수 없습니다.') + return + } + // 사용자 메시지 추가 - const userMessage: ChatMessage = { - id: Date.now().toString(), + const sentAt = Date.now() + addMessage({ + id: sentAt.toString(), message: options.message, isMyMessage: true, - timestamp: new Date().toISOString(), - } - addMessage(userMessage) + timestamp: new Date(sentAt).toISOString(), + }) // 로딩 메시지 추가 - const loadingMessageId = `loading-${Date.now()}` - const loadingMessage: ChatMessage = { + const loadingMessageId = `loading-${sentAt}` + addMessage({ id: loadingMessageId, message: '챗봇이 입력 중...', isMyMessage: false, - timestamp: new Date().toISOString(), - } - addMessage(loadingMessage) + timestamp: new Date(sentAt).toISOString(), + }) // API 호출 - 조건부로 sessionId 포함 const body: PostChatMessageBody = { - userId: options.userId, + userId, message: options.message, ...(currentSessionId && { sessionId: currentSessionId }), } try { const response = await sendMessageMutation.mutateAsync(body) - handleMutationSuccess(response, { ...options, loadingMessageId }) + handleMutationSuccess(response, loadingMessageId) } catch (error) { - handleMutationError(error as Error, { ...options, loadingMessageId }) + handleMutationError(error, loadingMessageId) } }, [sendMessageMutation, addMessage, handleMutationSuccess, handleMutationError, currentSessionId], @@ -110,7 +114,7 @@ export const useChatMessages = (): UseChatMessagesReturn => { return { messages, - recommendations, + recommendations: RECOMMENDATIONS, sendMessage, isSending: sendMessageMutation.isPending, } diff --git a/src/components/features/community/NewPostFAB.tsx b/src/components/features/community/NewPostFAB.tsx index a79c16e..688e597 100644 --- a/src/components/features/community/NewPostFAB.tsx +++ b/src/components/features/community/NewPostFAB.tsx @@ -4,16 +4,16 @@ import PencilIcon from '@/assets/icons/pencil.svg' const NewPostFAB = (): JSX.Element => { const router = useRouter() - const onPress = () => { - router.push('community/write') - } + return ( ) } diff --git a/src/components/features/facility/facility-card/index.tsx b/src/components/features/facility/facility-card/index.tsx index f4aa8d0..b861822 100644 --- a/src/components/features/facility/facility-card/index.tsx +++ b/src/components/features/facility/facility-card/index.tsx @@ -1,7 +1,6 @@ import { ReactElement } from 'react' import FacilityStat from './FacilityStat' -// import MapIcon from '@/assets/icons/map_thin.svg' import ReviewIcon from '@/assets/icons/chat_small.svg' import StarIcon from '@/assets/icons/star_small.svg' import Chip from '@/components/common/Chip' diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index 421c157..53da6b3 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -3,34 +3,50 @@ import { RefObject, useEffect, useRef } from 'react' import useIntersectionObserver from '@/hooks/useIntersectionObserver' -const ioOptions = { +/** + * 목록 끝에서 200px 앞서 다음 페이지를 부른다. 바닥에 닿고 나서 부르면 빈 화면이 보인다. + * 객체 참조가 매 렌더 바뀌면 옵저버가 다시 만들어지므로 모듈 스코프에 둔다. + */ +const ioOptions: IntersectionObserverInit = { + root: null, + rootMargin: '200px', threshold: 0, - delay: 0, } -type UseInfiniteScrollReturn = UseInfiniteQueryResult & { - loadMoreRef: RefObject +/** + * 이 훅이 실제로 쓰는 것만 요구한다. + * `UseInfiniteQueryResult` 로 못박으면 `useSuspenseInfiniteQuery` 결과를 받지 못한다 + * (suspense 결과에는 isPlaceholderData 등이 없어 union 이 서로 대입되지 않는다). + */ +type InfiniteQueryLike = Pick< + UseInfiniteQueryResult, + 'fetchNextPage' | 'hasNextPage' | 'isFetchingNextPage' +> + +type UseInfiniteScrollReturn = TQuery & { + loadMoreRef: RefObject isIntersecting: boolean | undefined observerRef: RefObject } -const useInfiniteScroll = ( - query: UseInfiniteQueryResult, -): UseInfiniteScrollReturn => { - const { fetchNextPage, hasNextPage } = query - const loadMoreRef = useRef(null!) +const useInfiniteScroll = ( + query: TQuery, +): UseInfiniteScrollReturn => { + const { fetchNextPage, hasNextPage, isFetchingNextPage } = query + const loadMoreRef = useRef(null) const { entries: [entry], observerRef, - } = useIntersectionObserver(loadMoreRef as RefObject, ioOptions) + } = useIntersectionObserver(loadMoreRef, ioOptions) const isIntersecting = entry?.isIntersecting useEffect(() => { - if (isIntersecting && hasNextPage) { + // 이미 받아오는 중이면 다시 부르지 않는다. + if (isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage() } - }, [isIntersecting, hasNextPage, fetchNextPage]) + }, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]) return { loadMoreRef, diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts index a809b4c..f8f037d 100644 --- a/src/hooks/useIntersectionObserver.ts +++ b/src/hooks/useIntersectionObserver.ts @@ -1,7 +1,13 @@ import { RefObject, useEffect, useRef, useState } from 'react' +/** + * 요소의 교차 상태를 구독한다. + * + * `options` 는 effect 의 의존성이므로 **모듈 스코프의 고정 객체**를 넘겨야 한다. + * 렌더마다 새 객체 리터럴을 넘기면 옵저버가 매 렌더 다시 만들어진다. + */ const useIntersectionObserver = ( - elemRef: RefObject, + elemRef: RefObject, options: IntersectionObserverInit, ): { entries: IntersectionObserverEntry[] @@ -14,10 +20,11 @@ const useIntersectionObserver = ( const node = elemRef.current if (!node) return - observerRef.current = new IntersectionObserver(setEntries, options) - observerRef.current.observe(node) + const observer = new IntersectionObserver(setEntries, options) + observerRef.current = observer + observer.observe(node) - return () => observerRef.current?.disconnect() + return () => observer.disconnect() }, [elemRef, options]) return { diff --git a/src/stores/useChatStore.ts b/src/stores/useChatStore.ts index e4ea390..ea8b991 100644 --- a/src/stores/useChatStore.ts +++ b/src/stores/useChatStore.ts @@ -58,6 +58,8 @@ export const useChatStore = create()( }), { name: 'chat-store', + // 프로덕션에서는 Redux DevTools 커넥터를 붙이지 않는다(액션 이력을 계속 들고 있다). + enabled: process.env.NODE_ENV === 'development', }, ), ) diff --git a/src/types/chat.ts b/src/types/chat.ts index 2627788..4b44383 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -16,7 +16,6 @@ export interface ChatStore { export type SendMessageOptions = { message: string - userId: string } export interface UseChatMessagesReturn { From bff91574b8fc77ca4f7301e431555ff3c6a9feb0 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 23 Aug 2026 21:07:29 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat(dev):=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EA=B0=80=EB=8A=94=20=EA=B0=9C=EB=B0=9C=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 로그인은 실제 앱 키와 등록된 리다이렉트 URI 가 있어야 해서 로컬에서 쓸 수 없다. 그래서 로그인 뒤 화면(아이 관리·건강 기록·마이페이지)을 전혀 확인할 수 없었다. - usePostLogin 추가. 기존에 postLogin 은 있었지만 이를 쓰는 훅이 없었다. 토큰을 메모리에 넣는 것까지 훅에서 끝내 호출부가 빠뜨릴 여지를 없앤다 - DevLoginButton 은 두 겹으로 막힌다. NODE_ENV 는 빌드 시 상수로 치환돼 분기 전체가 죽은 코드가 되고, 계정은 환경변수로만 들어와 값이 없으면 렌더되지 않는다. 환경변수를 채운 채 프로덕션 빌드를 돌려도 번들에 이메일·비밀번호·컴포넌트 이름이 남지 않는 것을 확인했다 - 회원가입 폼의 인라인 rules 를 제거하고 zod 스키마를 단일 출처로 삼는다. @hookform/resolvers 대신 25줄짜리 어댑터를 두어 의존성을 늘리지 않았다 --- .../features/login/DevLoginButton.tsx | 62 +++++++++++++++++++ .../features/login/KakaoLoginButton.tsx | 49 --------------- src/queries/auth.ts | 52 ++++++++-------- src/utils/zodResolver.ts | 32 ++++++++++ 4 files changed, 120 insertions(+), 75 deletions(-) create mode 100644 src/components/features/login/DevLoginButton.tsx delete mode 100644 src/components/features/login/KakaoLoginButton.tsx create mode 100644 src/utils/zodResolver.ts diff --git a/src/components/features/login/DevLoginButton.tsx b/src/components/features/login/DevLoginButton.tsx new file mode 100644 index 0000000..eb59226 --- /dev/null +++ b/src/components/features/login/DevLoginButton.tsx @@ -0,0 +1,62 @@ +'use client' +import { useRouter } from 'next/navigation' +import { ReactElement } from 'react' +import { getErrorMessage } from '@/apis/errors' +import { usePostLogin } from '@/queries/auth' + +/** + * 개발용 빠른 로그인. + * + * 카카오 로그인은 실제 앱 키와 등록된 리다이렉트 URI 가 있어야 하므로 로컬에서는 쓸 수 없다. + * 그 때문에 로그인 뒤 화면(아이 관리·건강 기록·마이페이지)을 전혀 확인할 수 없었다. + * + * **프로덕션 번들에 들어가면 안 된다.** 아래 두 겹으로 막는다. + * 1. `process.env.NODE_ENV` 는 빌드 시 상수로 치환되므로 이 분기 전체가 죽은 코드가 되어 제거된다. + * 2. 계정 정보는 환경변수로만 주입한다. 값이 없으면 버튼 자체가 나오지 않는다. + */ +const DEV_EMAIL = process.env.NEXT_PUBLIC_DEV_LOGIN_EMAIL +const DEV_PASSWORD = process.env.NEXT_PUBLIC_DEV_LOGIN_PASSWORD + +const DevLoginButton = (): ReactElement | null => { + const router = useRouter() + const { mutate: login, isPending, isError, error } = usePostLogin() + + if (process.env.NODE_ENV !== 'development') return null + if (!DEV_EMAIL || !DEV_PASSWORD) return null + + const handleClick = () => { + login( + { email: DEV_EMAIL, password: DEV_PASSWORD }, + { + onSuccess: (data) => { + if (!data.success) return + router.replace('/home') + }, + }, + ) + } + + return ( +
    + + + {isError && ( +

    + {getErrorMessage( + error, + '개발 계정으로 로그인하지 못했어요. 백엔드가 떠 있는지 확인해주세요.', + )} +

    + )} +
    + ) +} + +export default DevLoginButton diff --git a/src/components/features/login/KakaoLoginButton.tsx b/src/components/features/login/KakaoLoginButton.tsx deleted file mode 100644 index 96422ef..0000000 --- a/src/components/features/login/KakaoLoginButton.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client' -import { AxiosError } from 'axios' -import { useRouter } from 'next/navigation' -import { JSX, useEffect } from 'react' -import { ZodError } from 'zod' -import { postKakaoLogin, setTokens } from '@/apis/auth' - -const KakaoLoginButton = (): JSX.Element => { - const router = useRouter() - - const handleLogin = () => { - const kakaoURL = `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_REST_API_KEY}&redirect_uri=${process.env.NEXT_PUBLIC_REDIRECT_URL}&response_type=code` - window.location.href = kakaoURL - } - - useEffect(() => { - const code = new URL(window.location.href).searchParams.get('code')?.trim() - if (!code) return - - const login = async () => { - try { - const data = await postKakaoLogin({ kakaoAccessToken: code }) - setTokens(data.accessToken, data.userId, data.expiresIn) - router.push('/home') - } catch (error) { - if (error instanceof AxiosError) { - if (error.response?.status === 404) { - console.log('유저 없음, 회원가입 진행') - router.push('/register') - } else { - console.error('Axios 요청 실패', error.response?.status) - } - } else if (error instanceof ZodError) { - console.error('응답 검증 실패', error) - } else if (error instanceof Error) { - console.error('기타 오류', error) - } else { - console.error('알 수 없는 오류', error) - } - } - } - - login() - }, [router]) - - return -} - -export default KakaoLoginButton diff --git a/src/queries/auth.ts b/src/queries/auth.ts index a970f42..24fc21d 100644 --- a/src/queries/auth.ts +++ b/src/queries/auth.ts @@ -1,36 +1,21 @@ -import { createQueryKeys } from '@lukemorales/query-key-factory' -import { useMutation, UseMutationResult, useQuery, UseQueryResult } from '@tanstack/react-query' +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query' import { getKakaoAuthUrl, postKakaoAuth, - postSignup, + postLogin, postKakaoCompleteRegistration, + setTokens, } from '@/apis/auth' import { GetKakaoAuthUrlResponse, PostKakaoAuthBody, PostKakaoAuthResponse, - PostSignupBody, - PostSignupResponse, + PostLoginBody, + PostLoginResponse, KakaoRegistrationRequest, KakaoRegistrationResponse, } from '@/types/apis/auth' -export const authQueries = createQueryKeys('auth', { - kakaoAuthUrl: (redirectUri?: string) => ({ - queryKey: ['kakaoAuthUrl', redirectUri], - queryFn: () => getKakaoAuthUrl(redirectUri), - }), -}) - -export const useGetKakaoAuthUrl = ( - redirectUri?: string, -): UseQueryResult => { - return useQuery({ - ...authQueries.kakaoAuthUrl(redirectUri), - }) -} - export const useGetKakaoAuthUrlMutation = (): UseMutationResult< GetKakaoAuthUrlResponse, Error, @@ -41,6 +26,27 @@ export const useGetKakaoAuthUrlMutation = (): UseMutationResult< }) } +/** + * 이메일·비밀번호 로그인. + * + * 성공하면 액세스 토큰을 메모리에 넣는 것까지 여기서 끝낸다. 호출부마다 setTokens 를 + * 부르게 하면 한 곳만 빠뜨려도 "로그인은 됐는데 인증이 안 되는" 상태가 된다. + * (리프레시 토큰은 서버가 HttpOnly 쿠키로 심으므로 프런트가 다루지 않는다) + */ +export const usePostLogin = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: postLogin, + onSuccess: (data) => { + if (!data.success) return + setTokens(data.accessToken, data.user.userId, data.expiresIn) + // 로그인 전에 비어 있던 응답들을 다시 받는다. + queryClient.clear() + }, + }) +} + export const usePostKakaoAuth = (): UseMutationResult< PostKakaoAuthResponse, Error, @@ -51,12 +57,6 @@ export const usePostKakaoAuth = (): UseMutationResult< }) } -export const usePostSignup = (): UseMutationResult => { - return useMutation({ - mutationFn: postSignup, - }) -} - export const usePostKakaoCompleteRegistration = (): UseMutationResult< KakaoRegistrationResponse, Error, diff --git a/src/utils/zodResolver.ts b/src/utils/zodResolver.ts new file mode 100644 index 0000000..ebaf0d5 --- /dev/null +++ b/src/utils/zodResolver.ts @@ -0,0 +1,32 @@ +import type { FieldValues, Resolver } from 'react-hook-form' +import type { ZodType } from 'zod' + +/** + * zod 스키마를 react-hook-form 의 resolver 로 쓴다. + * + * 폼 규칙을 `rules={{ ... }}` 로 따로 적으면 API 스키마와 두 벌이 되고, 서버 계약이 바뀌어도 + * 폼은 모른 채 통과시킨다. 검증의 출처를 스키마 하나로 모은다. + * + * `@hookform/resolvers` 를 쓰지 않는 이유는 이 어댑터가 하는 일이 이게 전부이기 때문이다. + * (resolver 계약: 값이 유효하면 `{ values, errors: {} }`, 아니면 `{ values: {}, errors }`) + */ +export const zodResolver = + (schema: ZodType): Resolver => + async (values) => { + const result = schema.safeParse(values) + + if (result.success) { + return { values: values as TFieldValues, errors: {} } + } + + const errors: Record = {} + for (const issue of result.error.issues) { + // 같은 필드에 여러 이슈가 있으면 첫 번째만 보여준다. 한 번에 하나씩 고치게 한다. + const path = issue.path.join('.') + if (path && !errors[path]) { + errors[path] = { type: issue.code, message: issue.message } + } + } + + return { values: {}, errors: errors as never } + } From d22a80f9be239093d5d82e028d77a32470b9f677 Mon Sep 17 00:00:00 2001 From: RosieOh <20172207@gm.hannam.ac.kr> Date: Sun, 23 Aug 2026 21:08:14 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat(screen):=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B3=84=EC=B8=B5=EB=A7=8C=20=EC=9E=88=EA=B3=A0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=B4=20=EC=97=86=EB=8D=98=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EB=93=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 훅은 만들어져 있는데 어떤 화면도 쓰지 않는 항목이 30개 있었다. 그중 실제로 빠져 있던 화면과 기능을 채웠다. 새 화면 - /children/[childId]/edit — 아이 정보 수정. 등록 화면만 있고 수정이 없었다 - /mypage/blocked — 차단한 사용자 관리. useBlockUser 는 호출부가 0개라 애초에 아무도 차단할 수 없었다. 게시글 메뉴에 차단을 붙여 진입점도 만들었다 - /chat/history — 지난 상담 내역. 세션을 펼칠 때만 그 기록을 받는다 기존 화면에 빠져 있던 것 - 프로필 수정이 완전히 죽어 있었다. 값을 하드코딩하고 저장 버튼에 핸들러가 없어 입력이 그대로 버려졌다. 프로필을 불러와 채우고 저장하도록 다시 만들었다 - 댓글 수정·삭제 (API 자체가 없어 PUT/DELETE 추가) - 시설·병원 리뷰 수정·삭제 (공용 ReviewItem) - 알림 개별 읽음·삭제. 알림을 열면 딥링크로 이동해버려 열지 않고 읽음 처리할 방법이 필요했다 - 관리자 정책 검증 표시·해제. 사용자 화면의 확정/추정 금액을 가르는 버튼인데 없었다 작성자 판별도 함께 고쳤다. getUserId() 는 user_1787... 인데 authorId 는 DB id 라 항상 거짓이었고, 본인 글에도 수정·삭제가 뜬 적이 없다. 서버가 자리마다 다른 식별자를 쓰므로(시설 리뷰만 업무 userId, 나머지는 DB id) useCurrentUser 가 둘을 함께 돌려주고 각 자리에서 맞는 쪽을 고른다. --- src/apis/community.ts | 25 +++ src/apis/facility.ts | 11 -- src/apis/notification.ts | 17 +- src/apis/policy.ts | 10 - src/apis/user.ts | 9 - src/app/(without-tabs)/chat/history/page.tsx | 126 ++++++++++++ .../children/[childId]/edit/page.tsx | 181 ++++++++++++++++++ .../children/[childId]/page.tsx | 23 ++- .../(without-tabs)/community/[id]/page.tsx | 110 +++++++++-- src/app/(without-tabs)/facility/[id]/page.tsx | 51 ++--- src/app/(without-tabs)/hospital/[id]/page.tsx | 55 +++--- .../(without-tabs)/mypage/blocked/page.tsx | 107 +++++++++++ src/app/admin/policies/manage/page.tsx | 45 ++++- src/components/features/community/Comment.tsx | 113 +++++++++-- .../features/facility/ReviewItem.tsx | 137 +++++++++++++ src/hooks/useCurrentUser.ts | 25 +++ src/queries/community.ts | 73 +++++-- src/types/apis/community.ts | 14 ++ src/types/apis/facility.ts | 6 - src/types/apis/notification.ts | 7 - src/types/apis/policy.ts | 10 - 21 files changed, 988 insertions(+), 167 deletions(-) create mode 100644 src/app/(without-tabs)/chat/history/page.tsx create mode 100644 src/app/(without-tabs)/children/[childId]/edit/page.tsx create mode 100644 src/app/(without-tabs)/mypage/blocked/page.tsx create mode 100644 src/components/features/facility/ReviewItem.tsx create mode 100644 src/hooks/useCurrentUser.ts diff --git a/src/apis/community.ts b/src/apis/community.ts index 3e63596..61770d0 100644 --- a/src/apis/community.ts +++ b/src/apis/community.ts @@ -27,6 +27,14 @@ import { postCommunityCommentPathSchema, PostCommunityCommentResponse, postCommunityCommentResponseSchema, + PutCommunityCommentBody, + putCommunityCommentBodySchema, + PutCommunityCommentPath, + putCommunityCommentPathSchema, + PutCommunityCommentResponse, + putCommunityCommentResponseSchema, + DeleteCommunityCommentPath, + deleteCommunityCommentPathSchema, PostCommunityPostBody, postCommunityPostBodySchema, PostCommunityPostResponse, @@ -144,3 +152,20 @@ export const getCommunityTags = async (): Promise => { const res = await CareCode.get('/community/tags') return z.array(z.string()).parse(res.data) } + +// PUT /community/comments/{commentId} +export const putCommunityComment = async ( + path: PutCommunityCommentPath, + body: PutCommunityCommentBody, +): Promise => { + const parsedPath = putCommunityCommentPathSchema.parse(path) + const parsedBody = putCommunityCommentBodySchema.parse(body) + const res = await CareCode.put(`/community/comments/${parsedPath.commentId}`, parsedBody) + return putCommunityCommentResponseSchema.parse(res.data) +} + +// DELETE /community/comments/{commentId} +export const deleteCommunityComment = async (path: DeleteCommunityCommentPath): Promise => { + const parsedPath = deleteCommunityCommentPathSchema.parse(path) + await CareCode.delete(`/community/comments/${parsedPath.commentId}`) +} diff --git a/src/apis/facility.ts b/src/apis/facility.ts index f561d4c..0bfe53f 100644 --- a/src/apis/facility.ts +++ b/src/apis/facility.ts @@ -12,8 +12,6 @@ import { facilityReviewBodySchema, facilityReviewListSchema, facilityReviewSchema, - GetFacilitiesByKeywordQuery, - getFacilitiesByKeywordQuerySchema, GetFacilitiesByLocationPath, getFacilitiesByLocationPathSchema, GetFacilitiesByTypePath, @@ -76,15 +74,6 @@ export const getFacilitiesInRadius = async ( return facilityListSchema.parse(res.data) } -// 키워드 검색 -export const getFacilitiesByKeyword = async ( - query: GetFacilitiesByKeywordQuery, -): Promise => { - const parsedQuery = getFacilitiesByKeywordQuerySchema.parse(query) - const res = await CareCode.get('/facilities/keyword', { params: parsedQuery }) - return facilityListSchema.parse(res.data) -} - // 인기 시설 export const getPopularFacilities = async (limit = 10): Promise => { const res = await CareCode.get('/facilities/popular', { params: { limit } }) diff --git a/src/apis/notification.ts b/src/apis/notification.ts index e381bb4..373bcdf 100644 --- a/src/apis/notification.ts +++ b/src/apis/notification.ts @@ -1,10 +1,6 @@ import { getAccessToken, getUserId } from '@/apis/auth' import { CareCode } from '@/apis/interceptor' import { - GetNotificationByIdPath, - GetNotificationByIdResponse, - getNotificationByIdPathSchema, - getNotificationByIdResponseSchema, GetNotificationChannelsResponse, getNotificationChannelsResponseSchema, GetNotificationPreferencesResponse, @@ -25,19 +21,16 @@ export const getNotificationList = async (): Promise = return getNotificationsResponseSchema.parse(res.data) } -export const getNotificationById = async ( - path: GetNotificationByIdPath, -): Promise => { - const parsedPath = getNotificationByIdPathSchema.parse(path) - const res = await CareCode.get(`/notifications/${parsedPath.notificationId}`) - return getNotificationByIdResponseSchema.parse(res.data) -} - export const putNotificationToRead = async (path: PutNotificationToReadPath): Promise => { const parsedPath = putNotificationToReadPathSchema.parse(path) await CareCode.put(`/notifications/${parsedPath.notificationId}/read`) } +// DELETE /notifications/{notificationId} +export const deleteNotification = async (notificationId: number): Promise => { + await CareCode.delete(`/notifications/${notificationId}`) +} + // PUT /notifications/read-all export const putAllNotificationsToRead = async (): Promise => { await CareCode.put('/notifications/read-all') diff --git a/src/apis/policy.ts b/src/apis/policy.ts index d530038..109faf8 100644 --- a/src/apis/policy.ts +++ b/src/apis/policy.ts @@ -1,13 +1,9 @@ import { CareCode } from '@/apis/interceptor' import { - GetPolicyListQuery, - getPolicyListQuerySchema, - getPolicyListResponseSchema, GetPolicyByIdPath, getPolicyByIdPathSchema, getPolicyByIdResponseSchema, GetPolicyByIdResponse, - GetPolicyListResponse, getLatestPoliciesResponseSchema, GetLatestPoliciesResponse, PolicySearchRequestDto, @@ -31,12 +27,6 @@ import { benefitAmountReportBodySchema, } from '@/types/apis/policy' -export const getPolicyList = async (query: GetPolicyListQuery): Promise => { - const parsedQuery = getPolicyListQuerySchema.parse(query) - const res = await CareCode.get('/policies', { params: parsedQuery }) - return getPolicyListResponseSchema.parse(res.data) -} - export const getPolicyById = async (path: GetPolicyByIdPath): Promise => { const parsedPath = getPolicyByIdPathSchema.parse(path) const res = await CareCode.get(`/policies/${parsedPath.policyId}`) diff --git a/src/apis/user.ts b/src/apis/user.ts index 7c15cd2..09e8fc6 100644 --- a/src/apis/user.ts +++ b/src/apis/user.ts @@ -4,8 +4,6 @@ import { getProfileCompletionResponseSchema, GetUserInfoResponse, getUserInfoResponseSchema, - PatchNicknameBody, - patchNicknameBodySchema, PutUserInfoBody, putUserInfoBodySchema, PutUserInfoResponse, @@ -25,13 +23,6 @@ export const putUserInfo = async (body: PutUserInfoBody): Promise => { - const parsedBody = patchNicknameBodySchema.parse(body) - const res = await CareCode.patch('/users/profile/nickname', parsedBody) - return putUserInfoResponseSchema.parse(res.data) -} - // GET /users/profile/completion - 프로필 완성도 export const getProfileCompletion = async (): Promise => { const res = await CareCode.get('/users/profile/completion') diff --git a/src/app/(without-tabs)/chat/history/page.tsx b/src/app/(without-tabs)/chat/history/page.tsx new file mode 100644 index 0000000..1eaef75 --- /dev/null +++ b/src/app/(without-tabs)/chat/history/page.tsx @@ -0,0 +1,126 @@ +'use client' +import { formatDistanceToNow } from 'date-fns' +import { ko } from 'date-fns/locale' +import { ReactElement, useState } from 'react' +import AuthGuard from '@/components/common/AuthGuard' +import EmptyState from '@/components/common/EmptyState' +import ErrorView from '@/components/common/Error' +import Layout from '@/components/common/Layout' +import ChatBubble from '@/components/features/chat/chat-message/ChatBubble' +import { useChatHistory, useChatSessions } from '@/queries/chatbot' +import { toDate } from '@/utils/date' + +const formatTimeAgo = (value?: string | null): string => { + const date = toDate(value) + if (!date) return '' + return formatDistanceToNow(date, { addSuffix: true, locale: ko }) +} + +const SessionMessages = ({ sessionId }: { sessionId: string }): ReactElement => { + const { data: history = [], isLoading, isError, refetch } = useChatHistory({ sessionId }) + + if (isLoading) { + return ( +
    + {[0, 1].map((i) => ( +
    + ))} +
    + ) + } + + if (isError) { + return ( +
    + +
    + ) + } + + return ( +
    + {/* 서버는 최신순으로 준다. 대화는 시간 순으로 읽는 것이 자연스러우므로 뒤집는다. */} + {[...history].reverse().map((item) => ( +
    +
    + {item.message} +
    +
    + {item.response} +
    +
    + ))} +
    + ) +} + +const ChatHistoryContent = (): ReactElement => { + const { data: sessions = [], isLoading, isError, refetch } = useChatSessions({ size: 20 }) + const [openSessionId, setOpenSessionId] = useState(null) + + if (isLoading) { + return ( +
      + {[0, 1, 2].map((i) => ( +
    • + ))} +
    + ) + } + + if (isError) { + return refetch()} /> + } + + if (sessions.length === 0) { + return ( + + ) + } + + return ( +
      + {sessions.map((session) => { + const isOpen = openSessionId === session.sessionId + + return ( +
    • + + + {/* 열었을 때만 그 세션의 기록을 받는다. 전부 미리 받으면 세션 수만큼 요청이 나간다. */} + {isOpen && } +
    • + ) + })} +
    + ) +} + +const ChatHistoryPage = (): ReactElement => ( + + + + + +) + +export default ChatHistoryPage diff --git a/src/app/(without-tabs)/children/[childId]/edit/page.tsx b/src/app/(without-tabs)/children/[childId]/edit/page.tsx new file mode 100644 index 0000000..e7546cc --- /dev/null +++ b/src/app/(without-tabs)/children/[childId]/edit/page.tsx @@ -0,0 +1,181 @@ +'use client' +import { useParams, useRouter } from 'next/navigation' +import { ReactElement, useEffect } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { getErrorMessage } from '@/apis/errors' +import AuthGuard from '@/components/common/AuthGuard' +import Button from '@/components/common/Button' +import ErrorView from '@/components/common/Error' +import Layout from '@/components/common/Layout' +import ToggleChip from '@/components/common/ToggleChip' +import Input from '@/components/common/input' +import { useChildDetail, useUpdateChild } from '@/queries/child' +import { ChildBody } from '@/types/apis/child' + +const GENDER_OPTIONS = [ + { value: 'MALE', label: '남아' }, + { value: 'FEMALE', label: '여아' }, +] + +const EditChildContent = ({ childId }: { childId: number }): ReactElement => { + const router = useRouter() + const { data: child, isLoading, isError, refetch } = useChildDetail(childId) + const { mutate: updateChild, isPending, isError: isSaveError, error } = useUpdateChild(childId) + const today = new Date().toISOString().slice(0, 10) + + const { + control, + handleSubmit, + reset, + formState: { errors, isValid }, + } = useForm({ + mode: 'onChange', + defaultValues: { name: '', birthDate: '', gender: undefined, specialNeeds: '' }, + }) + + // 서버 값이 도착하면 폼을 채운다. 빈 폼으로 저장하면 기존 정보를 지우게 된다. + useEffect(() => { + if (!child) return + reset({ + name: child.name ?? '', + birthDate: child.birthDate ?? '', + gender: child.gender ?? undefined, + specialNeeds: child.specialNeeds ?? '', + }) + }, [child, reset]) + + if (isLoading) { + return ( +
    + {[0, 1, 2].map((i) => ( +
    + ))} +
    + ) + } + + if (isError) { + return refetch()} /> + } + + const onSubmit = (values: ChildBody) => { + updateChild(values, { onSuccess: () => router.replace(`/children/${childId}`) }) + } + + return ( + + ( + + )} + /> + + value <= today || '생년월일은 오늘 이전이어야 합니다', + }} + render={({ field }) => ( + + )} + /> +

    + 생년월일을 고치면 예방접종 예정일도 함께 다시 계산돼요. +

    + + ( +
    + 성별 +
    + {GENDER_OPTIONS.map((option) => ( + field.onChange(pressed ? option.value : undefined)} + > + {option.label} + + ))} +
    +

    + 성별을 입력하면 WHO 기준 성장 백분위를 함께 볼 수 있어요. +

    +
    + )} + /> + + ( + + )} + /> + + {isSaveError && ( +

    + {getErrorMessage(error, '저장하지 못했어요. 잠시 후 다시 시도해주세요.')} +

    + )} + +
    + +
    + + ) +} + +const EditChildPage = (): ReactElement => { + const params = useParams() + const childId = Number(params.childId) + + return ( + + + + + + ) +} + +export default EditChildPage diff --git a/src/app/(without-tabs)/children/[childId]/page.tsx b/src/app/(without-tabs)/children/[childId]/page.tsx index 909d28b..ead7b27 100644 --- a/src/app/(without-tabs)/children/[childId]/page.tsx +++ b/src/app/(without-tabs)/children/[childId]/page.tsx @@ -80,13 +80,22 @@ const ChildDetailPage = (): ReactElement => {
    )} - +
    + + +
    diff --git a/src/app/(without-tabs)/community/[id]/page.tsx b/src/app/(without-tabs)/community/[id]/page.tsx index 4dfe8c6..8d04996 100644 --- a/src/app/(without-tabs)/community/[id]/page.tsx +++ b/src/app/(without-tabs)/community/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useParams, useRouter } from 'next/navigation' import { JSX, Suspense, useState } from 'react' -import { getAccessToken, getUserId } from '@/apis/auth' +import { getAccessToken } from '@/apis/auth' import ArrowLeftIcon from '@/assets/icons/arrow_left.svg' import KebabIcon from '@/assets/icons/edit.svg' import PaperIcon from '@/assets/icons/paper_small.svg' @@ -16,14 +16,17 @@ import { Menubox } from '@/components/common/menubox' import ActionButton from '@/components/features/community/ActionButton' import Comment from '@/components/features/community/Comment' import ReportDialog from '@/components/features/community/ReportDialog' +import { useCurrentUser } from '@/hooks/useCurrentUser' import { useDeleteCommunityPost, useGetCommunityPostDetail, + useDeleteCommunityComment, usePostCommunityPostComment, + useUpdateCommunityComment, useToggleCommunityBookmark, useToggleCommunityLike, } from '@/queries/community' -import { useReport } from '@/queries/moderation' +import { useBlockUser, useReport } from '@/queries/moderation' import { PostCommunityCommentBody } from '@/types/apis/community' import { formatDate } from '@/utils/date' @@ -35,19 +38,26 @@ const CommunityDetail = (): JSX.Element => { const { data: post } = useGetCommunityPostDetail({ postId }) const { mutate: addComment, isPending: isAddingComment } = usePostCommunityPostComment({ postId }) + const { mutate: updateComment, isPending: isUpdatingComment } = useUpdateCommunityComment(postId) + const { mutate: removeComment } = useDeleteCommunityComment(postId) const { mutate: deletePost, isPending: isDeleting } = useDeleteCommunityPost({ postId }) const { mutate: toggleLike, isPending: isTogglingLike } = useToggleCommunityLike(postId) const { mutate: toggleBookmark, isPending: isTogglingBookmark } = useToggleCommunityBookmark(postId) + const { dbId } = useCurrentUser() const { mutate: report, isPending: isReporting } = useReport() + const { mutate: blockUser, isPending: isBlocking } = useBlockUser() const [newComment, setNewComment] = useState('') const [deleteDialogVisible, setDeleteDialogVisible] = useState(false) const [reportDialogVisible, setReportDialogVisible] = useState(false) + const [blockDialogVisible, setBlockDialogVisible] = useState(false) + const [commentToDelete, setCommentToDelete] = useState(null) const [reportDone, setReportDone] = useState(false) // 작성자 본인에게만 수정·삭제를, 그 외에는 신고를 노출한다. - const isAuthor = !!post && getUserId() === post.authorId + // authorId 는 DB id 다. 세션의 userId(`user_...`) 와 비교하면 항상 어긋난다. + const isAuthor = !!post && !!dbId && dbId === post.authorId const requireLogin = (action: () => void) => { if (!getAccessToken()) { @@ -93,11 +103,18 @@ const CommunityDetail = (): JSX.Element => { variant: 'destructive' as const, onSelect: () => requireLogin(() => setReportDialogVisible(true)), }, + { + // 신고는 관리자 판단을 기다려야 하지만, 차단은 그 자리에서 내 목록에서 치운다. + content: '이 사용자 차단', + icon: WarningIcon, + variant: 'destructive' as const, + onSelect: () => requireLogin(() => setBlockDialogVisible(true)), + }, ] return ( }> -
    +
    { )} - {post.comments?.map((comment) => ( - - ))} + {post.comments?.map((comment) => { + // 본인 댓글에만 수정·삭제를 준다. 실제 통제는 서버가 한다. + // authorId 는 DB id 다. 세션의 userId(`user_...`) 와 비교하면 항상 어긋난다. + const isMine = !!dbId && dbId === comment.authorId + + return ( + updateComment({ commentId: comment.commentId, content }) + : undefined + } + onDelete={isMine ? () => setCommentToDelete(comment.commentId) : undefined} + /> + ) + })}
    @@ -216,6 +246,56 @@ const CommunityDetail = (): JSX.Element => { } /> + setCommentToDelete(null)} + cancelButton={ + + } + confirmButton={ + + } + /> + + setBlockDialogVisible(false)} + cancelButton={ + + } + confirmButton={ + + } + /> + { const params = useParams<{ id: string }>() @@ -38,6 +40,8 @@ const FacilityDetailPage = (): ReactElement => { const { data: facility, isLoading, isError, refetch } = useFacilityDetail(facilityId) const { data: reviews = [], isLoading: isReviewLoading } = useFacilityReviews(facilityId) const { mutate: createReview, isPending: isReviewPending } = useCreateFacilityReview(facilityId) + const { mutate: updateReview, isPending: isUpdatingReview } = useUpdateFacilityReview(facilityId) + const { mutate: removeReview } = useDeleteFacilityReview(facilityId) const { mutate: createBooking, isPending: isBookingPending, @@ -154,27 +158,28 @@ const FacilityDetailPage = (): ReactElement => { /> ) : (
      - {reviews.map((review) => ( -
    • -
      -
      - - - {review.rating ?? '-'} - -
      - - {formatDate(review.createdAt)} - -
      -

      - {review.content} -

      -
    • - ))} + {reviews.map((review) => { + // 본인 리뷰에만 수정·삭제를 준다. 실제 통제는 서버가 한다. + // 시설 리뷰의 userId 는 업무 식별자다 (CareFacilityService: getUser().getUserId()). + // 병원 리뷰·커뮤니티와 달리 DB id 가 아니므로 여기서는 getUserId() 가 맞다. + const isMine = !!review.userId && review.userId === getUserId() + + return ( + updateReview({ reviewId: review.reviewId, body }) + : undefined + } + onDelete={isMine ? () => removeReview(review.reviewId) : undefined} + /> + ) + })}
    )} diff --git a/src/app/(without-tabs)/hospital/[id]/page.tsx b/src/app/(without-tabs)/hospital/[id]/page.tsx index 027c087..378609d 100644 --- a/src/app/(without-tabs)/hospital/[id]/page.tsx +++ b/src/app/(without-tabs)/hospital/[id]/page.tsx @@ -2,7 +2,6 @@ import { useParams, useRouter } from 'next/navigation' import { ReactElement } from 'react' import { getAccessToken } from '@/apis/auth' -import StarIcon from '@/assets/icons/star_small.svg' import Chip from '@/components/common/Chip' import DescriptionItem from '@/components/common/DescriptionItem' import EmptyState from '@/components/common/EmptyState' @@ -10,14 +9,17 @@ import ErrorView from '@/components/common/Error' import Layout from '@/components/common/Layout' import Separator from '@/components/common/Separator' import ReviewForm from '@/components/features/facility/ReviewForm' +import ReviewItem from '@/components/features/facility/ReviewItem' +import { useCurrentUser } from '@/hooks/useCurrentUser' import { useCreateHospitalReview, + useDeleteHospitalReview, + useUpdateHospitalReview, useHospitalDetail, useHospitalLikeStatus, useHospitalReviews, useToggleHospitalLike, } from '@/queries/hospital' -import { formatDate } from '@/utils/date' const HospitalDetailPage = (): ReactElement => { const params = useParams<{ id: string }>() @@ -27,9 +29,12 @@ const HospitalDetailPage = (): ReactElement => { const { data: hospital, isLoading, isError, refetch } = useHospitalDetail(hospitalId) // 찜 여부는 서버가 알려준다. 로컬 state 로 두면 새로고침마다 초기화된다. const { data: likeStatus } = useHospitalLikeStatus(hospitalId) + const { dbId } = useCurrentUser() const { data: reviews = [], isLoading: isReviewLoading } = useHospitalReviews(hospitalId) const { mutate: toggleLike, isPending: isTogglingLike } = useToggleHospitalLike(hospitalId) const { mutate: createReview, isPending: isReviewPending } = useCreateHospitalReview(hospitalId) + const { mutate: updateReview, isPending: isUpdatingReview } = useUpdateHospitalReview(hospitalId) + const { mutate: removeReview } = useDeleteHospitalReview(hospitalId) const liked = likeStatus?.liked ?? false const likeCount = likeStatus?.likeCount ?? 0 @@ -109,32 +114,26 @@ const HospitalDetailPage = (): ReactElement => { /> ) : (
      - {reviews.map((review) => ( -
    • -
      -
      - - - {review.rating ?? '-'} - - {review.userName && ( - - {review.userName} - - )} -
      - - {formatDate(review.createdAt)} - -
      -

      - {review.content} -

      -
    • - ))} + {reviews.map((review) => { + // 본인 리뷰에만 수정·삭제를 준다. 실제 통제는 서버가 한다. + // 병원 리뷰의 userId 는 DB id 다 (HospitalReviewMapper: getUser().getId()). + const isMine = review.userId != null && String(review.userId) === dbId + + return ( + updateReview({ reviewId: review.id, body }) : undefined + } + onDelete={isMine ? () => removeReview(review.id) : undefined} + /> + ) + })}
    )} diff --git a/src/app/(without-tabs)/mypage/blocked/page.tsx b/src/app/(without-tabs)/mypage/blocked/page.tsx new file mode 100644 index 0000000..71b27cf --- /dev/null +++ b/src/app/(without-tabs)/mypage/blocked/page.tsx @@ -0,0 +1,107 @@ +'use client' +import { ReactElement, useState } from 'react' +import { getErrorMessage } from '@/apis/errors' +import AlertDialog from '@/components/common/AlertDialog' +import AuthGuard from '@/components/common/AuthGuard' +import Button from '@/components/common/Button' +import EmptyState from '@/components/common/EmptyState' +import ErrorView from '@/components/common/Error' +import Layout from '@/components/common/Layout' +import { useBlockedUsers, useUnblockUser } from '@/queries/moderation' + +const BlockedUsersContent = (): ReactElement => { + const { data: blockedIds = [], isLoading, isError, refetch } = useBlockedUsers() + const { mutate: unblock, isPending, variables, isError: isUnblockError, error } = useUnblockUser() + const [target, setTarget] = useState(null) + + if (isLoading) { + return ( +
      + {[0, 1, 2].map((i) => ( +
    • + ))} +
    + ) + } + + if (isError) { + return refetch()} /> + } + + if (blockedIds.length === 0) { + return ( + + ) + } + + return ( + <> +

    + 차단한 사용자의 글과 댓글은 목록에서 보이지 않아요. 차단을 풀면 다시 보입니다. +

    + + {isUnblockError && ( +

    + {getErrorMessage(error, '차단을 풀지 못했어요. 잠시 후 다시 시도해주세요.')} +

    + )} + +
      + {blockedIds.map((userId) => ( +
    • + {/* + 서버가 주는 것은 사용자 ID 뿐이라(GET /community/blocks → List) + 이름을 보여줄 수 없다. 없는 정보를 지어내지 않고 식별자를 그대로 보여준다. + */} + 사용자 #{userId} + +
    • + ))} +
    + + setTarget(null)} + cancelButton={ + + } + confirmButton={ + + } + /> + + ) +} + +const BlockedUsersPage = (): ReactElement => ( + + + + + +) + +export default BlockedUsersPage diff --git a/src/app/admin/policies/manage/page.tsx b/src/app/admin/policies/manage/page.tsx index 78c101e..76a8812 100644 --- a/src/app/admin/policies/manage/page.tsx +++ b/src/app/admin/policies/manage/page.tsx @@ -15,6 +15,8 @@ import { useCreateAdminPolicy, useDeleteAdminPolicy, useUpdateAdminPolicy, + useUnverifyPolicy, + useVerifyPolicy, } from '@/queries/admin' import { AdminPolicyDetail } from '@/types/apis/admin' import { formatDate } from '@/utils/date' @@ -46,6 +48,8 @@ const AdminPolicyManagePage = (): ReactElement => { isError: isUpdateError, } = useUpdateAdminPolicy() const { mutate: deletePolicy, isPending: isDeletePending } = useDeleteAdminPolicy() + const { mutate: verifyPolicy, isPending: isVerifying } = useVerifyPolicy() + const { mutate: unverifyPolicy, isPending: isUnverifying } = useUnverifyPolicy() const policies = data?.content ?? [] @@ -105,11 +109,46 @@ const AdminPolicyManagePage = (): ReactElement => { {policy.policyCode} -
    - - + ) : ( + + )} +
    diff --git a/src/components/features/community/Comment.tsx b/src/components/features/community/Comment.tsx index 49941a3..0402aad 100644 --- a/src/components/features/community/Comment.tsx +++ b/src/components/features/community/Comment.tsx @@ -1,42 +1,125 @@ +'use client' import clsx from 'clsx' -import { ReactElement } from 'react' +import { ReactElement, useState } from 'react' import ArrowDownRightIcon from '@/assets/icons/arrow_down_right_thin.svg' -type Comment = { +type CommentData = { author: string content: string timestamp: string - replies?: Comment[] + replies?: CommentData[] } interface CommentProps { - comment: Comment + comment: CommentData isReply?: boolean className?: string + /** 본인 댓글일 때만 넘긴다. 없으면 수정·삭제가 보이지 않는다. */ + onEdit?: (content: string) => void + onDelete?: () => void + isSaving?: boolean } -const Comment = ({ comment, isReply = false, className }: CommentProps): ReactElement => { +const Comment = ({ + comment, + isReply = false, + className, + onEdit, + onDelete, + isSaving = false, +}: CommentProps): ReactElement => { + const [isEditing, setIsEditing] = useState(false) + const [draft, setDraft] = useState(comment.content) + + const canManage = !!onEdit || !!onDelete + + const handleSave = () => { + const trimmed = draft.trim() + if (!trimmed || trimmed === comment.content) { + setIsEditing(false) + return + } + onEdit?.(trimmed) + setIsEditing(false) + } + return (
    - {/* 메인 댓글 */}
    - {/* 답글 표시 아이콘 */} - {isReply && } + {isReply && }
    - {/* 작성자 */} {comment.author} - {/* 내용 */} -

    {comment.content}

    - {/* 작성 시간 */} - {comment.timestamp} + + {isEditing ? ( +
    +