diff --git a/.kiro/steering/korean-language.md b/.kiro/steering/korean-language.md deleted file mode 100644 index 7e5c478..0000000 --- a/.kiro/steering/korean-language.md +++ /dev/null @@ -1,12 +0,0 @@ -# 한국어 응답 규칙 - -## 언어 설정 -- 모든 응답은 한국어로 작성해야 합니다 -- 코드 주석도 가능한 한 한국어로 작성합니다 -- 기술 용어는 필요시 영어와 한국어를 병행 표기합니다 (예: "컨테이너(container)") -- 에러 메시지나 로그는 원본 언어를 유지하되, 설명은 한국어로 제공합니다 - -## 예외 상황 -- 코드 자체는 영어로 작성 (변수명, 함수명 등) -- 공식 문서나 명령어는 원본 언어 유지 -- 사용자가 명시적으로 다른 언어를 요청하는 경우에만 예외 적용 \ No newline at end of file diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md deleted file mode 100644 index 28e14bf..0000000 --- a/.kiro/steering/product.md +++ /dev/null @@ -1,18 +0,0 @@ -# 제품 개요 - -한국 시장을 타겟으로 하는 부동산 임대 웹 애플리케이션입니다. - -## 타겟 플랫폼 -- 데스크톱 우선 반응형 웹앱 (모바일 지원) -- 브레이크포인트: sm (375px), md (640px), lg (768px) - -## 핵심 기능 -- 사용자 인증 (OAuth 기반 로그인/회원가입) -- 다단계 온보딩 플로우 -- 주소 검색 기능 -- 태그 기반 필터링 및 분류 - -## 사용자 플로우 -- 공개 라우트: 랜딩 페이지, 로그인, 회원가입, 온보딩 -- 보호된 라우트: 쿠키 기반 인증 필요 (`is_auth` 쿠키) -- 미들웨어가 라우트 보호 및 리다이렉트 처리 diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md deleted file mode 100644 index ee59a5f..0000000 --- a/.kiro/steering/structure.md +++ /dev/null @@ -1,89 +0,0 @@ -# 프로젝트 구조 - -## 아키텍처 패턴 -**FSD Hybrid** (Feature-Sliced Design + Next.js App Router) - -Next.js App Router 규칙과 FSD 원칙을 결합한 하이브리드 접근 방식을 사용합니다. - -## 디렉토리 구조 - -``` -/app # Next.js App Router (라우팅 레이어) - /api # API 라우트 - /home # 홈 페이지 라우트 - /login # 로그인 페이지 라우트 - /signup # 회원가입 페이지 라우트 - /onboarding # 온보딩 플로우 라우트 - /test # 테스트 페이지 - layout.tsx # 루트 레이아웃 - page.tsx # 루트 페이지 - globals.css # 전역 스타일 - -/src # FSD 레이어 - /app # 앱 레이어 (설정, 프로바이더) - /config # 앱 설정 - /providers # Context 프로바이더 - - /entities # 비즈니스 엔티티 - /address # 주소 엔티티 - /auth # 인증 엔티티 - /tag # 태그 엔티티 - - /features # 사용자 대면 기능 - /addressSearch # 주소 검색 기능 - /login # 로그인 기능 - /onboarding # 온보딩 기능 - - /shared # 공유 리소스 - /api # API 클라이언트 및 유틸리티 - /hooks # 재사용 가능한 React 훅 - /lib # 유틸리티 함수 - /types # TypeScript 타입 정의 - /ui # 공유 UI 컴포넌트 - - /widgets # 복합 UI 블록 - /onboardingSection # 온보딩 섹션 위젯 - - /stories # Storybook 스토리 - /assets # 정적 자산 - /icons # 아이콘 파일 - /images # 이미지 파일 - -/components # 레거시/shadcn 컴포넌트 - /ui # UI 컴포넌트 라이브러리 - -/lib # 루트 레벨 유틸리티 - utils.ts # className 병합을 위한 cn() 유틸리티 - -/public # 루트에서 제공되는 정적 파일 - /fonts # 폰트 파일 - -/.storybook # Storybook 설정 - -middleware.ts # 인증/라우팅을 위한 Next.js 미들웨어 -``` - -## 레이어 책임 - -### App Router (`/app`) -- 라우팅 및 페이지 렌더링 처리 -- Next.js 전용 파일 포함 (layout, page, loading, error) -- `/app/api`에 API 라우트 위치 - -### FSD 레이어 (`/src`) -- **app**: 애플리케이션 초기화, 프로바이더, 전역 설정 -- **entities**: 비즈니스 도메인 모델 및 로직 -- **features**: UI와 로직을 포함한 완전한 사용자 대면 기능 -- **shared**: 비즈니스 로직이 없는 재사용 가능한 코드 -- **widgets**: features/entities를 결합한 복합 UI 섹션 - -## 임포트 규칙 -- 워크스페이스 루트에서 절대 임포트 시 `@/` 접두사 사용 -- FSD 레이어는 의존성 규칙 준수: shared ← entities ← features ← widgets ← app -- `/src/shared/ui`의 컴포넌트는 프레임워크에 독립적이고 재사용 가능해야 함 - -## 컴포넌트 패턴 -- 조건부 className 병합을 위해 `@/lib/utils`의 `cn()` 유틸리티 사용 -- 접근성 높은 컴포넌트를 위해 Radix UI 프리미티브 선호 -- SVG 파일은 @svgr/webpack을 통해 React 컴포넌트로 임포트 -- Storybook 스토리는 `/src/stories`에 위치 diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md deleted file mode 100644 index bd27f49..0000000 --- a/.kiro/steering/tech.md +++ /dev/null @@ -1,54 +0,0 @@ -# 기술 스택 - -## 코어 프레임워크 -- **Next.js 15.5.4** - App Router 및 Turbopack 사용 -- **React 19.1.0** - strict mode 비활성화 -- **TypeScript 5** - strict mode 활성화 - -## 스타일링 -- **Tailwind CSS 3.x** - 커스텀 디자인 토큰 사용 -- **Framer Motion** - 애니메이션 처리 -- **next-themes** - 다크 모드 지원 -- 커스텀 애니메이션: logoBounce, logoPop, slideOutLeft, slideInRight - -## 상태 관리 -- **Zustand** - 전역 상태 관리 -- **TanStack React Query** - 서버 상태 및 데이터 페칭 - -## UI 컴포넌트 -- **Radix UI** - 접근성 높은 UI 프리미티브 (Dialog, Dropdown Menu, Slot) -- **shadcn/ui** 패턴 - `cn()` 유틸리티 사용 (clsx + tailwind-merge) -- **Lucide React** - 아이콘 라이브러리 -- **Sonner** - 토스트 알림 - -## 개발 도구 -- **Storybook 9.x** - 컴포넌트 개발 및 문서화 -- **Chromatic** - 비주얼 테스팅 -- **ESLint** - TypeScript, React, 접근성, Tailwind 플러그인 포함 -- **Prettier** - Tailwind 플러그인을 포함한 코드 포맷팅 - -## 빌드 시스템 -- **Turbopack** - 빠른 빌드 속도 -- **@svgr/webpack** - SVG를 React 컴포넌트로 임포트 -- **ESM 모듈** - package.json에 `"type": "module"` 설정 - -## 주요 명령어 - -```bash -# 개발 -npm run dev # Turbopack으로 개발 서버 시작 -npm run build # Turbopack으로 프로덕션 빌드 -npm run start # 프로덕션 서버 시작 - -# 코드 품질 -npm run lint # ESLint 실행 -npm run format # Prettier로 코드 포맷팅 - -# Storybook -npm run storybook # Storybook 개발 서버 시작 (포트 6006) -npm run build-storybook # Storybook 정적 파일 빌드 -npm run chromatic # Chromatic에 배포하여 비주얼 테스팅 -``` - -## 경로 별칭 (Path Aliases) -- `@/*` - 워크스페이스 루트를 가리킴 diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx new file mode 100644 index 0000000..7e5c58d --- /dev/null +++ b/app/mypage/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { SearchLine } from "@/src/assets/icons/home"; +import FootPrintIcon from "@/src/assets/icons/mypage/footPrintIcon"; +import MapPinIcon from "@/src/assets/icons/mypage/mapPinIcon"; +import RecentIcon from "@/src/assets/icons/mypage/recentIcon"; +import PinsetIcon from "@/src/assets/icons/mypage/pinsetIcon"; +import { MypageSection, UserInfoCard, PinReportSection } from "@/src/features/mypage/ui"; +import { useRouter } from "next/navigation"; +import { useOAuthStore } from "@/src/features/login/model/authStore"; + + +export default function MypagePage() { + const { userName } = useOAuthStore(); + const imageUrl = null; + const router = useRouter(); + return ( +
+ {/* 사용자 정보 카드 */} + { + router.push("/mypage/settings"); + }} + /> + + {/* 핀 보고서 섹션 */} + { + router.push("/eligibility"); + }} + /> + + {/* 내 정보 섹션 */} + , + label: "관심 주변 환경 설정", + onClick: () => { + alert("관심 주변 환경 설정 미구현 상태"); + // TODO: 네비게이션 구현 + }, + }, + { + icon: , + label: "핀포인트 설정", + onClick: () => { + router.push("/mypage/pinpoints"); + }, + }, + ]} + /> + + {/* 내 활동 섹션 */} + , + label: "저장 목록", + onClick: () => { + alert("저장 목록 이동 미구현 상태"); + // TODO: 네비게이션 구현 + }, + }, + { + icon: , + label: "최근 본 공고", + onClick: () => { + alert("최근 본 공고 이동 미구현 상태"); + // TODO: 네비게이션 구현 + }, + }, + ]} + /> +
+ ); +} + diff --git a/src/assets/icons/mypage/footPrintIcon.tsx b/src/assets/icons/mypage/footPrintIcon.tsx new file mode 100644 index 0000000..875f1e1 --- /dev/null +++ b/src/assets/icons/mypage/footPrintIcon.tsx @@ -0,0 +1,17 @@ +const FootPrintIcon = () => { + return ( + + + + + + + + + + + + ); +}; + +export default FootPrintIcon; \ No newline at end of file diff --git a/src/assets/icons/mypage/mapPinIcon.tsx b/src/assets/icons/mypage/mapPinIcon.tsx new file mode 100644 index 0000000..40fb6a9 --- /dev/null +++ b/src/assets/icons/mypage/mapPinIcon.tsx @@ -0,0 +1,17 @@ +const MapPinIcon = () => { + return ( + + + + + + + + + + + + ); +}; + +export default MapPinIcon; \ No newline at end of file diff --git a/src/assets/icons/mypage/myPageArrow.tsx b/src/assets/icons/mypage/myPageArrow.tsx new file mode 100644 index 0000000..bac9ce9 --- /dev/null +++ b/src/assets/icons/mypage/myPageArrow.tsx @@ -0,0 +1,17 @@ +const MyPageArrow = () => { + return ( + + + + + + + + + + + + ); +}; + +export default MyPageArrow; \ No newline at end of file diff --git a/src/assets/icons/mypage/pinsetIcon.tsx b/src/assets/icons/mypage/pinsetIcon.tsx new file mode 100644 index 0000000..8aff999 --- /dev/null +++ b/src/assets/icons/mypage/pinsetIcon.tsx @@ -0,0 +1,14 @@ +const PinsetIcon = () => { + return ( + + + + + + + + + ); +}; + +export default PinsetIcon; \ No newline at end of file diff --git a/src/assets/icons/mypage/recentIcon.tsx b/src/assets/icons/mypage/recentIcon.tsx new file mode 100644 index 0000000..50f9836 --- /dev/null +++ b/src/assets/icons/mypage/recentIcon.tsx @@ -0,0 +1,16 @@ +const RecentIcon = () => { + return ( + + + + + + + + + + + ); +}; + +export default RecentIcon; \ No newline at end of file diff --git a/src/assets/icons/mypage/settingsIcon.tsx b/src/assets/icons/mypage/settingsIcon.tsx new file mode 100644 index 0000000..0980437 --- /dev/null +++ b/src/assets/icons/mypage/settingsIcon.tsx @@ -0,0 +1,18 @@ +const SettingsIcon = () => { + return ( + + + + + + + + + + + + + ); +}; + +export default SettingsIcon; \ No newline at end of file diff --git a/src/assets/icons/svgFile/mypage/footprint.svg b/src/assets/icons/svgFile/mypage/footprint.svg new file mode 100644 index 0000000..490c130 --- /dev/null +++ b/src/assets/icons/svgFile/mypage/footprint.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/svgFile/mypage/mapPinIcon.svg b/src/assets/icons/svgFile/mypage/mapPinIcon.svg new file mode 100644 index 0000000..f96e536 --- /dev/null +++ b/src/assets/icons/svgFile/mypage/mapPinIcon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/svgFile/mypage/myPageArrowIcon.svg b/src/assets/icons/svgFile/mypage/myPageArrowIcon.svg new file mode 100644 index 0000000..294a063 --- /dev/null +++ b/src/assets/icons/svgFile/mypage/myPageArrowIcon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/svgFile/mypage/pinsetIcon.svg b/src/assets/icons/svgFile/mypage/pinsetIcon.svg new file mode 100644 index 0000000..8930d2b --- /dev/null +++ b/src/assets/icons/svgFile/mypage/pinsetIcon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/svgFile/mypage/recent.svg b/src/assets/icons/svgFile/mypage/recent.svg new file mode 100644 index 0000000..8d258fa --- /dev/null +++ b/src/assets/icons/svgFile/mypage/recent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/svgFile/mypage/settingsIcon.svg b/src/assets/icons/svgFile/mypage/settingsIcon.svg new file mode 100644 index 0000000..dc28109 --- /dev/null +++ b/src/assets/icons/svgFile/mypage/settingsIcon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/images/mypage/ProfileDefaultImg.tsx b/src/assets/images/mypage/ProfileDefaultImg.tsx index c4bd39c..9e55df4 100644 --- a/src/assets/images/mypage/ProfileDefaultImg.tsx +++ b/src/assets/images/mypage/ProfileDefaultImg.tsx @@ -1,6 +1,6 @@ -export const ProfileDefaultImg = () => { +export const ProfileDefaultImg = ({width = 43, height = 43}: {width?: number, height?: number}) => { return ( - + { const pinpointId = useOAuthStore(state => state.pinPointId); @@ -21,6 +21,7 @@ export const useNoticeInfinite = () => { queryKey: ["notice", pinpointId], enabled: !!pinpointId, initialPageParam: 1, + retry: false, queryFn: ({ pageParam }) => getNoticeByPinPoint>({ url: HOME_NOTICE_ENDPOINT, @@ -48,6 +49,7 @@ export const useNoticeCount = () => { return useQuery({ queryKey: ["noticeCount", pinPointId, debouncedMaxTime], enabled: !!pinPointId, + retry: false, placeholderData: previousData => previousData, queryFn: () => getNoticeByPinPoint({ url: url, params: param }), }); @@ -60,6 +62,7 @@ export const useGlobal = ({ params, q }: { params: string; q: string }) => { return useQuery({ queryKey: ["global-search", params, q], + retry: false, queryFn: () => getNoticeByPinPoint({ url, params: param }), enabled: params === "popular" || q?.length > 0, }); @@ -81,6 +84,7 @@ export const useGlobalPageNation = ({ queryKey: ["globalInfinity", apiCategory], enabled: Boolean(category), initialPageParam: 1, + retry: false, queryFn: ({ pageParam }) => getNoticeByPinPoint>({ url, @@ -95,3 +99,29 @@ export const useGlobalPageNation = ({ }, }); }; + +export const useRecommendedNotice = () => { + return useInfiniteQuery, Error>({ + queryKey: ["HOME_RECOMMENDED"], + initialPageParam: 1, + retry: false, + queryFn: async ({ pageParam }) => { + try { + return await getNoticeByPinPoint>({ + url: HOME_RECOMMENDED_ENDPOINT, + params: { page: Number(pageParam), offSet: 10 }, + }); + } catch (e) { + if (axios.isAxiosError(e)) { + const message = e.response?.data?.message ?? e.response?.data?.error ?? e.message; + toast.error(message); + throw new Error(message); + } + throw e instanceof Error ? e : new Error("Unknown error"); + } + }, + getNextPageParam: lastPage => { + return lastPage.hasNext ? lastPage.pages + 1 : undefined; + }, + }); +}; diff --git a/src/features/home/ui/homeActionCardList.tsx b/src/features/home/ui/homeActionCardList.tsx index ff1eb7c..510cbc6 100644 --- a/src/features/home/ui/homeActionCardList.tsx +++ b/src/features/home/ui/homeActionCardList.tsx @@ -1,20 +1,16 @@ "use client"; import { ArrowUpRight } from "@/src/assets/icons/button/arrowUpRight"; -import { useNoticeCount } from "@/src/entities/home/hooks/homeHooks"; +import { useNoticeCount, useRecommendedNotice } from "@/src/entities/home/hooks/homeHooks"; import { useRouter } from "next/navigation"; +import { useHomeActionCard } from "@/src/features/home/ui/homeUseHooks/homeUseHooks"; export const ActionCardList = () => { const { data } = useNoticeCount(); - const conut = data?.count; - const router = useRouter(); + const { data: recommend } = useRecommendedNotice(); - const onListingsPageMove = () => { - router.push("/listings"); - }; + const count = data?.count; + const { onListingsPageMove, onEligibilityPageMove } = useHomeActionCard(); - const onEligibilityPageMove = () => { - router.push("/eligibility"); - }; return (
{
-

{conut}건

+

{count}건

{
-

0건

+

+ {recommend?.pages?.length ? recommend?.pages?.length : "0"}건 +

{ return { line1, line2, - onSelectSection - } -} \ No newline at end of file + onSelectSection, + }; +}; + +export const useHomeActionCard = () => { + const router = useRouter(); + + const onListingsPageMove = () => { + router.push("/listings"); + }; + + const onEligibilityPageMove = () => { + router.push("/eligibility"); + }; + + return { + onListingsPageMove, + onEligibilityPageMove, + }; +}; diff --git a/src/features/mypage/ui/index.ts b/src/features/mypage/ui/index.ts index 4db27ce..8edd366 100644 --- a/src/features/mypage/ui/index.ts +++ b/src/features/mypage/ui/index.ts @@ -5,3 +5,7 @@ export * from "./profileAvatar"; export * from "./profileNicknameInput"; export * from "./profileLoginInfo"; export * from "./profilePhotoBottomSheet"; +export * from "./mypageSection"; +export * from "./userInfoCard"; +export * from "./pinReportSection"; +export * from "./mypageMenuItem"; diff --git a/src/features/mypage/ui/mypageMenuItem.tsx b/src/features/mypage/ui/mypageMenuItem.tsx new file mode 100644 index 0000000..a32d578 --- /dev/null +++ b/src/features/mypage/ui/mypageMenuItem.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { ReactNode } from "react"; +import MyPageArrow from "@/src/assets/icons/mypage/myPageArrow"; + +export interface MypageMenuItemProps { + icon: ReactNode; + label: string; + onClick?: () => void; +} + +export const MypageMenuItem = ({ icon, label, onClick }: MypageMenuItemProps) => { + return ( +
+ +
+ ); +}; + diff --git a/src/features/mypage/ui/mypageSection.tsx b/src/features/mypage/ui/mypageSection.tsx new file mode 100644 index 0000000..62f09ae --- /dev/null +++ b/src/features/mypage/ui/mypageSection.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { ReactNode } from "react"; +import { MypageMenuItem, MypageMenuItemProps } from "./mypageMenuItem"; + +export interface MypageSectionProps { + title: string; + items: MypageMenuItemProps[]; +} + +export const MypageSection = ({ title, items }: MypageSectionProps) => { + return ( +
+
+

+ {title} +

+
+ {items.map((item, index) => ( + + ))} +
+ ); +}; + diff --git a/src/features/mypage/ui/pinReportSection.tsx b/src/features/mypage/ui/pinReportSection.tsx new file mode 100644 index 0000000..1009481 --- /dev/null +++ b/src/features/mypage/ui/pinReportSection.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Button } from "@/src/shared/lib/headlessUi"; + +interface PinReportSectionProps { + onDiagnosisClick?: () => void; +} + +export const PinReportSection = ({ + onDiagnosisClick +}: PinReportSectionProps) => { + return ( +
+
+

+ 핀 보고서 +

+
+
+

+ 자격진단으로 +
+ 임대주택 지원 가능 여부를 확인하고 +
+ 맞춤 보고서를 받아보세요 +

+ +
+ + +
+ ); +}; diff --git a/src/features/mypage/ui/userInfoCard.tsx b/src/features/mypage/ui/userInfoCard.tsx new file mode 100644 index 0000000..8d7bff6 --- /dev/null +++ b/src/features/mypage/ui/userInfoCard.tsx @@ -0,0 +1,49 @@ +"use client"; + +import Image from "next/image"; +import { ProfileDefaultImg } from "@/src/assets/images/mypage/ProfileDefaultImg"; +import SettingsIcon from "@/src/assets/icons/mypage/settingsIcon"; + +interface UserInfoCardProps { + imageUrl?: string | null; + userName?: string; + userEmail?: string; + onSettingsClick?: () => void; +} + +export const UserInfoCard = ({ + imageUrl, + userName = "유저명", + userEmail = "userid@naver.com", + onSettingsClick +}: UserInfoCardProps) => { + return ( +
+
+ {imageUrl ? ( + 프로필 사진 + ) : ( +
+ +
+ )} +
+
+ + {userName} + + + {userEmail} + +
+ +
+ ); +}; diff --git a/src/shared/api/endpoints.ts b/src/shared/api/endpoints.ts index cb46965..e7ea4c4 100644 --- a/src/shared/api/endpoints.ts +++ b/src/shared/api/endpoints.ts @@ -13,6 +13,7 @@ export const HTTP_METHODS = { */ export const HOME_NOTICE_ENDPOINT = "/home/notice"; export const HOME_SEARCH_POPULAR_ENDPOINT = "/home/search"; +export const HOME_RECOMMENDED_ENDPOINT = "/home/recommended-notices"; /** * 공고 API 엔드포인트 diff --git a/src/shared/ui/bottomNavigation/bottomNavigation.tsx b/src/shared/ui/bottomNavigation/bottomNavigation.tsx index 3c3dfe7..a551641 100644 --- a/src/shared/ui/bottomNavigation/bottomNavigation.tsx +++ b/src/shared/ui/bottomNavigation/bottomNavigation.tsx @@ -40,6 +40,7 @@ function BottomNavigationContent() { compareDetailPageRegex.test(pathname) || (pathname === "/home" && searchParams.has("mode")); + const isMypageActive = pathname === "/mypage" || pathname.startsWith("/mypage/"); if (shouldHide) return null; return (
@@ -65,8 +66,8 @@ function BottomNavigationContent() { router.push("/mypage/settings")} - fill={pathname === "/mypage/settings" ? "black" : "none"} + onClick={() => router.push("/mypage")} + fill={isMypageActive ? "black" : "none"} /> 마이