Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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` 과
Expand Down
76 changes: 76 additions & 0 deletions docs/ui-audit.md
Original file line number Diff line number Diff line change
@@ -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]` 버튼으로 들어가 홈 · 지원금 · 어린이집 · 커뮤니티 · 마이를 차례로 본다.
아이가 둘 이상인 계정으로 아이 칩을 바꿔 가며 홈의 "지금 할 일"이 바뀌는지 확인한다.
2 changes: 1 addition & 1 deletion src/app/(with-tabs)/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const GroupError = ({

return (
<div className="relative h-full">
<ErrorView onRetry={reset} />
<ErrorView fullScreen onRetry={reset} />
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -98,7 +99,8 @@ const FacilityPage = (): ReactElement => {
}

return (
<Layout hasTopNav hasBackButton title="시설 찾기" contentClassName="px-4.5 py-5">
<Layout hasTopNav={false} contentClassName="px-4.5 pb-5">
<ContextBar title="어린이집" />
<PopularSection
title="많이 찾는 시설"
items={popular.map((item) => ({
Expand Down
212 changes: 106 additions & 106 deletions src/app/(with-tabs)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,110 +39,106 @@ const Home = (): ReactElement => {
const { data: recommendations, isLoading: isRecommendationLoading } = usePolicyRecommendations(3)

return (
<Layout
hasTopNav
title="홈"
actionButtons={[
{
icon: BellIcon,
'aria-label': '알림',
showBadge: hasUnread,
onClick: handleNotificationClick,
},
]}
>
<div className="px-4.5">
<Spacer className="h-5 shrink-0" />
<Input
value=""
placeholder="궁금한 정책이 있으신가요?"
rightIcon={<SearchIcon className="size-6 cursor-pointer fill-gray-400" />}
onClick={handleSearchClick}
readOnly
/>
<Spacer className="h-5" />
<div className="flex flex-col gap-4">
<MissedBenefitBanner />
<Layout hasTopNav={false}>
<ContextBar title="홈" />

<div className="flex flex-col gap-6 px-4.5 pt-1 pb-6">
<TodoSection />

<MainSection title="이 아이에게 맞는 지원금">
<div className="flex flex-col gap-3 px-4 pb-4">
{isRecommendationLoading ? (
[0, 1].map((index) => (
<div key={index} className="h-24 animate-pulse rounded-lg bg-gray-200" />
))
) : !recommendations?.length ? (
<p className="text-b2-regular text-gray-600">
아이를 등록하면 월령과 거주지에 맞는 지원금을 찾아드려요.
</p>
) : (
recommendations.map((recommendation) => (
<RecommendedPolicyCard
key={recommendation.policy.id}
recommendation={recommendation}
onClick={() => router.push(`/policy/${recommendation.policy.id}`)}
/>
))
)}
</div>
</MainSection>

{/* 찾아보는 수단은 할 일 아래에 둔다 */}
<section aria-labelledby="explore-title" className="flex flex-col gap-2.5">
<h2 id="explore-title" className="text-t2-semibold text-black">
찾아보기
</h2>
<Input
value=""
placeholder="궁금한 정책이 있으신가요?"
rightIcon={<SearchIcon className="size-6 cursor-pointer fill-gray-400" />}
onClick={() => router.push('/search')}
readOnly
/>
<QuickMenu />
<ChatSection />
<MainSection title="맞춤 추천">
<div className="flex flex-col gap-3 px-4 pb-4">
{isRecommendationLoading ? (
[0, 1].map((index) => (
<div key={index} className="h-24 animate-pulse rounded-lg bg-gray-200" />
))
) : !recommendations?.length ? (
<p className="text-b2-regular text-gray-600">
아이를 등록하면 월령과 거주지에 맞는 지원금을 찾아드려요.
</p>
) : (
recommendations.map((recommendation) => (
<RecommendedPolicyCard
key={recommendation.policy.id}
recommendation={recommendation}
onClick={() => router.push(`/policy/${recommendation.policy.id}`)}
/>
))
)}
</div>
</MainSection>
<MainSection title="최근 정책">
<div className="scrollbar-hide flex gap-3 overflow-x-auto px-4 pb-4 [&>*]:w-64 [&>*]:flex-shrink-0">
{isLoading ? (
<div className="h-40 w-64 animate-pulse rounded-lg bg-gray-200" />
) : error ? (
<div className="flex h-40 w-64 items-center justify-center rounded-lg bg-red-100 text-red-600">
정책 목록을 불러올 수 없습니다.
</div>
) : (
policies?.map((policy) => {
const cardProps = convertPolicyToCardProps(policy)
return <PolicyCard key={cardProps.id} {...cardProps} />
})
)}
</div>
</MainSection>
<MainSection title="인기 게시글">
<div className="flex flex-col px-4 pb-4">
{isPopularLoading ? (
[...Array(5).keys()].map((index) => (
<div key={index}>
<div className="flex h-16 animate-pulse items-center py-2">
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded bg-gray-200" />
<div className="h-3 w-1/2 rounded bg-gray-200" />
</div>
<div className="flex gap-2">
<div className="h-6 w-12 rounded bg-gray-200" />
<div className="h-6 w-12 rounded bg-gray-200" />
</div>
</section>

<MainSection title="최근 정책">
<div className="scrollbar-hide flex gap-3 overflow-x-auto px-4 pb-4 [&>*]:w-64 [&>*]:flex-shrink-0">
{isLoading ? (
<div className="h-40 w-64 animate-pulse rounded-lg bg-gray-200" />
) : error ? (
<div className="flex h-40 w-64 items-center justify-center rounded-lg bg-red-100 text-red-600">
정책 목록을 불러올 수 없습니다.
</div>
) : (
policies?.map((policy) => {
const cardProps = convertPolicyToCardProps(policy)
return <PolicyCard key={cardProps.id} {...cardProps} />
})
)}
</div>
</MainSection>

<MainSection title="인기 게시글">
<div className="flex flex-col px-4 pb-4">
{isPopularLoading ? (
[...Array(5).keys()].map((index) => (
<div key={index}>
<div className="flex h-16 animate-pulse items-center py-2">
<div className="flex-1 space-y-2">
<div className="h-4 w-3/4 rounded bg-gray-200" />
<div className="h-3 w-1/2 rounded bg-gray-200" />
</div>
<div className="flex gap-2">
<div className="h-6 w-12 rounded bg-gray-200" />
<div className="h-6 w-12 rounded bg-gray-200" />
</div>
{index < 4 && <Separator />}
</div>
))
) : popularError ? (
<div className="flex h-40 items-center justify-center text-red-600">
인기 게시글을 불러올 수 없습니다.
{index < 4 && <Separator />}
</div>
) : (
popularPosts?.content?.map((post, index) => (
<div key={post.postId}>
<PopularPost
content={post.title}
likeCount={post.likeCount}
commentCount={post.commentCount}
createdDate={format(new Date(post.createdAt), 'MM-dd')}
createdTime={format(new Date(post.createdAt), 'HH:mm')}
onClick={() => router.push(`/community/${post.postId}`)}
/>
{index < popularPosts.content.length - 1 && <Separator />}
</div>
))
)}
</div>
</MainSection>
</div>
<Spacer className="h-5" />
))
) : popularError ? (
<div className="flex h-40 items-center justify-center text-red-600">
인기 게시글을 불러올 수 없습니다.
</div>
) : (
popularPosts?.content?.map((post, index) => (
<div key={post.postId}>
<PopularPost
content={post.title}
likeCount={post.likeCount}
commentCount={post.commentCount}
createdDate={format(new Date(post.createdAt), 'MM-dd')}
createdTime={format(new Date(post.createdAt), 'HH:mm')}
onClick={() => router.push(`/community/${post.postId}`)}
/>
{index < popularPosts.content.length - 1 && <Separator />}
</div>
))
)}
</div>
</MainSection>
</div>
</Layout>
)
Expand Down
Loading
Loading