diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a466112 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# CareCode 백엔드 베이스 URL +# 백엔드의 CORS_ALLOWED_ORIGINS 에 이 앱의 주소(http://localhost:3000)가 들어 있어야 한다. +NEXT_PUBLIC_API_URL=http://localhost:8080 + +# 이 앱이 서비스되는 주소. sitemap.xml 과 robots.txt 의 절대 URL 에 쓰인다. +# 비우면 http://localhost:3000 으로 떨어지므로 배포 환경에서는 반드시 설정한다. +NEXT_PUBLIC_SITE_URL= + +# ── 웹 푸시(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..4a6dabd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +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: + # package.json 의 engines 하한(^22.22.2 || >=24.15.0)을 만족해야 한다. + # Node 20 에서는 jsdom 30 이 쓰는 undici 가 markAsUncloneable 을 찾지 못해 + # 테스트가 한 건도 돌지 않고 죽는다. + node-version: 24 + 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/README.md b/README.md index 88387f0..347f0ee 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ CareCode 백엔드(Spring Boot)의 REST API를 소비하는 모바일 우선 웹 ## 기술 스택 -| 영역 | 사용 기술 | -| ----------- | ------------------------------------------- | -| 프레임워크 | Next.js 15 (App Router) · React 19 | -| 상태·서버 | TanStack Query v5 · zustand | -| 스타일 | Tailwind CSS v4 · Radix UI | -| 폼·검증 | react-hook-form · zod | -| HTTP | axios (`src/apis/interceptor.ts`) | +| 영역 | 사용 기술 | +| ---------- | ---------------------------------- | +| 프레임워크 | Next.js 15 (App Router) · React 19 | +| 상태·서버 | TanStack Query v5 · zustand | +| 스타일 | Tailwind CSS v4 · Radix UI | +| 폼·검증 | react-hook-form · zod | +| HTTP | axios (`src/apis/interceptor.ts`) | ## 시작하기 @@ -26,11 +26,12 @@ http://localhost:3000 에서 확인할 수 있습니다. ### 환경 변수 -| 이름 | 설명 | -| --------------------- | --------------------------------------------------- | -| `NEXT_PUBLIC_API_URL` | CareCode 백엔드 베이스 URL (예: `http://localhost:8080`) | -| `NEXT_PUBLIC_FIREBASE_*` | 웹 푸시(FCM) 설정. 선택 — 비우면 푸시 기능 전체가 꺼집니다 | -| `NEXT_PUBLIC_FIREBASE_VAPID_KEY` | 웹 푸시 인증서 공개 키. 위와 함께 있어야 동작합니다 | +| 이름 | 설명 | +| ------------------------------------------- | ------------------------------------------------------------- | +| `NEXT_PUBLIC_API_URL` | CareCode 백엔드 베이스 URL (예: `http://localhost:8080`) | +| `NEXT_PUBLIC_FIREBASE_*` | 웹 푸시(FCM) 설정. 선택 — 비우면 푸시 기능 전체가 꺼집니다 | +| `NEXT_PUBLIC_FIREBASE_VAPID_KEY` | 웹 푸시 인증서 공개 키. 위와 함께 있어야 동작합니다 | +| `NEXT_PUBLIC_DEV_LOGIN_EMAIL` / `_PASSWORD` | 개발용 빠른 로그인 계정. 선택 — 비우면 버튼이 나오지 않습니다 | ## 디렉터리 구조 @@ -40,14 +41,28 @@ src/ ├── queries/ TanStack Query 훅 (query-key-factory 기반 키) ├── types/apis/ 서버 DTO 대응 zod 스키마 & 타입 ├── app/ App Router 라우트 -│ ├── (with-tabs)/ 하단 탭이 있는 화면 (홈·커뮤니티·검색·마이페이지) -│ └── (without-tabs)/ 단독 화면 (아이 관리·시설·게시글 상세 등) +│ ├── (with-tabs)/ 하단 탭이 있는 화면 (커뮤니티·육아 정보·홈·챗봇·마이페이지) +│ ├── (without-tabs)/ 단독 화면 (아이 관리·시설·게시글 상세·알림·회원가입 등) +│ ├── admin/ 관리자 (자체 레이아웃 + AdminGuard) +│ └── auth/ OAuth 콜백 (화면이 아니라 통과 지점이라 그룹 밖) ├── components/ │ ├── common/ 디자인 시스템 단위 컴포넌트 │ └── features/ 도메인 컴포넌트 └── utils/ 날짜·파일 등 순수 유틸 ``` +**모든 화면은 두 그룹 중 하나에 들어갑니다.** 그룹 밖에 두면 어느 레이아웃도 받지 못해 +스크롤 컨테이너를 페이지마다 다시 짜게 됩니다. 특히 **하단 탭의 목적지는 반드시 +`(with-tabs)/`** 여야 합니다 — 그룹 밖 경로를 탭에 넣으면 그 탭을 누르는 순간 탭 바가 +사라져 다른 탭으로 돌아갈 수 없습니다. + +### 의존성 규칙 + +**import 하는 패키지는 반드시 `package.json` 에 선언합니다.** 한동안 `framer-motion` 과 +radix 서브패키지 5개가 선언 없이 다른 패키지의 전이 의존성 호이스팅으로만 동작했습니다. +npm 에서는 우연히 동작하지만 pnpm·yarn PnP 로 옮기거나 상위 패키지가 의존성을 정리하면 +그날로 빌드가 깨집니다. motion 은 `motion/react` 경로 하나로만 import 합니다. + ### API 레이어 규칙 - `apis/*` 는 요청 body/param 을 zod 로 `parse` 한 뒤 보내고, 응답도 `parse` 해서 반환합니다. @@ -73,6 +88,98 @@ src/ > 백엔드는 쿠키가 없으면 요청 본문의 `refreshToken` 도 계속 받습니다. > 쿠키를 쓸 수 없는 클라이언트(모바일 네이티브 등)와 함께 동작해야 하기 때문입니다. +### 프로필 완성도 응답은 불리언 맵입니다 + +`GET /users/profile/completion` 은 이런 모양입니다. + +```json +{ "completionPercentage": 20, "complete": false, + "missingFields": { "needsAddress": true, "needsGender": true, ... } } +``` + +프런트 스키마는 `completionRate` 와 `missingFields: string[]` 을 기다리고 있었습니다. 모든 +필드가 `nullish()` 라 **파싱은 통과하고 값만 전부 `undefined` 가 되어** 완성도 0%, 빠진 항목 +없음처럼 조용히 틀렸고, `complete` 를 못 읽어 이미 다 채운 사용자에게도 안내가 계속 떴습니다. +계약 테스트로 고정했습니다. + +주소가 비면 지역별 지원금 비교와 주변 시설 추천이 아예 동작하지 않는데 그 사실을 알려주는 +곳이 없었습니다. 마이페이지 상단에서 빠진 항목을 이름으로 알려주고 수정 화면으로 보냅니다. + +### 안 읽은 알림은 종 아이콘에 표시합니다 + +`IconButton` 에 `showBadge` 가 있고 조회 훅도 있었지만 실제 화면에서 아무도 넘기지 않아 +(컴포넌트 갤러리에서만 썼습니다) 알림이 와도 알림함에 들어가 보기 전까지 알 수 없었습니다. +`useHasUnreadNotifications()` 를 상단바가 있는 다섯 화면에서 씁니다 — 쿼리 키가 같아 요청은 +한 번만 나갑니다. + +### 작성자 판별은 어느 식별자인지 확인하고 씁니다 + +서버는 사용자를 두 가지로 가리키고, 응답마다 담는 쪽이 다릅니다. + +| 값 | 예시 | 담기는 곳 | +| -------- | ------------------------ | ------------------------------------------------------ | +| `userId` | `user_1787417490710_394` | 토큰·세션, **시설 리뷰**의 `userId` | +| `id` | `2` | **게시글·댓글**의 `authorId`, **병원 리뷰**의 `userId` | + +한쪽만 보고 비교하면 항상 거짓이 되어 본인 글에도 수정·삭제가 뜨지 않습니다 — 실제로 게시글 +상세가 그 상태였습니다(`getUserId() === post.authorId`). `useCurrentUser()` 가 둘을 함께 +돌려주므로, 비교할 필드가 어느 쪽인지 확인하고 골라 씁니다. + +### 아이 수정은 전체 교체입니다 + +`PUT /children/{id}` 는 보내지 않은 필드를 `null` 로 만듭니다(`ChildService.updateChild`). +그래서 수정 화면은 **현재 값을 모두 읽어와 채운 뒤** 저장해야 합니다. + +`specialNeeds`(알레르기·기저질환)는 등록 요청은 받으면서 응답에는 없어서 읽어올 방법이 +없었고, 이름만 고쳐도 특이사항이 지워졌습니다. 백엔드 `ChildInfoResponse`·`ChildMapper` 에 +필드를 추가해 왕복이 되도록 고쳤습니다. + +### 토큰 응답의 신원은 항상 `user` 안에 있습니다 + +서버 `TokenDto` 에는 최상위 `userId`/`email`/`role` 필드가 있지만 `AuthServiceImpl.issueTokenForUser` +는 이 셋을 채우지 않습니다. 로그인·갱신·카카오 로그인 **모두** 신원을 중첩된 `user` 로만 내려줍니다. + +이 값을 최상위에서 필수로 읽고 있어서 두 가지가 조용히 망가져 있었습니다. + +- **일반 로그인**: 200 과 토큰을 받고도 zod 파싱에서 실패해 한 번도 성공한 적이 없었습니다. +- **세션 복구**: `POST /auth/refresh` 도 같은 모양이라 `SessionBootstrap` 이 파싱 실패를 세션 만료로 + 보고 `clearTokens()` 를 불렀습니다. 결과적으로 **새로고침할 때마다 로그아웃**됐고, 인터셉터의 + 401 → 갱신 → 재시도도 마지막 단계에서 항상 무너졌습니다. + +둘 다 화면에는 아무 표시가 나지 않는 종류라 계약 테스트로 고정했습니다 +(`postLoginResponseSchema` / `postRefreshTokenResponseSchema`). + +역할 값도 한 곳에서만 정의합니다. 로그인 응답 스키마가 `['PARENT', 'CHILD']` 로 좁혀져 있어 +서버에 없는 `CHILD` 를 기다리는 대신 실제 값인 `CAREGIVER`·`ADMIN`·`GUEST` 를 거부했습니다. +지금은 `types/apis/user.ts` 의 `USER_ROLE` 이 정본이고 나머지는 이를 참조합니다. + +### 첫 렌더는 서버와 클라이언트가 같아야 합니다 + +`SessionBootstrap` 이 `useState(() => hasStoredSession() && ...)` 로 시작하면, 서버에서는 +localStorage 를 읽을 수 없어 `false`(children 렌더), 클라이언트에서는 `true`(대기 화면 렌더)가 되어 +**모든 페이지에서 hydration 이 깨집니다.** 저장소를 읽는 판단은 effect 안에서만 하고, 첫 렌더는 +양쪽 모두 대기 화면으로 시작합니다. + +### 개발용 빠른 로그인 + +카카오 로그인은 실제 앱 키와 등록된 리다이렉트 URI 가 있어야 해서 로컬에서는 쓸 수 없습니다. +그러면 로그인 뒤 화면(아이 관리·건강 기록·마이페이지)을 전혀 확인할 수 없으므로, 일반 로그인을 +쓰는 개발 전용 버튼을 로그인 화면에 둡니다. + +```bash +# 1) 백엔드에 개발 계정을 만든다 (role 을 ADMIN 으로 주면 /admin 까지 확인할 수 있다) +curl -X POST http://localhost:8082/auth/register -H 'Content-Type: application/json' -d '{"email":"dev@carecode.local","password":"devpassword123!","name":"dev","role":"PARENT"}' + +# 2) .env.local 에 계정을 넣는다 +NEXT_PUBLIC_DEV_LOGIN_EMAIL=dev@carecode.local +NEXT_PUBLIC_DEV_LOGIN_PASSWORD=devpassword123! +``` + +버튼은 두 겹으로 막혀 있습니다 — `NODE_ENV` 는 빌드 시 상수로 치환되므로 분기 전체가 죽은 코드가 +되어 제거되고, 계정 정보는 환경변수로만 들어옵니다. 값이 없으면 버튼이 렌더되지 않습니다. +환경변수를 채운 채로 프로덕션 빌드를 돌려도 번들에 이메일·비밀번호·컴포넌트 이름이 남지 않는 것을 +확인했습니다. + ### 동의 기반 접근 차단 건강·의료 정보는 개인정보보호법상 민감정보라 서버가 `HEALTH_DATA` 동의 없이는 저장을 막고 @@ -110,28 +217,32 @@ src/ - 대기 관리: 대기 신청 기록, 입소·포기 결과 남기기 (이 기록이 다른 부모의 통계가 됩니다) - 자녀 통합 현황: 다자녀 가구를 위한 접종·대기·다자녀 혜택 한눈에 보기 - 약관·처리방침 원문 열람 -- 마이페이지: 프로필, 나의 활동, 내 예약, 개인정보 동의·데이터 내보내기·탈퇴 +- 마이페이지: 프로필 수정, 나의 활동(좋아요·북마크한 글·**북마크한 지원금**), 내 예약, + 차단한 사용자, 개인정보 동의·**동의 이력**·데이터 내보내기·탈퇴, 프로필 완성도 안내 +- 커뮤니티 정리: 본인 글·댓글 수정·삭제, 신고, 사용자 차단(마이페이지에서 해제) +- 리뷰: 시설·병원 리뷰 작성과 본인 리뷰 수정·삭제 +- 챗봇: 지난 상담 내역(세션별 문답 다시 보기) **관리자 (`/admin`)** `/admin` 은 요약 대시보드 + 섹션 인덱스입니다. 앱 셸이 모바일 폭(`max-w-sm`)이라 탭을 늘리면 넘치므로 탭 바 대신 인덱스에서 각 섹션으로 들어가는 구조로 두었습니다. -| 경로 | 내용 | -| --- | --- | -| `/admin` | 건수 요약, 신규 가입 추이, 최근 활동, 섹션 목록 | -| `/admin/reports` | 신고 숨김/반려 | -| `/admin/bookings` | 예약 확정·반려, 대기/확정/오늘 현황 | -| `/admin/users` | 역할 변경, 계정 정지/해제 | -| `/admin/community` | 게시글 직접 삭제 | -| `/admin/policies/manage` | 정책 등록·수정·삭제 | -| `/admin/policies` | 지역별 금액 검증률 | -| `/admin/hospitals` | 병원 목록·삭제 | -| `/admin/public-data` | 시설·유치원·정책·병원 동기화, 좌표 보정 | -| `/admin/analytics` | 온보딩 퍼널(이탈 구간 강조), 코호트 리텐션, 이벤트 건수 | -| `/admin/notifications` | 알림 발송·삭제 | -| `/admin/health-records` | 건강기록 목록·삭제 (민감정보) | -| `/admin/sample-data` | 샘플 데이터 적재·제거 (**개발 환경 전용**) | +| 경로 | 내용 | +| ------------------------ | ------------------------------------------------------- | +| `/admin` | 건수 요약, 신규 가입 추이, 최근 활동, 섹션 목록 | +| `/admin/reports` | 신고 숨김/반려 | +| `/admin/bookings` | 예약 확정·반려, 대기/확정/오늘 현황 | +| `/admin/users` | 역할 변경, 계정 정지/해제 | +| `/admin/community` | 게시글 직접 삭제 | +| `/admin/policies/manage` | 정책 등록·수정·삭제 | +| `/admin/policies` | 지역별 금액 검증률 | +| `/admin/hospitals` | 병원 목록·삭제 | +| `/admin/public-data` | 시설·유치원·정책·병원 동기화, 좌표 보정 | +| `/admin/analytics` | 온보딩 퍼널(이탈 구간 강조), 코호트 리텐션, 이벤트 건수 | +| `/admin/notifications` | 알림 발송·삭제 | +| `/admin/health-records` | 건강기록 목록·삭제 (민감정보) | +| `/admin/sample-data` | 샘플 데이터 적재·제거 (**개발 환경 전용**) | > 별도 어드민 앱을 만들지 않고 같은 앱에 역할 기반 라우트로 두었습니다. > `` 는 권한 없는 사용자가 빈 화면과 403 을 보지 않게 하는 **안내**일 뿐이고, @@ -157,11 +268,11 @@ src/ 서버는 값의 null 여부가 아니라 **요청 JSON 에 그 키가 있었는지**로 판단합니다. -| 요청 | 결과 | -| --- | --- | -| 키 없음 | 기존 값 유지 | -| 키 있음 + 값 있음 | 그 값으로 변경 | -| 키 있음 + `null` | 해당 항목을 비움 | +| 요청 | 결과 | +| ----------------- | ---------------- | +| 키 없음 | 기존 값 유지 | +| 키 있음 + 값 있음 | 그 값으로 변경 | +| 키 있음 + `null` | 해당 항목을 비움 | null 만으로 판단하면 "비우기" 와 "건드리지 않기" 를 구분할 수 없어 둘 중 하나는 불가능해집니다. 그래서 프런트도 이 구분을 지켜야 합니다 — `toPolicyPatchBody()` 는 폼이 다루는 항목만 키로 넣고, @@ -286,11 +397,19 @@ import 하면 SDK 가 모든 페이지 첫 로딩에 실립니다. 푸시를 설 ```bash npm run dev # 개발 서버 (turbopack) npm run build # 프로덕션 빌드 -npm run lint # ESLint + Prettier +npm run lint # ESLint + Prettier (설정 파일 포함 전체) +npm run lint:fix # 자동 수정 npm run typecheck # tsc --noEmit -npm test # Vitest (스키마 계약 테스트) +npm test # Vitest ``` +`next lint` 는 Next 15.3 에서 deprecated 되어 16 에서 제거되므로 `eslint .` 를 직접 씁니다. +`src/` 만 보던 예전과 달리 `next.config.ts` 같은 설정 파일도 검사 대상입니다. + +이 네 가지는 PR 마다 CI(`.github/workflows/ci.yml`)에서 함께 돌아갑니다 — 여기에 빌드까지 +더해 다섯 단계입니다. 계약 테스트가 통과해도 서버 컴포넌트 경계 문제로 빌드가 깨질 수 있어 +빌드를 따로 둡니다. + ### 개발 전용 화면 `*.dev.tsx` 확장자를 쓴 페이지는 개발 서버에서만 라우트로 잡히고 프로덕션 번들에서 제외됩니다 @@ -298,7 +417,7 @@ npm test # Vitest (스키마 계약 테스트) ### 테스트 -Vitest + Testing Library (jsdom) 로 두 층을 덮습니다. +Vitest + Testing Library (jsdom) 로 세 층을 덮습니다. **계약 테스트** — `src/types/apis/__tests__/contracts.test.ts` 백엔드 응답 DTO 를 그대로 옮긴 픽스처로 zod 스키마를 검증합니다. 서버가 필드를 바꾸거나 @@ -309,6 +428,11 @@ Vitest + Testing Library (jsdom) 로 두 층을 덮습니다. 분기가 있는 컴포넌트만 다룹니다. 스타일이 아니라 **사용자가 실제로 보고 누르는 것** (접근 가능한 이름, `aria-pressed`, 비활성 상태)을 기준으로 검증합니다. +**인터셉터 테스트** — `src/apis/__tests__/interceptor.test.ts` +401 → 갱신 → 재시도 경로는 틀려도 화면에 아무 표시가 나지 않고 사용자만 이유 없이 +로그아웃됩니다. 갱신이 요청 수만큼 나가지 않는지(single-flight), 재시도가 한 번으로 +끝나는지, 갱신 API 자체의 401 을 갱신 대상으로 오해하지 않는지를 고정합니다. + 설정 메모: - `@vitejs/plugin-react` 는 이 프로젝트의 `@babel/core` 와 peer 충돌이 있어 쓰지 않습니다. @@ -319,4 +443,7 @@ Vitest + Testing Library (jsdom) 로 두 층을 덮습니다. ## 커밋 컨벤션 -`commitlint.config.cjs` 를 따릅니다. husky pre-commit 훅에서 린트가 실행됩니다. +`commitlint.config.cjs` 를 따릅니다. + +- `.husky/commit-msg` — 커밋 메시지 형식 검사 +- `.husky/pre-commit` — `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..267b78c 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", @@ -57,6 +60,9 @@ "typescript": "5", "vite-tsconfig-paths": "^6.1.1", "vitest": "^3.2.7" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" } }, "node_modules/@adobe/css-tools": { @@ -4684,68 +4690,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 +4744,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 +4797,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", @@ -5122,63 +4938,6 @@ } } }, - "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", @@ -5257,21 +5016,21 @@ } } }, - "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==", + "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-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" + "@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": "*", @@ -5288,25 +5047,13 @@ } } }, - "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==", + "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/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" + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -5323,162 +5070,10 @@ } } }, - "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" - }, - "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-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-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/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -5520,60 +5115,6 @@ } } }, - "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==", - "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@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-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==", - "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-direction": "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", - "@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-roving-focus": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", @@ -5604,132 +5145,6 @@ } } }, - "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==", - "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": { - "@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-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==", - "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" - }, - "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-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==", - "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-slider": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", - "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==", - "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-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" - }, - "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-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -5862,23 +5277,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 +5302,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 +5346,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 +5483,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 +5531,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 +5953,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 +7483,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 +7812,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 +10462,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 +12456,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 +12493,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 +14143,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..54bca0f 100644 --- a/package.json +++ b/package.json @@ -2,29 +2,37 @@ "name": "carecode-fe", "version": "0.1.0", "private": true, + "engines": { + "node": "^22.22.2 || >=24.15.0" + }, "scripts": { "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 +44,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 +55,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", 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/apis/__tests__/interceptor.test.ts b/src/apis/__tests__/interceptor.test.ts new file mode 100644 index 0000000..728fd72 --- /dev/null +++ b/src/apis/__tests__/interceptor.test.ts @@ -0,0 +1,161 @@ +import MockAdapter from 'axios-mock-adapter' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { clearTokens, getAccessToken, setTokens } from '@/apis/auth' +import { CareCode, runRefresh } from '@/apis/interceptor' + +/** + * 401 → 갱신 → 재시도 경로. + * + * 이 앱에서 가장 조용히 틀리기 쉬운 곳이다. 갱신이 요청 수만큼 나가거나, 재시도가 무한히 + * 돌거나, 갱신 실패를 붙잡지 못하면 사용자는 이유 없이 로그아웃된다. + */ +/** + * 서버가 실제로 돌려주는 갱신 응답 모양. + * 최상위 userId/email/role 은 채워지지 않고 신원은 user 안에 있다(TokenDto). + */ +const refreshResponse = (accessToken: string) => ({ + accessToken, + refreshToken: 'refresh-token', + tokenType: 'Bearer', + expiresIn: 60_000, + refreshExpiresIn: 2_592_000_000, + userId: null, + email: null, + role: null, + success: true, + message: '토큰 갱신 성공!', + user: { id: 1, userId: 'user-1', email: 'dev@carecode.local', name: '개발계정', role: 'PARENT' }, +}) + +describe('CareCode 인터셉터', () => { + let mock: MockAdapter + + beforeEach(() => { + mock = new MockAdapter(CareCode) + clearTokens() + // 세션이 있었다는 표시가 없으면 갱신을 시도조차 하지 않는다. + localStorage.setItem('hasSession', '1') + // 리다이렉트가 실제로 페이지를 옮기지 않도록 막는다. + vi.spyOn(window, 'location', 'get').mockReturnValue({ + ...window.location, + pathname: '/home', + href: '', + } as unknown as Location) + }) + + afterEach(() => { + mock.restore() + clearTokens() + localStorage.clear() + vi.restoreAllMocks() + }) + + it('메모리에 토큰이 있으면 Authorization 을 붙인다', async () => { + setTokens('token-1', 'user-1', 60_000) + + mock.onGet('/users/me').reply((config) => { + expect(config.headers?.Authorization).toBe('Bearer token-1') + return [200, { ok: true }] + }) + + await CareCode.get('/users/me') + }) + + it('401 을 받으면 갱신한 토큰으로 원 요청을 한 번 재시도한다', async () => { + setTokens('stale', 'user-1', 60_000) + + let attempt = 0 + mock.onGet('/children').reply((config) => { + attempt += 1 + if (attempt === 1) return [401, { message: 'expired' }] + expect(config.headers?.Authorization).toBe('Bearer fresh') + return [200, [{ childId: 1 }]] + }) + mock.onPost('/auth/refresh').reply(200, refreshResponse('fresh')) + + const res = await CareCode.get('/children') + + expect(attempt).toBe(2) + expect(res.data).toEqual([{ childId: 1 }]) + expect(getAccessToken()).toBe('fresh') + }) + + it('동시에 401 을 받아도 갱신은 한 번만 나간다 (single-flight)', async () => { + setTokens('stale', 'user-1', 60_000) + + let refreshCalls = 0 + const attempts: Record = { '/children': 0, '/health/records': 0 } + + for (const url of Object.keys(attempts)) { + mock.onGet(url).reply(() => { + attempts[url] += 1 + return attempts[url] === 1 ? [401, {}] : [200, { url }] + }) + } + mock.onPost('/auth/refresh').reply(() => { + refreshCalls += 1 + return [200, refreshResponse('fresh')] + }) + + await Promise.all([CareCode.get('/children'), CareCode.get('/health/records')]) + + expect(refreshCalls).toBe(1) + }) + + it('재시도한 요청이 또 401 이면 다시 갱신하지 않는다 (무한 루프 방지)', async () => { + setTokens('stale', 'user-1', 60_000) + + let refreshCalls = 0 + mock.onGet('/children').reply(401, {}) + mock.onPost('/auth/refresh').reply(() => { + refreshCalls += 1 + return [200, refreshResponse('fresh')] + }) + + await expect(CareCode.get('/children')).rejects.toMatchObject({ + response: { status: 401 }, + }) + expect(refreshCalls).toBe(1) + }) + + it('갱신 자체가 401 이면 갱신을 시도하지 않고 세션을 비운다', async () => { + setTokens('stale', 'user-1', 60_000) + mock.onPost('/auth/refresh').reply(401, {}) + + await expect(CareCode.post('/auth/refresh')).rejects.toMatchObject({ + response: { status: 401 }, + }) + expect(getAccessToken()).toBeNull() + expect(localStorage.getItem('hasSession')).toBeNull() + }) + + it('401 이 아닌 오류는 갱신 없이 그대로 던진다', async () => { + setTokens('token-1', 'user-1', 60_000) + + let refreshCalls = 0 + mock.onGet('/children').reply(500, { message: 'boom' }) + mock.onPost('/auth/refresh').reply(() => { + refreshCalls += 1 + return [200, {}] + }) + + await expect(CareCode.get('/children')).rejects.toMatchObject({ + response: { status: 500 }, + }) + expect(refreshCalls).toBe(0) + expect(getAccessToken()).toBe('token-1') + }) + + it('로그인 이력이 없으면 서버를 왕복하지 않고 갱신을 포기한다', async () => { + localStorage.removeItem('hasSession') + + let refreshCalls = 0 + mock.onPost('/auth/refresh').reply(() => { + refreshCalls += 1 + return [200, {}] + }) + + await expect(runRefresh()).rejects.toThrow('No stored session') + expect(refreshCalls).toBe(0) + }) +}) 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/apis/community.ts b/src/apis/community.ts index 3e63596..63b6bf2 100644 --- a/src/apis/community.ts +++ b/src/apis/community.ts @@ -1,6 +1,7 @@ -import { z } from 'zod' import { CareCode } from './interceptor' import { + CommunityTag, + communityTagListSchema, PostListItem, postListItemSchema, ToggleBookmarkResponse, @@ -27,6 +28,14 @@ import { postCommunityCommentPathSchema, PostCommunityCommentResponse, postCommunityCommentResponseSchema, + PutCommunityCommentBody, + putCommunityCommentBodySchema, + PutCommunityCommentPath, + putCommunityCommentPathSchema, + PutCommunityCommentResponse, + putCommunityCommentResponseSchema, + DeleteCommunityCommentPath, + deleteCommunityCommentPathSchema, PostCommunityPostBody, postCommunityPostBodySchema, PostCommunityPostResponse, @@ -140,7 +149,24 @@ export const getBookmarkedPosts = async (): Promise => { } // GET /community/tags - 인기 태그 -export const getCommunityTags = async (): Promise => { +export const getCommunityTags = async (): Promise => { const res = await CareCode.get('/community/tags') - return z.array(z.string()).parse(res.data) + return communityTagListSchema.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/health.ts b/src/apis/health.ts index f0e0d95..2a60e28 100644 --- a/src/apis/health.ts +++ b/src/apis/health.ts @@ -6,6 +6,8 @@ import { CreateHealthRecordBody, createHealthRecordBodySchema, HealthAlert, + HealthRecommendation, + healthRecommendationSchema, healthAlertListSchema, HealthRecord, healthRecordListSchema, @@ -96,6 +98,20 @@ export const uploadAttachment = async ( return attachmentSchema.parse(res.data) } +/** + * GET /health/records/{recordId}/attachments/{attachmentId}/download + * + * 첨부 저장소는 정적으로 공개되지 않는다(민감정보). `` 나 `` 로는 + * 인증 헤더를 붙일 수 없어 401 이 나므로, 인증된 axios 로 본문을 받아 blob 으로 다룬다. + */ +export const downloadAttachment = async (recordId: number, attachmentId: number): Promise => { + const res = await CareCode.get( + `/health/records/${recordId}/attachments/${attachmentId}/download`, + { responseType: 'blob' }, + ) + return res.data as Blob +} + // DELETE /health/records/attachments/{attachmentId} export const deleteAttachment = async (attachmentId: number): Promise => { await CareCode.delete(`/health/records/attachments/${attachmentId}`) @@ -114,3 +130,9 @@ export const getHealthStatistics = async (userId: string): Promise const res = await CareCode.get('/health/statistics', { params: { userId } }) return healthStatsSchema.parse(res.data) } + +// GET /health/recommendations - 아이 월령 기준 추천 +export const getHealthRecommendations = async (): Promise => { + const res = await CareCode.get('/health/recommendations') + return healthRecommendationSchema.parse(res.data) +} diff --git a/src/apis/hospital.ts b/src/apis/hospital.ts index 475d955..56a0f03 100644 --- a/src/apis/hospital.ts +++ b/src/apis/hospital.ts @@ -42,6 +42,12 @@ export const getHospitalsByType = async (type: string): Promise => { return hospitalListSchema.parse(res.data) } +// GET /health/hospitals/likes - 내가 찜한 병원 +export const getLikedHospitals = async (): Promise => { + const res = await CareCode.get('/health/hospitals/likes') + return hospitalListSchema.parse(res.data) +} + // GET /health/hospitals/popular export const getPopularHospitals = async (limit = 10): Promise => { const res = await CareCode.get('/health/hospitals/popular', { params: { limit } }) 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/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..bfed9bf 100644 --- a/src/apis/policy.ts +++ b/src/apis/policy.ts @@ -1,15 +1,13 @@ import { CareCode } from '@/apis/interceptor' import { - GetPolicyListQuery, - getPolicyListQuerySchema, - getPolicyListResponseSchema, GetPolicyByIdPath, getPolicyByIdPathSchema, getPolicyByIdResponseSchema, GetPolicyByIdResponse, - GetPolicyListResponse, getLatestPoliciesResponseSchema, GetLatestPoliciesResponse, + PolicyCategoryList, + policyCategoryListSchema, PolicySearchRequestDto, policySearchRequestSchema, PolicySearchResponseDto, @@ -31,12 +29,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}`) @@ -48,6 +40,26 @@ export const getLatestPolicies = async (): Promise => return getLatestPoliciesResponseSchema.parse(res.data) } +// GET /policies/categories - 카테고리 이름 목록 +export const getPolicyCategories = async (): Promise => { + const res = await CareCode.get('/policies/categories') + return policyCategoryListSchema.parse(res.data) +} + +// GET /policies/category/{category} - 그 카테고리의 정책 목록 +export const getPoliciesByCategory = async ( + category: string, +): Promise => { + const res = await CareCode.get(`/policies/category/${encodeURIComponent(category)}`) + return getLatestPoliciesResponseSchema.parse(res.data) +} + +// GET /policies/popular - 조회수 기준 인기 정책 +export const getPopularPolicies = async (): Promise => { + const res = await CareCode.get('/policies/popular') + return getLatestPoliciesResponseSchema.parse(res.data) +} + export const searchPolicies = async ( request: PolicySearchRequestDto, ): Promise => { diff --git a/src/apis/user.ts b/src/apis/user.ts index 7c15cd2..b551991 100644 --- a/src/apis/user.ts +++ b/src/apis/user.ts @@ -4,8 +4,8 @@ import { getProfileCompletionResponseSchema, GetUserInfoResponse, getUserInfoResponseSchema, - PatchNicknameBody, - patchNicknameBodySchema, + ProfileImageResponse, + profileImageResponseSchema, PutUserInfoBody, putUserInfoBodySchema, PutUserInfoResponse, @@ -25,11 +25,20 @@ 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) +/** + * POST /users/me/profile-image - 프로필 이미지 업로드. + * + * 예전에는 URL 문자열만 받는 PUT 뿐이라 파일을 올릴 곳이 없었다. + * 서버가 저장 후 주소를 돌려주므로 그 값을 그대로 화면에 반영한다. + */ +export const uploadProfileImage = async (file: File): Promise => { + const formData = new FormData() + formData.append('file', file) + + const res = await CareCode.post('/users/me/profile-image', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return profileImageResponseSchema.parse(res.data) } // GET /users/profile/completion - 프로필 완성도 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/(with-tabs)/community/hooks/usePosts.ts b/src/app/(with-tabs)/community/hooks/usePosts.ts index 8d2f9ff..9dc604e 100644 --- a/src/app/(with-tabs)/community/hooks/usePosts.ts +++ b/src/app/(with-tabs)/community/hooks/usePosts.ts @@ -1,27 +1,21 @@ import { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query' -import { useMemo } from 'react' +import { RefObject, useMemo } from 'react' +import useInfiniteScroll from '@/hooks/useInfiniteScroll' import { useGetCommunityPosts } from '@/queries/community' import { GetCommunityPostsQuery, GetCommunityPostsResponse } from '@/types/apis/community' export type UsePostsReturn = { posts: GetCommunityPostsResponse['content'] + /** 이 요소가 화면에 들어오면 다음 페이지를 불러온다. */ + loadMoreRef: RefObject } & Omit, Error>, 'data'> export function usePosts(query: GetCommunityPostsQuery): UsePostsReturn { const queryResult = useGetCommunityPosts(query) + const { loadMoreRef } = useInfiniteScroll(queryResult) - const posts = useMemo( - () => queryResult.data?.pages.flatMap((page) => page.content) ?? [], - [queryResult.data], - ) + const { data, ...rest } = queryResult + const posts = useMemo(() => data?.pages.flatMap((page) => page.content) ?? [], [data]) - const { fetchNextPage, hasNextPage, isFetchingNextPage, ...rest } = queryResult - - return { - posts, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - ...rest, - } + return { posts, loadMoreRef, ...rest } } diff --git a/src/app/(with-tabs)/community/page.tsx b/src/app/(with-tabs)/community/page.tsx index b598f19..6772503 100644 --- a/src/app/(with-tabs)/community/page.tsx +++ b/src/app/(with-tabs)/community/page.tsx @@ -1,139 +1,183 @@ 'use client' -import clsx from 'clsx' -import { motion } from 'framer-motion' import { useRouter } from 'next/navigation' -import { JSX, useEffect, useRef, useState } from 'react' +import { JSX, useState } from 'react' import { usePosts } from './hooks/usePosts' import BabyIcon from '@/assets/icons/baby.svg' import BellIcon from '@/assets/icons/bell.svg' import SearchIcon from '@/assets/icons/search.svg' import Chip from '@/components/common/Chip' +import EmptyState from '@/components/common/EmptyState' +import ErrorView from '@/components/common/Error' 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 NewPostFAB from '@/components/features/community/NewPostFAB' import CommunityPost from '@/components/features/community/community-post-list' +import { useRecentSearches } from '@/hooks/useRecentSearches' +import { useCommunityTags } from '@/queries/community' +import { useHasUnreadNotifications } from '@/queries/notification' const Community = (): JSX.Element => { - const { posts, fetchNextPage, hasNextPage, isFetchingNextPage } = usePosts({ page: 0, size: 10 }) - const searchInput = useInput('') - const loader = useRef(null) const router = useRouter() + const hasUnread = useHasUnreadNotifications() + const searchInput = useInput('') + const [isSearching, setIsSearching] = useState(false) - 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가 조금이라도 보이면 실행 - }, - ) - - observer.observe(currentLoader) + // 최근 검색어는 /search 화면과 같은 저장소를 쓴다. 화면마다 따로 들고 있으면 서로 어긋난다. + const { recentSearches, addSearch, removeSearch, clearAllSearches } = useRecentSearches() - return () => { - observer.unobserve(currentLoader) - } - }, [fetchNextPage, isFetchingNextPage]) + const { posts, loadMoreRef, hasNextPage, isLoading, isError, refetch } = usePosts({ size: 10 }) + const { data: tags = [] } = useCommunityTags() - const [searchFocused, setSearchFocused] = useState(false) - const [recentKeywords, setRecentKeywords] = useState(['아동', '육아', '임신', '출산']) + const goToSearch = (keyword: string) => { + const trimmed = keyword.trim() + if (!trimmed) return - const handleDeleteKeyword = (keyword: string) => { - setRecentKeywords((prev) => prev.filter((k) => k !== keyword)) //temp - } - const handleRecentKeywordClick = (keyword: string) => { - router.push(`/community/search?keyword=${encodeURIComponent(keyword)}&page=0&size=10`) + addSearch(trimmed) + router.push(`/community/search?keyword=${encodeURIComponent(trimmed)}`) } + return (
- + {/* 검색 중에는 목록 대신 최근 검색어를 보여주므로 상단바를 접는다. */} + {!isSearching && ( router.push('/notification'), + }, + ]} + isSticky /> - + )} -
+
{ + event.preventDefault() + goToSearch(searchInput.value) + }} + > setSearchFocused(true)} + onFocus={() => setIsSearching(true)} placeholder="검색어를 입력하세요" + aria-label="게시글 검색" rightIcon={ goToSearch(searchInput.value)} /> } /> -
+ {/* 검색 패널로 들어오면 목록이 가려진다. 되돌아갈 길을 남긴다. */} + {isSearching && ( + + )} + - {searchFocused ? ( -
-
-
-
- 최근 검색어 -
+ {/* + 태그로 게시글을 거르는 API 가 없어 태그 이름으로 검색을 태운다. + 없는 필터를 흉내 내는 것보다 실제로 동작하는 경로를 쓴다. + */} + {!isSearching && tags.length > 0 && ( +
+ {tags.map((tag) => ( + goToSearch(tag.name)} + > + {tag.name} + + ))} +
+ )} + + {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 +186,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)/error.tsx b/src/app/(with-tabs)/error.tsx new file mode 100644 index 0000000..b3161f9 --- /dev/null +++ b/src/app/(with-tabs)/error.tsx @@ -0,0 +1,30 @@ +'use client' +import { ReactElement, useEffect } from 'react' +import ErrorView from '@/components/common/Error' + +/** + * 이 그룹 안에서 렌더 중 터진 예외를 여기서 받는다. + * + * 루트 error.tsx 만 있으면 화면 하나가 깨져도 레이아웃까지 통째로 날아가 + * 사용자가 다른 탭으로 옮겨갈 수단조차 사라진다. 그룹 경계에서 잡아 + * 탭 바와 상단바는 살려 둔다. + */ +const GroupError = ({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement => { + useEffect(() => { + console.error('화면 렌더 중 오류:', error) + }, [error]) + + return ( +
+ +
+ ) +} + +export default GroupError diff --git a/src/app/(with-tabs)/home/page.tsx b/src/app/(with-tabs)/home/page.tsx index 4142720..53a4a95 100644 --- a/src/app/(with-tabs)/home/page.tsx +++ b/src/app/(with-tabs)/home/page.tsx @@ -17,11 +17,13 @@ import QuickMenu from '@/components/features/home/QuickMenu' import PolicyCard from '@/components/features/policy/PolicyCard' import RecommendedPolicyCard from '@/components/features/policy/RecommendedPolicyCard' import { useGetCommunityPopular } from '@/queries/community' +import { useHasUnreadNotifications } from '@/queries/notification' import { useGetLatestPolicies, usePolicyRecommendations } from '@/queries/policy' import { convertPolicyToCardProps } from '@/types/policy' const Home = (): ReactElement => { const router = useRouter() + const hasUnread = useHasUnreadNotifications() const handleNotificationClick = () => router.push('/notification') const handleSearchClick = () => router.push('/search') const { data: policies, isLoading, error } = useGetLatestPolicies() @@ -36,7 +38,14 @@ const Home = (): ReactElement => {
diff --git a/src/app/(with-tabs)/loading.tsx b/src/app/(with-tabs)/loading.tsx new file mode 100644 index 0000000..6742158 --- /dev/null +++ b/src/app/(with-tabs)/loading.tsx @@ -0,0 +1,6 @@ +import { ReactElement } from 'react' +import RouteSkeleton from '@/components/common/RouteSkeleton' + +const Loading = (): ReactElement => + +export default Loading diff --git a/src/app/(with-tabs)/mypage/page.tsx b/src/app/(with-tabs)/mypage/page.tsx index 3bb832e..7b87ce6 100644 --- a/src/app/(with-tabs)/mypage/page.tsx +++ b/src/app/(with-tabs)/mypage/page.tsx @@ -12,10 +12,59 @@ import Layout from '@/components/common/Layout' import IconButton from '@/components/common/top-navbar/IconButton' import MenuList from '@/components/features/mypage/MenuList' import { useIsAdmin } from '@/hooks/useIsAdmin' -import { useLogout, useUserProfile } from '@/queries/user' +import { useHasUnreadNotifications } from '@/queries/notification' +import { useLogout, useProfileCompletion, useUserProfile } from '@/queries/user' + +/** 서버가 주는 불리언 맵의 키를 사용자가 읽을 수 있는 말로 바꾼다. */ +const MISSING_FIELD_LABEL: Record = { + needsRealName: '이름', + needsPhoneNumber: '전화번호', + needsBirthDate: '생년월일', + needsGender: '성별', + needsAddress: '주소', +} + +/** + * 프로필 완성도 안내. + * + * 주소가 비어 있으면 지역별 지원금 비교와 가까운 시설 추천이 아예 동작하지 않는데, + * 그 사실을 알려주는 곳이 없어서 사용자는 "추천이 원래 비어 있는 화면" 으로 오해한다. + * 다 채운 사람에게는 아무것도 띄우지 않는다. + */ +const ProfileCompletionBanner = (): ReactElement | null => { + const router = useRouter() + const { data } = useProfileCompletion() + + if (!data || data.complete) return null + + // 서버는 `{ needsAddress: true }` 처럼 불리언 맵으로 준다. true 인 것만 빠진 항목이다. + const missing = Object.entries(data.missingFields ?? {}) + .filter(([, needed]) => needed) + .map(([field]) => MISSING_FIELD_LABEL[field] ?? field) + + return ( + + ) +} const MyPage = (): ReactElement => { const router = useRouter() + const hasUnread = useHasUnreadNotifications() const { data: user, isLoading } = useUserProfile() const { mutate: logout, isPending: isLoggingOut } = useLogout() const isAdmin = useIsAdmin() @@ -27,8 +76,17 @@ const MyPage = (): ReactElement => { router.push('/notification') }]} + actionButtons={[ + { + icon: BellIcon, + 'aria-label': '알림', + showBadge: hasUnread, + onClick: () => router.push('/notification'), + }, + ]} > + + {/* 프로필 */}
@@ -54,6 +112,7 @@ const MyPage = (): ReactElement => { router.push('/mypage/edit')} />
@@ -103,6 +162,11 @@ const MyPage = (): ReactElement => { }, { id: 'bookings', title: '내 예약', onClick: () => router.push('/mypage/bookings') }, { id: 'waitlist', title: '내 대기', onClick: () => router.push('/mypage/waitlist') }, + { + id: 'liked-hospitals', + title: '찜한 병원', + onClick: () => router.push('/mypage/liked-hospitals'), + }, ]} /> { title: '알림 설정', onClick: () => router.push('/notification/settings'), }, + { + id: 'blocked', + title: '차단한 사용자', + onClick: () => router.push('/mypage/blocked'), + }, { id: 'privacy', title: '개인정보 설정 및 약관 동의', diff --git a/src/app/(with-tabs)/search/page.tsx b/src/app/(with-tabs)/search/page.tsx index 9deaa97..583291b 100644 --- a/src/app/(with-tabs)/search/page.tsx +++ b/src/app/(with-tabs)/search/page.tsx @@ -7,12 +7,18 @@ 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' +import { usePolicyCategories, usePopularPolicies } from '@/queries/policy' const Search = (): ReactElement => { const { recentSearches, removeSearch, clearAllSearches } = useRecentSearches() + const { data: categories = [] } = usePolicyCategories() + const { data: popular = [] } = usePopularPolicies() const router = useRouter() + const hasUnread = useHasUnreadNotifications() const { inputValue, handleInputChange, search } = useSearchPolicy() const handleNotificationClick = () => router.push('/notification') const handleSubmit = (e: React.FormEvent) => { @@ -24,7 +30,14 @@ const Search = (): ReactElement => {
@@ -33,15 +46,61 @@ const Search = (): ReactElement => { placeholder="검색어를 입력하세요" onChange={handleInputChange} rightIcon={ - search()} /> } /> + + {/* + 검색은 "무엇을 찾을지 아는 사람" 만 쓸 수 있다. 카테고리를 앞에 둬서 + 무엇이 있는지 모르는 사람도 지원금을 발견할 수 있게 한다. + */} + {categories.length > 0 && ( +
+ 카테고리로 찾기 +
+ {categories.map((name) => ( + router.push(`/policy/category/${encodeURIComponent(name)}`)} + > + {name} + + ))} +
+
+ )} + + {popular.length > 0 && ( +
+ 많이 찾는 지원금 +
    + {popular.slice(0, 5).map((policy) => ( +
  • + +
  • + ))} +
+
+ )} {recentSearches.length > 0 && (
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]/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 { 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 [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/(without-tabs)/error.tsx b/src/app/(without-tabs)/error.tsx new file mode 100644 index 0000000..b3161f9 --- /dev/null +++ b/src/app/(without-tabs)/error.tsx @@ -0,0 +1,30 @@ +'use client' +import { ReactElement, useEffect } from 'react' +import ErrorView from '@/components/common/Error' + +/** + * 이 그룹 안에서 렌더 중 터진 예외를 여기서 받는다. + * + * 루트 error.tsx 만 있으면 화면 하나가 깨져도 레이아웃까지 통째로 날아가 + * 사용자가 다른 탭으로 옮겨갈 수단조차 사라진다. 그룹 경계에서 잡아 + * 탭 바와 상단바는 살려 둔다. + */ +const GroupError = ({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement => { + useEffect(() => { + console.error('화면 렌더 중 오류:', error) + }, [error]) + + return ( +
+ +
+ ) +} + +export default GroupError diff --git a/src/app/(without-tabs)/facility/[id]/page.tsx b/src/app/(without-tabs)/facility/[id]/page.tsx index 158d28d..2c0d646 100644 --- a/src/app/(without-tabs)/facility/[id]/page.tsx +++ b/src/app/(without-tabs)/facility/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useParams, useRouter } from 'next/navigation' import { ReactElement, useEffect, useState } from 'react' -import { getAccessToken } from '@/apis/auth' +import { getAccessToken, getUserId } from '@/apis/auth' import { postFacilityView } from '@/apis/facility' import StarIcon from '@/assets/icons/star_small.svg' import Button from '@/components/common/Button' @@ -14,16 +14,18 @@ import Separator from '@/components/common/Separator' import AdmissionInsight from '@/components/features/facility/AdmissionInsight' import BookingDialog from '@/components/features/facility/BookingDialog' import ReviewForm from '@/components/features/facility/ReviewForm' +import ReviewItem from '@/components/features/facility/ReviewItem' import WaitlistDialog from '@/components/features/facility/WaitlistDialog' import { useCreateBooking, useCreateFacilityReview, + useDeleteFacilityReview, + useUpdateFacilityReview, useFacilityDetail, useFacilityReviews, } from '@/queries/facility' import { useRegisterWaitlist } from '@/queries/waitlist' import { FACILITY_TYPE_LABEL, FacilityType, PostFacilityBookBody } from '@/types/apis/facility' -import { formatDate } from '@/utils/date' const FacilityDetailPage = (): ReactElement => { 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)/facility/page.tsx b/src/app/(without-tabs)/facility/page.tsx index 930a7af..2b8fcc9 100644 --- a/src/app/(without-tabs)/facility/page.tsx +++ b/src/app/(without-tabs)/facility/page.tsx @@ -9,7 +9,12 @@ import Spacer from '@/components/common/Spacer' import ToggleChip from '@/components/common/ToggleChip' import Input from '@/components/common/input' import FacilityListItem from '@/components/features/facility/FacilityListItem' -import { useAdvancedFacilitySearch, useFacilitySearch } from '@/queries/facility' +import PopularSection from '@/components/features/facility/PopularSection' +import { + useAdvancedFacilitySearch, + useFacilitySearch, + usePopularFacilities, +} from '@/queries/facility' import { FACILITY_TYPE_LABEL, FacilityAdvancedSearchBody, @@ -21,6 +26,7 @@ const PAGE_SIZE = 20 const FacilityPage = (): ReactElement => { const router = useRouter() + const { data: popular = [] } = usePopularFacilities() const [inputValue, setInputValue] = useState('') const [keyword, setKeyword] = useState('') const [selectedType, setSelectedType] = useState(null) @@ -93,6 +99,16 @@ const FacilityPage = (): ReactElement => { return ( + ({ + id: item.id, + name: item.name, + subtitle: item.address, + }))} + onSelect={(id) => router.push(`/facility/${id}`)} + /> +
= { HIGH: 0, MEDIUM: 1, LOW: 2 } + +const PRIORITY_STYLE: Record = { + HIGH: 'border-red bg-red/5', + MEDIUM: 'border-yellow bg-yellow/10', + LOW: 'border-gray-300 bg-gray-50', +} + +/** + * 예방접종·검진 임박 알림. + * + * 알림함(푸시)으로도 나가지만 그건 놓치면 끝이다. 기록 화면에 들어온 사람에게는 + * 지금 챙겨야 할 것을 맨 위에서 다시 보여준다. 없으면 아무것도 그리지 않는다. + */ +const HealthAlertSection = (): ReactElement | null => { + const { data: alerts = [], isLoading } = useHealthAlerts() + + if (isLoading || !alerts.length) return null + + const sorted = [...alerts].sort( + (a, b) => + (PRIORITY_ORDER[a.priority ?? 'LOW'] ?? 2) - (PRIORITY_ORDER[b.priority ?? 'LOW'] ?? 2), + ) + + return ( +
+

챙길 일정

+
    + {sorted.map((alert, index) => ( +
  • + {alert.title ?? '알림'} + {alert.message && ( + {alert.message} + )} + {alert.dueDate && ( + + {formatDate(alert.dueDate)} 예정 + + )} +
  • + ))} +
+
+ ) +} + +/** + * 아이 월령 기준 넛지. + * + * 서버는 추천 정책·시설을 **이름 문자열로만** 준다(id 가 없다). 상세로 바로 보낼 수 없으므로 + * 검색 지름길로 쓴다 — 없는 링크를 만들어 404 로 보내는 것보다 낫다. + */ +const HealthRecommendationSection = (): ReactElement | null => { + const router = useRouter() + const { data } = useHealthRecommendations() + + const policies = data?.recommendedPolicies ?? [] + if (!data?.nudgeMessage && !policies.length) return null + + return ( +
+ {data?.nudgeMessage && ( + {data.nudgeMessage} + )} + {policies.length > 0 && ( +
+ {policies.map((name) => ( + + ))} +
+ )} +
+ ) +} const HealthPage = (): ReactElement => { const router = useRouter() @@ -34,6 +125,9 @@ const HealthPage = (): ReactElement => { return ( + + + {children.length > 1 && ( <>
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)/hospital/page.tsx b/src/app/(without-tabs)/hospital/page.tsx index 0b873af..34dec75 100644 --- a/src/app/(without-tabs)/hospital/page.tsx +++ b/src/app/(without-tabs)/hospital/page.tsx @@ -9,14 +9,16 @@ 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 PopularSection from '@/components/features/facility/PopularSection' import { useGeolocation } from '@/hooks/useGeolocation' -import { useHospitals, useNearbyHospitals } from '@/queries/hospital' +import { useHospitals, useNearbyHospitals, usePopularHospitals } from '@/queries/hospital' import { HOSPITAL_GRADES, HospitalGrade } from '@/types/apis/hospital' const RADIUS_KM = 3 const HospitalPage = (): ReactElement => { const router = useRouter() + const { data: popular = [] } = usePopularHospitals() const [keyword, setKeyword] = useState('') const [nearbyMode, setNearbyMode] = useState(false) const [selectedGrade, setSelectedGrade] = useState(null) @@ -57,6 +59,16 @@ const HospitalPage = (): ReactElement => { return ( + ({ + id: item.id, + name: item.name, + subtitle: item.address, + }))} + onSelect={(id) => router.push(`/hospital/${id}`)} + /> + + +export default Loading diff --git a/src/app/(without-tabs)/mypage/activity/page.tsx b/src/app/(without-tabs)/mypage/activity/page.tsx index 43211d4..c77b974 100644 --- a/src/app/(without-tabs)/mypage/activity/page.tsx +++ b/src/app/(without-tabs)/mypage/activity/page.tsx @@ -8,11 +8,17 @@ import EmptyState from '@/components/common/EmptyState' import Layout from '@/components/common/Layout' import PostListItem from '@/components/features/community/PostListItem' import { useBookmarkedPosts, useLikedPosts } from '@/queries/community' +import { usePolicyBookmarks } from '@/queries/policy' import { PostListItem as Post } from '@/types/apis/community' +import { PolicyBookmark } from '@/types/apis/policy' +import { formatDate } from '@/utils/date' const TABS = [ { value: 'liked', label: '좋아요한 글' }, - { value: 'bookmarked', label: '북마크' }, + { value: 'bookmarked', label: '북마크한 글' }, + // 지원금 북마크는 커뮤니티 글과 다른 도메인이지만, 사용자에게는 "내가 저장해 둔 것"으로 + // 한 자리에 있는 편이 찾기 쉽다. + { value: 'policies', label: '지원금' }, ] as const const PostSection = ({ @@ -53,12 +59,68 @@ const PostSection = ({ ) } +const PolicyBookmarkSection = ({ + bookmarks, + isLoading, +}: { + bookmarks: PolicyBookmark[] + isLoading: boolean +}): ReactElement => { + const router = useRouter() + + if (isLoading) { + return ( +
    + {[0, 1, 2].map((i) => ( +
  • + ))} +
+ ) + } + + if (!bookmarks.length) { + return ( + router.push('/search')} + /> + ) + } + + return ( +
    + {bookmarks.map((bookmark) => ( +
  • + +
  • + ))} +
+ ) +} + const ActivityContent = (): ReactElement => { const searchParams = useSearchParams() - const defaultTab = searchParams?.get('tab') === 'bookmarked' ? 'bookmarked' : 'liked' + const requestedTab = searchParams?.get('tab') + const defaultTab = TABS.some((tab) => tab.value === requestedTab) + ? (requestedTab as (typeof TABS)[number]['value']) + : 'liked' const { data: liked = [], isLoading: isLikedLoading } = useLikedPosts() const { data: bookmarked = [], isLoading: isBookmarkedLoading } = useBookmarkedPosts() + const { data: policyBookmarks = [], isLoading: isPolicyLoading } = usePolicyBookmarks() return ( @@ -94,6 +156,10 @@ const ActivityContent = (): ReactElement => { emptyDescription="나중에 다시 볼 글을 북마크해두세요." /> + + + + ) } 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/(without-tabs)/mypage/edit/page.tsx b/src/app/(without-tabs)/mypage/edit/page.tsx new file mode 100644 index 0000000..1c2321a --- /dev/null +++ b/src/app/(without-tabs)/mypage/edit/page.tsx @@ -0,0 +1,250 @@ +'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 EditProfileImage from '@/components/features/mypage/EditProfileImage' +import { useUpdateProfile, useUploadProfileImage, useUserProfile } from '@/queries/user' +import { toAbsoluteFileUrl } from '@/utils/file' + +// 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 { mutate: uploadImage, isPending: isUploadingImage } = useUploadProfileImage() + + 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 ( + <> +
+ uploadImage(file)} + /> + + + + + + + +
+ +

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

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

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

+ )} + +
+ + ) +} + +const ProfileEditPage = (): ReactElement => ( + + + + + +) + +export default ProfileEditPage diff --git a/src/app/(without-tabs)/mypage/liked-hospitals/page.tsx b/src/app/(without-tabs)/mypage/liked-hospitals/page.tsx new file mode 100644 index 0000000..1a92a1b --- /dev/null +++ b/src/app/(without-tabs)/mypage/liked-hospitals/page.tsx @@ -0,0 +1,67 @@ +'use client' +import { useRouter } from 'next/navigation' +import { ReactElement } 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 { useLikedHospitals } from '@/queries/hospital' + +const LikedHospitalsContent = (): ReactElement => { + const router = useRouter() + const { data: hospitals = [], isLoading, isError, refetch } = useLikedHospitals() + + if (isLoading) { + return ( +
    + {[0, 1, 2].map((i) => ( +
  • + ))} +
+ ) + } + + if (isError) { + return refetch()} /> + } + + if (hospitals.length === 0) { + return ( + router.push('/hospital')} + /> + ) + } + + return ( +
    + {hospitals.map((hospital) => ( +
  • + +
  • + ))} +
+ ) +} + +const LikedHospitalsPage = (): ReactElement => ( + + + + + +) + +export default LikedHospitalsPage diff --git a/src/app/(without-tabs)/mypage/privacy/page.tsx b/src/app/(without-tabs)/mypage/privacy/page.tsx index f52ea02..fbbe673 100644 --- a/src/app/(without-tabs)/mypage/privacy/page.tsx +++ b/src/app/(without-tabs)/mypage/privacy/page.tsx @@ -9,15 +9,90 @@ import Layout from '@/components/common/Layout' import Separator from '@/components/common/Separator' import Switch from '@/components/common/Switch' import { useLegalVersion } from '@/queries/legal' -import { useConsents, useDeleteAccount, useExportMyData, useUpdateConsent } from '@/queries/privacy' +import { + useConsentHistory, + useConsents, + useDeleteAccount, + useExportMyData, + useUpdateConsent, +} from '@/queries/privacy' import { ConsentType, SENSITIVE_CONSENT_TYPES } from '@/types/apis/privacy' import { formatDate } from '@/utils/date' import { downloadJson } from '@/utils/file' +/** + * 동의 이력. + * + * 약관은 개정되므로 "지금 동의했는가" 만으로는 무엇에 동의했는지 증명할 수 없다. + * 서버가 동의 시점의 약관 버전을 함께 남기므로 그대로 보여준다. + */ +const ConsentHistorySection = (): ReactElement => { + const { data: history = [], isLoading, isError, refetch } = useConsentHistory() + + if (isLoading) { + return ( +
    + {[0, 1].map((i) => ( +
  • + ))} +
+ ) + } + + if (isError) { + return ( + + ) + } + + if (!history.length) { + return

아직 남은 동의 이력이 없어요.

+ } + + return ( +
    + {history.map((item, index) => ( +
  • +
    + + {item.displayName ?? item.consentType} + + + {item.granted ? '동의' : '철회'} + +
    + + {[ + item.policyVersion && `약관 ${item.policyVersion}`, + formatDate(item.createdAt, 'yyyy.MM.dd HH:mm'), + ] + .filter(Boolean) + .join(' · ')} + +
  • + ))} +
+ ) +} + const PrivacyPage = (): ReactElement => { const router = useRouter() const [withdrawOpen, setWithdrawOpen] = useState(false) + const [isHistoryOpen, setIsHistoryOpen] = useState(false) const { data, isLoading, isError, refetch } = useConsents() // 동의 이력에 남는 값이라 서버가 시행 중인 버전을 쓴다. const { data: policyVersion } = useLegalVersion() @@ -117,6 +192,27 @@ const PrivacyPage = (): ReactElement => { +
+

동의 이력

+

+ 언제 어떤 버전의 약관에 동의했는지 남겨둔 기록이에요. +

+ + + + {/* 열었을 때만 받아온다. 대부분의 방문에서는 필요 없는 조회다. */} + {isHistoryOpen && } +
+ + +

내 데이터

diff --git a/src/app/(without-tabs)/not-found.tsx b/src/app/(without-tabs)/not-found.tsx new file mode 100644 index 0000000..ea42414 --- /dev/null +++ b/src/app/(without-tabs)/not-found.tsx @@ -0,0 +1,32 @@ +'use client' +import { useRouter } from 'next/navigation' +import { ReactElement } from 'react' +import CharacterIcon from '@/assets/icons/characters/error.svg' +import Button from '@/components/common/Button' + +/** + * 상세 화면에서 없는 리소스를 열었을 때(notFound()). + * 루트 404 와 달리 "홈" 이 아니라 바로 앞 목록으로 돌려보내는 편이 자연스럽다. + */ +const NotFound = (): ReactElement => { + const router = useRouter() + + return ( +

+ +

+ {'찾으시는 내용이 없어요.\n삭제되었을 수 있어요.'} +

+
+ + +
+
+ ) +} + +export default NotFound 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/(without-tabs)/policy/[id]/opengraph-image.tsx b/src/app/(without-tabs)/policy/[id]/opengraph-image.tsx new file mode 100644 index 0000000..5ec3bc9 --- /dev/null +++ b/src/app/(without-tabs)/policy/[id]/opengraph-image.tsx @@ -0,0 +1,69 @@ +import { ImageResponse } from 'next/og' + +export const alt = '지원금 상세' +export const size = { width: 1200, height: 630 } +export const contentType = 'image/png' + +/** + * 지원금 상세 공유 카드. + * + * 제목을 카드에 실어야 "무슨 지원금인지" 가 링크만 보고도 전달된다. + * 서버가 응답하지 않으면 제목 없이 기본 문구로 그린다 — 카드가 통째로 깨지는 것보다 낫다. + */ +const fetchTitle = async (policyId: string): Promise => { + const base = process.env.NEXT_PUBLIC_API_URL + if (!base) return null + + try { + const res = await fetch(`${base}/policies/${policyId}`, { signal: AbortSignal.timeout(3000) }) + if (!res.ok) return null + + const data = (await res.json()) as { title?: string } + return data.title ?? null + } catch { + return null + } +} + +export default async function OpengraphImage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const { id } = await params + const title = await fetchTitle(id) + + return new ImageResponse( + ( +
+
+
지원금
+
+ {title ?? '받을 수 있는 지원금을 확인해보세요'} +
+
+
맘편한 · 놓친 지원금까지 찾아드려요
+
+ ), + size, + ) +} 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/(without-tabs)/policy/category/[category]/page.tsx b/src/app/(without-tabs)/policy/category/[category]/page.tsx new file mode 100644 index 0000000..efead8f --- /dev/null +++ b/src/app/(without-tabs)/policy/category/[category]/page.tsx @@ -0,0 +1,70 @@ +'use client' +import { useParams, useRouter } from 'next/navigation' +import { ReactElement } from 'react' +import Chip from '@/components/common/Chip' +import EmptyState from '@/components/common/EmptyState' +import ErrorView from '@/components/common/Error' +import Layout from '@/components/common/Layout' +import PolicyCard from '@/components/features/policy/PolicyCard' +import { usePoliciesByCategory, usePolicyCategories } from '@/queries/policy' +import { convertPolicyToCardProps } from '@/types/policy' + +const CategoryPolicyPage = (): ReactElement => { + const params = useParams<{ category: string }>() + const router = useRouter() + const category = decodeURIComponent(params?.category ?? '') + + const { data: policies = [], isLoading, isError, refetch } = usePoliciesByCategory(category) + const { data: categories = [] } = usePolicyCategories() + + return ( + + {/* 다른 카테고리로 바로 건너뛸 수 있어야 한다. 뒤로 갔다 다시 들어오게 하면 번거롭다. */} + {categories.length > 0 && ( +
+ {categories.map((name) => ( + router.replace(`/policy/category/${encodeURIComponent(name)}`)} + > + {name} + + ))} +
+ )} + + {isLoading ? ( +
    + {[0, 1, 2].map((i) => ( +
  • + ))} +
+ ) : isError ? ( + refetch()} /> + ) : policies.length === 0 ? ( + router.push('/search')} + /> + ) : ( +
+ {`${policies.length}건`} + {policies.map((policy) => ( + router.push(`/policy/${policy.id}`)} + /> + ))} +
+ )} +
+ ) +} + +export default CategoryPolicyPage 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/admin/error.tsx b/src/app/admin/error.tsx new file mode 100644 index 0000000..179aaef --- /dev/null +++ b/src/app/admin/error.tsx @@ -0,0 +1,23 @@ +'use client' +import { ReactElement, useEffect } from 'react' +import ErrorView from '@/components/common/Error' + +const AdminError = ({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement => { + useEffect(() => { + console.error('관리자 화면 오류:', error) + }, [error]) + + return ( +
+ +
+ ) +} + +export default AdminError diff --git a/src/app/admin/loading.tsx b/src/app/admin/loading.tsx new file mode 100644 index 0000000..37bfc16 --- /dev/null +++ b/src/app/admin/loading.tsx @@ -0,0 +1,7 @@ +import { ReactElement } from 'react' +import RouteSkeleton from '@/components/common/RouteSkeleton' + +/** 관리자 레이아웃이 이미 상단바를 그리므로 여기서는 본문만 비운다. */ +const Loading = (): ReactElement => + +export default Loading 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/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/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/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/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..865e382 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,68 @@ +'use client' +import { ReactElement, useEffect } from 'react' + +/** + * 루트 레이아웃 자체가 터졌을 때의 마지막 그물. + * + * 이 경계는 레이아웃을 대신하므로 html·body 를 직접 그려야 한다. + * 같은 이유로 전역 스타일도 아직 적용되지 않으므로 인라인 스타일만 쓴다. + */ +const GlobalError = ({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement => { + useEffect(() => { + console.error('앱을 시작하지 못했습니다:', error) + }, [error]) + + return ( + + +

+ 앱을 여는 중 문제가 생겼어요 +

+

+ 잠시 후 다시 시도해주세요. +
+ 계속 이러면 앱을 완전히 껐다가 다시 열어주세요. +

+ + + + ) +} + +export default GlobalError 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/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/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..fa0ba4d --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -0,0 +1,37 @@ +import { ImageResponse } from 'next/og' + +export const alt = '맘편한 — 부모와 자녀를 위한 육아 정보 플랫폼' +export const size = { width: 1200, height: 630 } +export const contentType = 'image/png' + +/** + * 링크를 공유했을 때 보이는 카드. + * + * 외부 폰트를 받아오지 않는다. 빌드·요청 시점에 네트워크에 기대면 폰트 서버가 느릴 때 + * 이미지 생성이 통째로 실패한다. 한글은 시스템 폰트로 그린다. + */ +export default function OpengraphImage(): ImageResponse { + return new ImageResponse( + ( +
+
맘편한
+
부모와 자녀를 위한 육아 정보 플랫폼
+
+ 지원금 · 어린이집 · 예방접종 · 커뮤니티 +
+
+ ), + size, + ) +} 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/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/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..acb7772 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,29 @@ +import type { MetadataRoute } from 'next' + +/** + * 로그인해야 볼 수 있는 화면과 관리자 화면은 크롤링 대상이 아니다. + * 색인돼 봐야 검색 결과에서 로그인 화면으로 튕기므로 유입에 도움이 되지 않는다. + */ +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: '*', + allow: '/', + disallow: [ + '/admin', + '/auth', + '/mypage', + '/children', + '/health', + '/notification', + '/chat', + '/signup', + '/benefits', + // 로그인이 필요한 쓰기 화면 + '/community/write', + '/community/*/edit', + ], + }, + sitemap: `${process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'}/sitemap.xml`, + } +} 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/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..5b91c2d --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,73 @@ +import type { MetadataRoute } from 'next' + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000' +const API_URL = process.env.NEXT_PUBLIC_API_URL + +/** 목록당 이 개수까지만 싣는다. 사이트맵 하나가 지나치게 커지면 크롤러가 통째로 건너뛴다. */ +const MAX_PER_TYPE = 500 + +/** + * 빌드 시점에 백엔드가 떠 있지 않을 수 있다(CI 등). + * 사이트맵 때문에 빌드가 깨지면 안 되므로 실패하면 정적 경로만 내보낸다. + */ +const fetchList = async (path: string): Promise => { + if (!API_URL) return [] + + try { + const res = await fetch(`${API_URL}${path}`, { signal: AbortSignal.timeout(5000) }) + if (!res.ok) return [] + + const data = await res.json() + // 배열로 주는 곳과 { content: [...] } 로 주는 곳이 섞여 있다. + if (Array.isArray(data)) return data + if (data && Array.isArray(data.content)) return data.content + return [] + } catch { + return [] + } +} + +const toEntries = ( + items: unknown[], + idKey: string, + prefix: string, + priority: number, +): MetadataRoute.Sitemap => + items + .slice(0, MAX_PER_TYPE) + .map((item) => (item as Record)[idKey]) + .filter((id): id is string | number => id != null) + .map((id) => ({ + url: `${SITE_URL}${prefix}/${id}`, + changeFrequency: 'weekly' as const, + priority, + })) + +export default async function sitemap(): Promise { + const staticRoutes: MetadataRoute.Sitemap = [ + { url: SITE_URL, changeFrequency: 'monthly', priority: 1 }, + { url: `${SITE_URL}/home`, changeFrequency: 'daily', priority: 0.9 }, + { url: `${SITE_URL}/search`, changeFrequency: 'weekly', priority: 0.8 }, + { url: `${SITE_URL}/community`, changeFrequency: 'daily', priority: 0.8 }, + { url: `${SITE_URL}/facility`, changeFrequency: 'weekly', priority: 0.7 }, + { url: `${SITE_URL}/hospital`, changeFrequency: 'weekly', priority: 0.7 }, + { url: `${SITE_URL}/legal/terms`, changeFrequency: 'yearly', priority: 0.3 }, + { url: `${SITE_URL}/legal/privacy-policy`, changeFrequency: 'yearly', priority: 0.3 }, + ] + + const [policies, posts, facilities, hospitals] = await Promise.all([ + fetchList('/policies'), + fetchList('/community/posts?page=0&size=500'), + fetchList('/facilities?page=0&size=500'), + fetchList('/health/hospitals?page=0&size=500'), + ]) + + return [ + ...staticRoutes, + // 지원금 상세가 이 서비스에서 검색 유입 가치가 가장 큰 문서다. + ...toEntries(policies, 'id', '/policy', 0.9), + ...toEntries(posts, 'postId', '/community', 0.6), + ...toEntries(facilities, 'id', '/facility', 0.6), + ...toEntries(hospitals, 'id', '/hospital', 0.6), + ] +} 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/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/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 ? ( +
+