From f16ca75547578a8b50a5f9e27205a9b799ad7222 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Fri, 25 Sep 2026 03:02:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(layout):=20=ED=83=AD=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EA=B8=B0=EC=A4=80(=EC=95=84=EC=9D=B4=C2=B7?= =?UTF-8?q?=EC=A7=80=EC=97=AD)=EC=9D=84=20=EC=84=B8=EC=9A=B0=EA=B3=A0=20?= =?UTF-8?q?=ED=99=88=EC=9D=84=20=ED=95=A0=20=EC=9D=BC=20=EC=A4=91=EC=8B=AC?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 화면 11개를 띄워 보고 화면들 사이에서 어긋나는 것만 골라 고쳤다. 진단 기록은 docs/ui-audit.md. - 상단: 탭 화면은 ContextBar(아이 칩·지역 칩·알림). 이 서비스의 답은 전부 '어느 아이, 어디 사는가' 에 달려 있는데 화면 어디에도 그 기준이 없었다 - 선택된 아이를 전역 상태로(useSelectedChild, 기기 저장). 화면마다 useState 로 흩어져 있어 화면을 옮기면 첫째로 되돌아갔다 - 홈: 기한이 걸린 것(TodoSection)을 맨 위로, 검색·퀵메뉴·챗봇은 '찾아보기' 로 내림. 없는 마감은 만들지 않는다 — 정책 신청 기간이 자유 문장이라 날짜가 있는 것(건강 알림·접종·대기·프로필)만, 그중 45일 안의 것만 올린다 - 탭: 홈·지원금·어린이집·커뮤니티·마이. 시설 목록을 (with-tabs) 로, 챗봇을 (without-tabs) 로 옮기고 뒤로 가기를 넣었다. 퀵메뉴에서 탭과 겹치던 시설 찾기 제거 - 목록·버튼·오류의 자리를 규칙으로: MenuList(카드+꺾쇠), BottomAction(주요 행동은 화면 아래), ErrorView 는 기본이 목록 자리만 채우고 라우트 오류 경계만 fullScreen --- README.md | 30 ++- docs/ui-audit.md | 76 +++++++ src/app/(with-tabs)/error.tsx | 2 +- .../facility/page.tsx | 4 +- src/app/(with-tabs)/home/page.tsx | 212 +++++++++--------- src/app/(with-tabs)/mypage/page.tsx | 24 +- src/app/(with-tabs)/search/page.tsx | 21 +- .../chat/page.tsx | 3 +- src/app/(without-tabs)/error.tsx | 2 +- src/app/(without-tabs)/health/page.tsx | 14 +- src/components/common/BottomAction.tsx | 33 +++ src/components/common/ContextBar.tsx | 96 ++++++++ src/components/common/Error.tsx | 28 ++- src/components/common/tab-bar/index.tsx | 17 +- src/components/features/home/QuickMenu.tsx | 9 +- src/components/features/home/TodoSection.tsx | 209 +++++++++++++++++ src/components/features/mypage/MenuList.tsx | 42 ++-- src/stores/useSelectedChild.ts | 60 +++++ 18 files changed, 693 insertions(+), 189 deletions(-) create mode 100644 docs/ui-audit.md rename src/app/{(without-tabs) => (with-tabs)}/facility/page.tsx (97%) rename src/app/{(with-tabs) => (without-tabs)}/chat/page.tsx (95%) create mode 100644 src/components/common/BottomAction.tsx create mode 100644 src/components/common/ContextBar.tsx create mode 100644 src/components/features/home/TodoSection.tsx create mode 100644 src/stores/useSelectedChild.ts diff --git a/README.md b/README.md index a971451..587affa 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ 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/ @@ -56,6 +56,32 @@ src/ `(with-tabs)/`** 여야 합니다 — 그룹 밖 경로를 탭에 넣으면 그 탭을 누르는 순간 탭 바가 사라져 다른 탭으로 돌아갈 수 없습니다. +### 화면 레이아웃 규칙 + +화면마다 틀이 달라지면 사용자는 "이 화면은 어떻게 생겼을 것"이라는 예측을 못 하고, 같은 +목적지가 여러 입구로 갈라집니다. 진단 기록은 [`docs/ui-audit.md`](./docs/ui-audit.md) 에 있습니다. + +**탭 화면의 상단은 `ContextBar`, 하위 화면은 `TopNavBar` 입니다.** +`ContextBar` 는 화면 이름 대신 **기준**을 보여줍니다 — 지금 보고 있는 아이와 지역. 이 서비스의 +답은 전부 그 둘에 달려 있어서, 기준이 화면에 없으면 사용자는 결과를 잘못 읽습니다. 하위 화면은 +뒤로 가기가 있는 `TopNavBar` 를 씁니다(`hasBackButton`). + +**지금 보는 아이는 전역 상태입니다** — `useSelectedChild()` (`src/stores/useSelectedChild.ts`). +화면마다 `useState` 로 고르면 화면을 옮길 때마다 첫째로 되돌아갑니다. 선택은 기기에 남기되, +서버 렌더에는 `localStorage` 가 없으므로 `skipHydration` 으로 두고 마운트 뒤에 복구합니다. + +**홈의 첫 화면은 "지금 할 일" 입니다.** 기한이 걸린 것만 위에 오고, 찾아보는 수단(검색·퀵메뉴· +챗봇)은 그 아래입니다. 여기에는 **날짜가 실제로 있는 것만** 올립니다. 정책의 신청 기간은 자유 +문장이라 마감일을 만들 수 없고, 없는 날짜를 지어내면 "추정치는 추정치로" 원칙과 어긋납니다. +날짜가 있어도 45일보다 먼 것은 아직 할 일이 아닙니다. + +**같은 목적지를 두 입구로 주지 않습니다.** 하단 탭에 있는 곳은 홈 퀵메뉴에 넣지 않습니다. +탭이 곧 이 서비스의 중요도 선언이라, 중복을 두면 그 선언이 흐려집니다. + +**목록은 카드, 주요 행동은 화면 아래입니다.** 누를 수 있는 목록은 흰 카드에 담고 오른쪽에 +꺾쇠를 둡니다(`MenuList`). 목록에 무엇을 더하는 행동은 `BottomAction` 으로 아래에 고정해, +목록이 길어져도 자리가 변하지 않게 합니다. + ### 의존성 규칙 **import 하는 패키지는 반드시 `package.json` 에 선언합니다.** 한동안 `framer-motion` 과 diff --git a/docs/ui-audit.md b/docs/ui-audit.md new file mode 100644 index 0000000..0d1630b --- /dev/null +++ b/docs/ui-audit.md @@ -0,0 +1,76 @@ +# 화면 틀 진단 (2026-09) + +로그인한 상태로 주요 화면 11개를 390×844(모바일)에서 실제로 띄워 보고 적었다. +개별 화면의 취향 문제가 아니라 **화면들 사이에서 어긋나는 것**만 남겼다. + +이 문서는 기록이다. 고친 것은 "고침"으로 표시하고, 남은 것은 왜 아직 못 고쳤는지 적는다. + +--- + +## 1. 상단바가 가장 중요한 것을 안 보여줬다 — 고침 + +모든 탭 화면의 상단바가 "화면 이름 + 종 아이콘"뿐이었다. 그런데 이 서비스가 내놓는 답은 +전부 **어느 아이, 어디 사는가**에 달려 있다(월령별 지원금, 접종 일정, 반 편성, 가까운 시설). +다자녀 가구는 지금 보는 화면이 누구 기준인지 알 방법이 없었고, 주소를 넣지 않은 사용자는 +추천이 비어 있는 이유도 알 수 없었다. + +- 아이 선택이 전역 상태로 없었다. `health/page.tsx`, `WaitlistDialog` 등 화면마다 로컬 + `useState` 로 흩어져 있어서 화면을 옮기면 기준이 첫째로 되돌아갔다. +- 고침: `src/stores/useSelectedChild.ts`(기기에 저장) + `src/components/common/ContextBar.tsx` + (아이 칩 · 지역 칩 · 알림). 탭 화면 네 곳(홈 · 지원금 · 어린이집 · 마이)에 붙였다. + +## 2. 홈이 포털이었다 — 고침 + +첫 화면 위쪽 절반이 검색창 · 아이콘 5개 · 캐릭터 배너로 채워져 있었고, 정작 **놓치면 +사라지는 것**(소급 신청 가능한 지원금, 지난 접종, 대기 순번)은 스크롤 아래에 있었다. + +- 고침: `src/components/features/home/TodoSection.tsx` 를 첫 화면 맨 위로. 찾아보는 수단 + (검색 · 퀵메뉴 · 챗봇)은 그 아래 "찾아보기"로 내렸다. +- **마감 임박은 넣지 못했다.** 정책의 신청 기간이 서버에서 자유 문장(`applicationPeriod`) + 으로 와서 날짜를 뽑을 수 없다. 날짜가 실제로 있는 것만 올린다 — 건강 알림 `dueDate`, + 접종 예정일, 대기 순번, 빠진 프로필. 구조화된 마감일은 관리자 타입(`applicationEndDate`) + 에만 있어, 정책 API 가 그 값을 열어주면 그때 추가한다. +- 날짜가 있는 항목은 45일 안(또는 지난 것)만 올린다. 1년 뒤 접종까지 올리면 목록이 + 달력이 되고 이번 주에 할 일이 묻힌다. + +## 3. 같은 목적지가 세 군데에 있었다 — 고침 + +건강 기록 · 병원 찾기 · 시설 찾기가 홈 퀵메뉴에도, 마이페이지 목록에도 있었다. 반대로 +탭 다섯 자리는 커뮤니티 · 육아 정보 · 홈 · 챗봇 · 마이페이지였다 — **서비스의 핵심인 +지원금과 어린이집이 탭에 없었다.** + +- 고침: 탭을 `홈 · 지원금 · 어린이집 · 커뮤니티 · 마이` 로 바꿨다. + - `facility/page.tsx` 를 `(with-tabs)` 로 옮겼다(상세 `facility/[id]` 는 그대로). + - 챗봇은 탭에서 내리고 `(without-tabs)` 로 옮겼다. 전체 화면 대화라 탭 바가 필요 없고, + 홈의 진입점(`ChatSection`)은 남겼다. 대신 나가는 길이 없어지므로 뒤로 가기를 넣었다. + - 퀵메뉴에서 탭과 겹치는 "시설 찾기" 를 뺐다. + +## 4. 화면마다 틀이 달랐다 — 일부 고침 + +| 항목 | 이전 | 지금 | +|---|---|---| +| 목록 | 홈은 흰 카드, 커뮤니티는 구분선 목록, 마이페이지는 텍스트 나열 | 마이페이지를 카드+꺾쇠로(`MenuList`). 커뮤니티 목록은 그대로 둠 | +| 주요 버튼 | 건강 기록은 본문 안, 시설 오류는 하단 고정, 커뮤니티는 떠 있는 버튼 | `BottomAction` 을 만들어 건강 기록에 적용. 나머지는 미적용 | +| 오류 | `ErrorView` 가 항상 `absolute inset-0 z-50` 이라 목록 한 칸이 실패해도 상단바·탭 바까지 덮었다 | 기본은 자리만 채우고, 라우트 오류 경계만 `fullScreen` | +| 상단 | 화면마다 제목+종 | 탭 화면은 `ContextBar`, 하위 화면은 `TopNavBar`(뒤로 가기) | + +남은 것: 커뮤니티 목록과 떠 있는 글쓰기 버튼, 시설 오류 화면의 하단 버튼은 아직 공통 +규칙을 따르지 않는다. 한 번에 바꾸면 회귀 범위가 커져 다음으로 미뤘다. + +## 5. 아직 남은 것 + +- **밀도**: 건강 기록은 칩 한 줄 · 카드 한 장 뒤로 화면이 비고, 칩 줄은 오른쪽에서 잘리는데 + 더 있다는 표시가 없다. +- **마이페이지 항목 상태값**: `MenuList` 에 `hint` 를 받도록 열어 뒀다(예: "대기 3곳"). + 아직 값을 넣지 않았다. + +--- + +## 다시 확인하는 법 + +```bash +npm run dev -- --port 3210 # 백엔드 CORS 에 이 주소가 있어야 한다 +``` + +로그인 화면의 `[DEV]` 버튼으로 들어가 홈 · 지원금 · 어린이집 · 커뮤니티 · 마이를 차례로 본다. +아이가 둘 이상인 계정으로 아이 칩을 바꿔 가며 홈의 "지금 할 일"이 바뀌는지 확인한다. diff --git a/src/app/(with-tabs)/error.tsx b/src/app/(with-tabs)/error.tsx index b3161f9..5ae4ece 100644 --- a/src/app/(with-tabs)/error.tsx +++ b/src/app/(with-tabs)/error.tsx @@ -22,7 +22,7 @@ const GroupError = ({ return (
- +
) } diff --git a/src/app/(without-tabs)/facility/page.tsx b/src/app/(with-tabs)/facility/page.tsx similarity index 97% rename from src/app/(without-tabs)/facility/page.tsx rename to src/app/(with-tabs)/facility/page.tsx index 2b8fcc9..4dac0ad 100644 --- a/src/app/(without-tabs)/facility/page.tsx +++ b/src/app/(with-tabs)/facility/page.tsx @@ -2,6 +2,7 @@ import { useRouter } from 'next/navigation' import { ReactElement, useMemo, useState } from 'react' import SearchIcon from '@/assets/icons/search.svg' +import ContextBar from '@/components/common/ContextBar' import EmptyState from '@/components/common/EmptyState' import ErrorView from '@/components/common/Error' import Layout from '@/components/common/Layout' @@ -98,7 +99,8 @@ const FacilityPage = (): ReactElement => { } return ( - + + ({ diff --git a/src/app/(with-tabs)/home/page.tsx b/src/app/(with-tabs)/home/page.tsx index 53a4a95..ecab334 100644 --- a/src/app/(with-tabs)/home/page.tsx +++ b/src/app/(with-tabs)/home/page.tsx @@ -3,29 +3,33 @@ import { format } from 'date-fns' import { useRouter } from 'next/navigation' import { ReactElement } from 'react' -import BellIcon from '@/assets/icons/bell.svg' import SearchIcon from '@/assets/icons/search.svg' +import ContextBar from '@/components/common/ContextBar' import Layout from '@/components/common/Layout' import MainSection from '@/components/common/MainSection' import Separator from '@/components/common/Separator' -import Spacer from '@/components/common/Spacer' import Input from '@/components/common/input' import ChatSection from '@/components/features/chat/ChatSection' import PopularPost from '@/components/features/community/popular-post' -import MissedBenefitBanner from '@/components/features/home/MissedBenefitBanner' import QuickMenu from '@/components/features/home/QuickMenu' +import TodoSection from '@/components/features/home/TodoSection' 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' +/** + * 홈. + * + * 순서가 이 화면의 전부다. 기한이 걸린 것(지금 할 일)이 가장 위에 오고, 그다음이 이 아이에게 + * 맞는 지원금, 찾아보는 수단(검색·퀵메뉴)은 그 아래다. 예전에는 검색창과 퀵메뉴와 배너가 + * 첫 화면의 절반을 차지해서, 정작 놓치면 사라지는 것들이 스크롤 아래에 있었다. + * + * 상단바는 화면 이름 대신 기준(아이 · 지역)을 보여준다 — ContextBar. + */ 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() const { data: popularPosts, @@ -35,110 +39,106 @@ const Home = (): ReactElement => { const { data: recommendations, isLoading: isRecommendationLoading } = usePolicyRecommendations(3) return ( - -
- - } - onClick={handleSearchClick} - readOnly - /> - -
- + + + +
+ + + +
+ {isRecommendationLoading ? ( + [0, 1].map((index) => ( +
+ )) + ) : !recommendations?.length ? ( +

+ 아이를 등록하면 월령과 거주지에 맞는 지원금을 찾아드려요. +

+ ) : ( + recommendations.map((recommendation) => ( + router.push(`/policy/${recommendation.policy.id}`)} + /> + )) + )} +
+ + + {/* 찾아보는 수단은 할 일 아래에 둔다 */} +
+

+ 찾아보기 +

+ } + onClick={() => router.push('/search')} + readOnly + /> - -
- {isRecommendationLoading ? ( - [0, 1].map((index) => ( -
- )) - ) : !recommendations?.length ? ( -

- 아이를 등록하면 월령과 거주지에 맞는 지원금을 찾아드려요. -

- ) : ( - recommendations.map((recommendation) => ( - router.push(`/policy/${recommendation.policy.id}`)} - /> - )) - )} -
- - -
- {isLoading ? ( -
- ) : error ? ( -
- 정책 목록을 불러올 수 없습니다. -
- ) : ( - policies?.map((policy) => { - const cardProps = convertPolicyToCardProps(policy) - return - }) - )} -
- - -
- {isPopularLoading ? ( - [...Array(5).keys()].map((index) => ( -
-
-
-
-
-
-
-
-
-
+
+ + +
+ {isLoading ? ( +
+ ) : error ? ( +
+ 정책 목록을 불러올 수 없습니다. +
+ ) : ( + policies?.map((policy) => { + const cardProps = convertPolicyToCardProps(policy) + return + }) + )} +
+ + + +
+ {isPopularLoading ? ( + [...Array(5).keys()].map((index) => ( +
+
+
+
+
+
+
+
+
- {index < 4 && }
- )) - ) : popularError ? ( -
- 인기 게시글을 불러올 수 없습니다. + {index < 4 && }
- ) : ( - popularPosts?.content?.map((post, index) => ( -
- router.push(`/community/${post.postId}`)} - /> - {index < popularPosts.content.length - 1 && } -
- )) - )} -
- -
- + )) + ) : popularError ? ( +
+ 인기 게시글을 불러올 수 없습니다. +
+ ) : ( + popularPosts?.content?.map((post, index) => ( +
+ router.push(`/community/${post.postId}`)} + /> + {index < popularPosts.content.length - 1 && } +
+ )) + )} +
+
) diff --git a/src/app/(with-tabs)/mypage/page.tsx b/src/app/(with-tabs)/mypage/page.tsx index 7b87ce6..b28ae75 100644 --- a/src/app/(with-tabs)/mypage/page.tsx +++ b/src/app/(with-tabs)/mypage/page.tsx @@ -1,18 +1,17 @@ 'use client' import { useRouter } from 'next/navigation' import { ReactElement, useState } from 'react' -import BellIcon from '@/assets/icons/bell.svg' import CameraIcon from '@/assets/icons/camera_small.svg' import KakaoIcon from '@/assets/icons/logo/kakao.svg' import PencilIcon from '@/assets/icons/pencil.svg' import AlertDialog from '@/components/common/AlertDialog' import AuthGuard from '@/components/common/AuthGuard' import Button from '@/components/common/Button' +import ContextBar from '@/components/common/ContextBar' 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 { useHasUnreadNotifications } from '@/queries/notification' import { useLogout, useProfileCompletion, useUserProfile } from '@/queries/user' /** 서버가 주는 불리언 맵의 키를 사용자가 읽을 수 있는 말로 바꾼다. */ @@ -64,7 +63,6 @@ const ProfileCompletionBanner = (): ReactElement | null => { const MyPage = (): ReactElement => { const router = useRouter() - const hasUnread = useHasUnreadNotifications() const { data: user, isLoading } = useUserProfile() const { mutate: logout, isPending: isLoggingOut } = useLogout() const isAdmin = useIsAdmin() @@ -73,18 +71,8 @@ const MyPage = (): ReactElement => { return ( - router.push('/notification'), - }, - ]} - > + + {/* 프로필 */} @@ -118,7 +106,6 @@ const MyPage = (): ReactElement => {
{ ]} /> { ]} /> { ]} /> { {/* 관리자에게만 노출한다. 실제 접근 통제는 서버가 한다. */} {isAdmin && ( router.push('/admin') }, @@ -201,7 +184,6 @@ const MyPage = (): ReactElement => { /> )} setLogoutDialogOpen(true) }, diff --git a/src/app/(with-tabs)/search/page.tsx b/src/app/(with-tabs)/search/page.tsx index 583291b..049eb91 100644 --- a/src/app/(with-tabs)/search/page.tsx +++ b/src/app/(with-tabs)/search/page.tsx @@ -1,16 +1,15 @@ 'use client' import { useRouter } from 'next/navigation' import { ReactElement } from 'react' -import BellIcon from '@/assets/icons/bell.svg' import SearchIcon from '@/assets/icons/search.svg' import Chip from '@/components/common/Chip' +import ContextBar from '@/components/common/ContextBar' 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 => { @@ -18,28 +17,16 @@ const Search = (): ReactElement => { 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) => { e.preventDefault() search() } return ( - + + +
{ // 챗봇은 사용자별 대화 기록을 남긴다. 로그인 없이 들어오면 보낼 수 없다.
- {/* 탭의 최상위 화면이라 뒤로 가기를 두지 않는다. */} + {/* 탭에서 내려온 전체 화면 대화라 나가는 길은 뒤로 가기뿐이다. */} - +
) } diff --git a/src/app/(without-tabs)/health/page.tsx b/src/app/(without-tabs)/health/page.tsx index 173d1ef..113526d 100644 --- a/src/app/(without-tabs)/health/page.tsx +++ b/src/app/(without-tabs)/health/page.tsx @@ -3,6 +3,7 @@ import clsx from 'clsx' import { useRouter } from 'next/navigation' import { ReactElement, useMemo, useState } from 'react' import AuthGuard from '@/components/common/AuthGuard' +import BottomAction from '@/components/common/BottomAction' import Button from '@/components/common/Button' import EmptyState from '@/components/common/EmptyState' import ErrorView from '@/components/common/Error' @@ -201,14 +202,11 @@ const HealthPage = (): ReactElement => { )} {children.length > 0 && ( - + + + )}
diff --git a/src/components/common/BottomAction.tsx b/src/components/common/BottomAction.tsx new file mode 100644 index 0000000..99783b8 --- /dev/null +++ b/src/components/common/BottomAction.tsx @@ -0,0 +1,33 @@ +import clsx from 'clsx' +import { ReactElement, ReactNode } from 'react' + +/** + * 화면의 주요 행동을 두는 자리. + * + * 같은 성격의 버튼이 화면마다 다른 곳에 있었다 — 본문 흐름 안, 하단 고정, 떠 있는 동그란 버튼. + * 그래서 "이 화면에서 뭘 하면 되는지"를 매번 다시 찾아야 했다. 목록에 무엇을 더하는 행동은 + * 이 컴포넌트로 화면 아래에 고정한다. 목록이 길어져도 자리가 변하지 않는다. + * + * 스크롤 컨테이너 안이 아니라 화면 기준으로 붙이므로, 마지막 항목이 가리지 않도록 + * 컨테이너 쪽에 아래 여백(pb-24)을 함께 준다. + */ +const BottomAction = ({ + children, + className, +}: { + children: ReactNode + className?: string +}): ReactElement => { + return ( +
+ {children} +
+ ) +} + +export default BottomAction diff --git a/src/components/common/ContextBar.tsx b/src/components/common/ContextBar.tsx new file mode 100644 index 0000000..d554e70 --- /dev/null +++ b/src/components/common/ContextBar.tsx @@ -0,0 +1,96 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { ReactElement } from 'react' +import ArrowDownIcon from '@/assets/icons/arrow_down.svg' +import BellIcon from '@/assets/icons/bell.svg' +import NavigationIcon from '@/assets/icons/navigation_small.svg' +import { Menubox } from '@/components/common/menubox' +import IconButton from '@/components/common/top-navbar/IconButton' +import { useHasUnreadNotifications } from '@/queries/notification' +import { useUserProfile } from '@/queries/user' +import { useSelectedChild } from '@/stores/useSelectedChild' +import { formatChildAge } from '@/utils/date' + +/** + * 탭 화면의 상단 맥락 바. + * + * 이 서비스의 답은 전부 "어느 아이, 어디 사는가" 에 달려 있다. 그런데 상단바가 화면 이름과 + * 종 아이콘만 보여줘서, 지원금 목록이 누구 기준인지 화면 어디에도 없었다. 기준을 상단에 + * 고정해 두면 사용자가 매번 되짚지 않아도 되고, 잘못된 기준으로 읽는 일도 막는다. + * + * 주소가 비어 있으면 지역 지원금과 가까운 시설 추천이 아예 동작하지 않으므로, 빈 자리를 + * 감추지 않고 "지역 설정" 버튼으로 드러낸다. + */ +const ContextBar = ({ title }: { title: string }): ReactElement => { + const router = useRouter() + const hasUnread = useHasUnreadNotifications() + const { data: user } = useUserProfile() + const { children, selected, select } = useSelectedChild() + + /* 주소는 "서울특별시 강남구 …" 로 온다. 상단 칩에는 시·구까지만 쓴다 */ + const region = user?.address?.trim().split(/\s+/).slice(0, 2).join(' ') + + const childItems = [ + ...children.map((child) => ({ + content: `${child.name} · ${formatChildAge(child.birthDate)}`, + onSelect: () => select(child.id), + })), + { content: '아이 추가하기', onSelect: () => router.push('/children/new') }, + ] + + return ( +
+
+

{title}

+ router.push('/notification')} + /> +
+ +
+ {selected ? ( + + {selected.name} + + {formatChildAge(selected.birthDate)} + + + 아이 바꾸기 + + } + items={childItems} + /> + ) : ( + + )} + + +
+
+ ) +} + +export default ContextBar diff --git a/src/components/common/Error.tsx b/src/components/common/Error.tsx index 7c50646..ab3d6a1 100644 --- a/src/components/common/Error.tsx +++ b/src/components/common/Error.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { ReactElement } from 'react' import Button from './Button' import Character from '@/assets/icons/characters/error.svg' @@ -6,21 +7,34 @@ interface ErrorProps { content?: string retryText?: string onRetry?: () => void + /** + * 화면 전체를 덮는 형태. 라우트 단위 오류 경계(error.tsx)처럼 화면에 남길 게 없을 때만 쓴다. + * + * 기본값이 전체 덮기였을 때는 목록 한 칸이 실패했을 뿐인데 상단바와 탭 바까지 가려서, + * 사용자가 다른 탭으로 옮겨갈 수단마저 사라졌다. + */ + fullScreen?: boolean } + const Error = ({ content = '문제가 발생했습니다.\n잠시 후 다시 시도해주세요.', retryText = '다시 시도하기', onRetry, + fullScreen = false, }: ErrorProps): ReactElement => { return ( -
-
- -

{content}

-
+
+ +

{content}

{onRetry && ( -
-
diff --git a/src/components/common/tab-bar/index.tsx b/src/components/common/tab-bar/index.tsx index aa53e67..fb58ed1 100644 --- a/src/components/common/tab-bar/index.tsx +++ b/src/components/common/tab-bar/index.tsx @@ -4,8 +4,8 @@ import { usePathname } from 'next/navigation' import { ReactElement } from 'react' import { TabItem } from './TabItem' -import SearchIcon from '@/assets/icons/book.svg' -import ChatIcon from '@/assets/icons/chat.svg' +import PolicyIcon from '@/assets/icons/book.svg' +import BuildingIcon from '@/assets/icons/building.svg' import HomeIcon from '@/assets/icons/home.svg' import CommunityIcon from '@/assets/icons/leaf.svg' import UserIcon from '@/assets/icons/user.svg' @@ -14,12 +14,17 @@ import UserIcon from '@/assets/icons/user.svg' * 하단 탭. 여기 있는 경로는 모두 `(with-tabs)` 그룹 안에 있어야 한다. * 그룹 밖 경로를 넣으면 그 탭을 누르는 순간 탭 바 자체가 사라진다. */ +/* + * 탭은 이 서비스가 무엇을 하는 곳인지 말한다. 예전에는 커뮤니티·챗봇이 다섯 중 둘을 차지하고 + * 정작 지원금과 어린이집은 탭에 없어서, 홈의 아이콘 줄을 거쳐야만 갈 수 있었다. + * 챗봇은 탭에서 내리고 홈의 진입점으로 남긴다(전체 화면 대화라 탭 바가 필요 없다). + */ const tabData = [ - { title: '커뮤니티', icon: CommunityIcon, url: '/community' }, - { title: '육아 정보', icon: SearchIcon, url: '/search' }, { title: '홈', icon: HomeIcon, url: '/home' }, - { title: '챗봇 상담', icon: ChatIcon, url: '/chat' }, - { title: '마이페이지', icon: UserIcon, url: '/mypage' }, + { title: '지원금', icon: PolicyIcon, url: '/search' }, + { title: '어린이집', icon: BuildingIcon, url: '/facility' }, + { title: '커뮤니티', icon: CommunityIcon, url: '/community' }, + { title: '마이', icon: UserIcon, url: '/mypage' }, ] export interface TabBarProps { diff --git a/src/components/features/home/QuickMenu.tsx b/src/components/features/home/QuickMenu.tsx index 470b26f..a8fe7d8 100644 --- a/src/components/features/home/QuickMenu.tsx +++ b/src/components/features/home/QuickMenu.tsx @@ -2,7 +2,6 @@ import { useRouter } from 'next/navigation' import { ReactElement } from 'react' import BabyIcon from '@/assets/icons/baby.svg' -import BuildingIcon from '@/assets/icons/building.svg' import CompassIcon from '@/assets/icons/compass.svg' import HeartIcon from '@/assets/icons/leaf.svg' import PhoneIcon from '@/assets/icons/phone_small.svg' @@ -12,10 +11,14 @@ const MENUS = [ { icon: BabyIcon, label: '아이 관리', href: '/children' }, { icon: HeartIcon, label: '건강 기록', href: '/health' }, { icon: PhoneIcon, label: '병원 찾기', href: '/hospital' }, - { icon: BuildingIcon, label: '시설 찾기', href: '/facility' }, ] -/** 홈에서 주요 기능으로 한 번에 이동하는 바로가기. */ +/** + * 홈에서 주요 기능으로 한 번에 이동하는 바로가기. + * + * 하단 탭에 있는 곳(어린이집·지원금·커뮤니티)은 넣지 않는다. 같은 목적지를 두 번 주면 + * 어느 쪽이 정식 입구인지 흐려지고, 탭이 말하는 중요도도 무너진다. + */ const QuickMenu = (): ReactElement => { const router = useRouter() diff --git a/src/components/features/home/TodoSection.tsx b/src/components/features/home/TodoSection.tsx new file mode 100644 index 0000000..69197e0 --- /dev/null +++ b/src/components/features/home/TodoSection.tsx @@ -0,0 +1,209 @@ +'use client' + +import { differenceInCalendarDays } from 'date-fns' +import { useRouter } from 'next/navigation' +import { ReactElement } from 'react' +import { useSiblingOverview } from '@/queries/child' +import { useHealthAlerts } from '@/queries/health' +import { useMissedBenefits } from '@/queries/policy' +import { useProfileCompletion } from '@/queries/user' +import { useMyWaitlists } from '@/queries/waitlist' +import { useSelectedChild } from '@/stores/useSelectedChild' +import { formatDate, toDate } from '@/utils/date' +import { formatAmount } from '@/utils/money' + +/** + * 홈 첫 화면의 "지금 할 일". + * + * 부모가 이 앱을 여는 이유는 둘러보기 위해서가 아니라 놓친 게 없는지 확인하기 위해서다. + * 그런데 홈이 검색창·퀵메뉴·배너로 시작해서, 정작 기한이 걸린 것들은 스크롤해야 나왔다. + * 그래서 기한이 있는 것만 위로 끌어올린다. + * + * **없는 마감은 만들지 않는다.** 정책의 신청 기간은 서버에서 자유 문장(`applicationPeriod`) + * 으로 와서 날짜를 뽑을 수 없다. 그래서 여기 올라오는 것은 날짜가 실제로 있는 것들뿐이다 — + * 소급 신청 잔여 기간, 건강 알림의 예정일, 다음 접종일, 대기 순번, 빠진 프로필 항목. + * + * 날짜가 있는 것은 45일 안(또는 이미 지난 것)만 올린다 — 나머지는 아직 할 일이 아니다. + */ +type Todo = { + id: string + label: string + title: string + detail?: string + href: string + /** 기한이 임박했거나 이미 지난 것. 목록 위쪽에 모으고 색으로도 구분한다 */ + urgent?: boolean +} + +/** 남은 날짜를 사람이 읽는 말로. 지난 것은 지났다고 말한다 */ +const dueLabel = (value?: string | null): string | undefined => { + const date = toDate(value) + if (!date) return undefined + + const days = differenceInCalendarDays(date, new Date()) + if (days < 0) return `${formatDate(value)} · ${Math.abs(days)}일 지남` + if (days === 0) return `${formatDate(value)} · 오늘` + return `${formatDate(value)} · ${days}일 남음` +} + +const daysLeft = (value?: string | null): number | null => { + const date = toDate(value) + return date ? differenceInCalendarDays(date, new Date()) : null +} + +const isOverdue = (value?: string | null): boolean => (daysLeft(value) ?? 0) < 0 + +/* + * 할 일에 올릴 기한의 범위. 1년 뒤 접종까지 "지금 할 일"에 올리면 목록이 달력이 되고, + * 정작 이번 주에 해야 할 것이 묻힌다. 지난 것은 기한과 상관없이 올린다. + */ +const SOON_DAYS = 45 +const isSoonOrOverdue = (value?: string | null): boolean => { + const days = daysLeft(value) + return days != null && days <= SOON_DAYS +} + +const TodoSection = (): ReactElement => { + const router = useRouter() + const { selected } = useSelectedChild() + const { data: missed, isLoading: isMissedLoading } = useMissedBenefits() + const { data: alerts, isLoading: isAlertsLoading } = useHealthAlerts() + const { data: overview, isLoading: isOverviewLoading } = useSiblingOverview() + const { data: waitlists, isLoading: isWaitlistLoading } = useMyWaitlists() + const { data: completion, isLoading: isCompletionLoading } = useProfileCompletion() + + const isLoading = + isMissedLoading || + isAlertsLoading || + isOverviewLoading || + isWaitlistLoading || + isCompletionLoading + + const todos: Todo[] = [] + + /* 1. 아직 받을 수 있는 돈. 기한이 걸려 있어 가장 먼저 본다 */ + if (missed && missed.claimableCount > 0) { + todos.push({ + id: 'missed', + label: '지원금', + title: `소급 신청할 수 있는 지원금 ${missed.claimableCount}건`, + detail: `${formatAmount(missed.claimableAmount)} · 기간이 지나면 사라져요`, + href: '/benefits/missed', + urgent: true, + }) + } + + /* 2. 접종·검진 알림. 예정일이 있는 것만, 지난 것을 먼저 */ + const dueAlerts = (alerts ?? []) + .filter((alert) => !alert.isRead && isSoonOrOverdue(alert.dueDate)) + .sort((a, b) => (toDate(a.dueDate)?.getTime() ?? 0) - (toDate(b.dueDate)?.getTime() ?? 0)) + .slice(0, 2) + + dueAlerts.forEach((alert, index) => { + todos.push({ + id: `alert-${alert.alertId ?? index}`, + label: '건강', + title: alert.title ?? '확인이 필요한 건강 알림', + detail: dueLabel(alert.dueDate), + href: '/health', + urgent: alert.priority === 'HIGH' || isOverdue(alert.dueDate), + }) + }) + + /* 3. 고른 아이의 다음 접종. 아이별로 흩어져 있으면 놓치기 쉽다 */ + const summary = overview?.children.find((child) => child.childId === selected?.id) + if ( + summary?.nextVaccination && + summary.nextVaccinationDate && + isSoonOrOverdue(summary.nextVaccinationDate) + ) { + todos.push({ + id: 'vaccination', + label: '접종', + title: `${summary.name} · ${summary.nextVaccination}`, + detail: dueLabel(summary.nextVaccinationDate), + href: `/children/${summary.childId}`, + urgent: isOverdue(summary.nextVaccinationDate), + }) + } + + /* 4. 대기 중인 어린이집. 순번은 서버가 주지만 시설 이름은 목록 화면에 있다 */ + const waiting = (waitlists ?? []).filter((entry) => entry.status === 'WAITING') + if (waiting.length > 0) { + const bestNumber = waiting + .map((entry) => entry.waitNumber) + .filter((n): n is number => typeof n === 'number') + .sort((a, b) => a - b)[0] + + todos.push({ + id: 'waitlist', + label: '어린이집', + title: `대기 중인 어린이집 ${waiting.length}곳`, + detail: bestNumber != null ? `가장 앞선 순번 ${bestNumber}번` : '순번을 아직 받지 못했어요', + href: '/mypage/waitlist', + }) + } + + /* 5. 빠진 프로필. 주소가 없으면 지역 지원금과 가까운 시설이 아예 안 나온다 */ + if (completion && !completion.complete) { + todos.push({ + id: 'profile', + label: '설정', + title: '프로필을 마저 채워주세요', + detail: '주소를 넣으면 지역 지원금과 가까운 시설을 찾아드려요', + href: '/mypage/edit', + }) + } + + const sorted = [...todos].sort((a, b) => Number(b.urgent ?? false) - Number(a.urgent ?? false)) + + return ( +
+

+ 지금 할 일 +

+ + {isLoading ? ( +
+ {[0, 1].map((index) => ( +
+ ))} +
+ ) : sorted.length === 0 ? ( +

+ 지금 챙길 것이 없어요. 새로 생기면 여기에서 먼저 알려드릴게요. +

+ ) : ( +
    + {sorted.map((todo) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + +export default TodoSection diff --git a/src/components/features/mypage/MenuList.tsx b/src/components/features/mypage/MenuList.tsx index 34b34c0..bb8050d 100644 --- a/src/components/features/mypage/MenuList.tsx +++ b/src/components/features/mypage/MenuList.tsx @@ -1,9 +1,12 @@ import clsx from 'clsx' import { ReactElement } from 'react' +import ChevronIcon from '@/assets/icons/arrow_down.svg' type MenuItem = { id: string title: string + /** 항목 오른쪽에 붙는 짧은 상태값 (예: 대기 3곳) */ + hint?: string onClick?: () => void } @@ -13,25 +16,34 @@ interface MenuListProps { className?: string } +/** + * 마이페이지의 묶음 목록. + * + * 예전에는 제목 아래 텍스트만 나열돼 있어서, 이게 눌리는 것인지 그냥 글인지 알 수 없었고 + * 카드로 된 다른 화면들과도 따로 놀았다. 다른 화면과 같은 흰 카드에 담고, 누를 수 있다는 것을 + * 화살표로 말한다. 항목 사이 구분선은 눌리는 영역의 경계를 겸한다. + */ const MenuList = ({ title, items, className }: MenuListProps): ReactElement => { return ( -
- {/* 메뉴 제목 */} -

{title}

- {/* 메뉴 아이템들 */} -
+
+

{title}

+
    {items.map((item) => ( - +
  • + +
  • ))} -
-
+ + ) } diff --git a/src/stores/useSelectedChild.ts b/src/stores/useSelectedChild.ts new file mode 100644 index 0000000..1be5c89 --- /dev/null +++ b/src/stores/useSelectedChild.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react' +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { useMyChildren } from '@/queries/child' +import { Child } from '@/types/apis/child' + +/** + * 지금 보고 있는 아이. + * + * 이 서비스의 화면은 거의 전부 "어느 아이 기준인가" 에 따라 답이 달라진다(월령별 지원금, + * 접종 일정, 어린이집 반). 그런데 아이 선택이 화면마다 로컬 state 로 흩어져 있어서, 화면을 + * 옮길 때마다 기준이 첫째로 되돌아가고 사용자는 무엇을 보고 있는지 알 수 없었다. + * + * 선택은 기기에 남긴다 — 앱을 다시 열 때마다 고르게 하면 다자녀 가구에는 매번 하는 일이 된다. + */ +type SelectedChildStore = { + childId: number | null + setChildId: (childId: number) => void +} + +export const useSelectedChildStore = create()( + persist( + (set) => ({ + childId: null, + setChildId: (childId) => set({ childId }), + }), + { + name: 'carecode.selected-child', + /* + * 서버 렌더에는 localStorage 가 없다. 자동 복구를 켜두면 첫 렌더가 서버와 달라져 + * 하이드레이션이 어긋나므로, 마운트된 뒤에 아래 훅이 직접 복구한다. + */ + skipHydration: true, + }, + ), +) + +export type SelectedChild = { + children: Child[] + /** 고른 아이. 고른 적이 없으면 첫째, 아이가 없으면 null */ + selected: Child | null + select: (childId: number) => void + isLoading: boolean +} + +export const useSelectedChild = (): SelectedChild => { + const { data, isLoading } = useMyChildren() + const childId = useSelectedChildStore((state) => state.childId) + const setChildId = useSelectedChildStore((state) => state.setChildId) + + useEffect(() => { + void useSelectedChildStore.persist.rehydrate() + }, []) + + const children = data ?? [] + /* 저장된 아이가 지워졌을 수 있다. 목록에 없으면 첫째로 되돌린다 */ + const selected = children.find((child) => child.id === childId) ?? children[0] ?? null + + return { children, selected, select: setChildId, isLoading } +}