diff --git a/app/eligibility/page.tsx b/app/eligibility/page.tsx index 01e43c4..b6a01dc 100644 --- a/app/eligibility/page.tsx +++ b/app/eligibility/page.tsx @@ -1,23 +1,73 @@ "use client"; -import { Suspense, useEffect } from "react"; +import { Suspense, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { EligibilitySection } from "@/src/widgets/eligibilitySection"; import { useEligibilityStore } from "@/src/features/eligibility/model/eligibilityStore"; +import { useDiagnosisResultStore } from "@/src/features/eligibility/model/diagnosisResultStore"; +import { getDiagnosisLatest } from "@/src/features/eligibility/api/diagnosisApi"; +import type { DiagnosisLatestData } from "@/src/features/eligibility/api/diagnosisTypes"; +import { Modal } from "@/src/shared/ui/modal/default/modal"; import { Spinner } from "@/src/shared/ui/spinner/default"; export default function EligibilityPage() { + const router = useRouter(); const reset = useEligibilityStore(state => state.reset); + const setDiagnosisResult = useDiagnosisResultStore(s => s.setResult); + const [isModalOpen, setIsModalOpen] = useState(false); useEffect(() => { - // 자격진단 페이지 진입 시 store 초기화 - reset(); - }, [reset]); + let mounted = true; + + const checkLatest = async () => { + try { + const response = await getDiagnosisLatest(); + if (!mounted) return; + const data = response?.data as DiagnosisLatestData | undefined; + if (data != null && typeof data === "object" && "eligible" in data) { + setDiagnosisResult( + { + eligible: data.eligible, + decisionMessage: data.diagnosisResult, + recommended: data.recommended, + }, + { incomeLevel: data.myIncomeLevel } + ); + setIsModalOpen(true); + } else { + reset(); + } + } catch { + if (mounted) reset(); + } + }; + + checkLatest(); + return () => { + mounted = false; + }; + }, [reset, setDiagnosisResult]); + + const handleModalButtonClick = (index: number) => { + setIsModalOpen(false); + if (index === 0) { + setDiagnosisResult(null); + reset(); + } else { + router.push("/eligibility/result/final"); + } + }; return (
}> +
); } diff --git a/app/eligibility/result/final/page.tsx b/app/eligibility/result/final/page.tsx new file mode 100644 index 0000000..325086a --- /dev/null +++ b/app/eligibility/result/final/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { EligibilityFinalResultSection } from "@/src/widgets/eligibilitySection"; + +export default function EligibilityFinalResultPage() { + return ( +
+ +
+ ); +} diff --git a/app/eligibility/result/page.tsx b/app/eligibility/result/page.tsx new file mode 100644 index 0000000..25f08c8 --- /dev/null +++ b/app/eligibility/result/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { EligibilityResultSection } from "@/src/widgets/eligibilitySection"; + +export default function EligibilityResultPage() { + return ( +
+ +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index b2d1164..ba95ad1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,8 +8,6 @@ import { HomeLandingRender } from "@/src/shared/ui/globalRender/globalRender"; import { FrameBottomNav } from "@/src/shared/ui/bottomNavigation/frameBottomNavigation"; import { ClientOnly } from "@/src/shared/ui/clientOnly/clientOnly"; - - export const metadata: Metadata = { title: "pinhouse", description: "pinhosue-fe", @@ -43,9 +41,7 @@ export default function RootLayout({ } > - -
{children}
-
+ {children} diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx index 7e5c58d..fc01f77 100644 --- a/app/mypage/page.tsx +++ b/app/mypage/page.tsx @@ -1,83 +1,7 @@ "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"; - +import { MypageSection } from "@/src/widgets/mypageSection"; 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: 네비게이션 구현 - }, - }, - ]} - /> -
- ); + return ; } - diff --git a/app/mypage/pinpoints/page.tsx b/app/mypage/pinpoints/page.tsx index 0ca841f..816257f 100644 --- a/app/mypage/pinpoints/page.tsx +++ b/app/mypage/pinpoints/page.tsx @@ -1,50 +1,7 @@ "use client"; -import { MapPin } from "@/src/assets/icons/onboarding"; -import { useAddressStore } from "@/src/entities/address"; -import { AddressSearch } from "@/src/features/addressSearch"; -import { useAddPinpoint } from "@/src/features/mypage/hooks/useAddPinpoint"; -import { Button } from "@/src/shared/lib/headlessUi"; -import { useRouter } from "next/navigation"; +import { PinpointsSection } from "@/src/widgets/mypageSection"; -const MypagePinpointsPage = () => { - const title = "핀포인트 설정"; - const description = "나만의 핀포인트를 찍고\n원하는 거리 안의 임재주택을 찾아보세요!"; - const image = ; - const { address, pinPoint } = useAddressStore(); - const router = useRouter(); - const { addPinpoint, isLoading, isError, error } = useAddPinpoint({ - onSuccess: () => { - router.push("/mypage/settings"); // 주소 선택 후 이전 화면으로 - // 또는 목록 새로고침 등 - }, - onError: err => { - // 토스트 등 에러 처리 - }, - }); - return ( -
-
-
- {image} -
- {title &&

{title}

} -

{description}

-
- -
-
- {address ? ( - - ) : null} -
- ); -}; - -export default MypagePinpointsPage; +export default function MypagePinpointsPage() { + return ; +} diff --git a/app/mypage/profile/page.tsx b/app/mypage/profile/page.tsx index dd19974..6554475 100644 --- a/app/mypage/profile/page.tsx +++ b/app/mypage/profile/page.tsx @@ -1,26 +1,7 @@ "use client"; -import { ProfileForm } from "@/src/features/mypage/ui/profileForm"; -import { LeftButton } from "@/src/assets/icons/button/leftButton"; -import { useRouter } from "next/navigation"; -import { useOAuthStore } from "@/src/features/login/model/authStore"; +import { ProfileSection } from "@/src/widgets/mypageSection"; export default function ProfilePage() { - const { userName } = useOAuthStore(); - - // TODO: 로그인 시 or 프로필 조회 API로 실제 사용자 이메일, Provider 정보 필요 - const initialEmail = "로그인할때or프로필조회API로이메일_필요@naver.com"; // 예시 - const initialProvider = "kakao" as const; - return ( -
- {/* 프로필 폼 */} -
- -
-
- ); + return ; } diff --git a/app/mypage/settings/page.tsx b/app/mypage/settings/page.tsx index c497281..0313e43 100644 --- a/app/mypage/settings/page.tsx +++ b/app/mypage/settings/page.tsx @@ -1,35 +1,7 @@ "use client"; -import Link from "next/link"; -import { logout } from "@/src/features/login/utils/logout"; +import { SettingsSection } from "@/src/widgets/mypageSection"; export default function MypageSettingsPage() { - return ( -
-
- - 프로필 설정 - -
- - - - - 회원 탈퇴 - -
-
- ); + return ; } diff --git a/app/mypage/withdraw/page.tsx b/app/mypage/withdraw/page.tsx index 1825891..b5c48ba 100644 --- a/app/mypage/withdraw/page.tsx +++ b/app/mypage/withdraw/page.tsx @@ -1,25 +1,7 @@ "use client"; -import { WithdrawForm } from "@/src/features/mypage/ui/withdrawForm"; -import { WithdrawBanner } from "@/src/features/mypage/ui"; +import { WithdrawSection } from "@/src/widgets/mypageSection"; export default function WithdrawPage() { - return ( -
- {/* 상단 아이콘 및 문구 */} - - - {/* 구분선 */} -
- - {/* 탈퇴 폼 */} -
- -
-
- ); + return ; } diff --git a/app/test/page.tsx b/app/test/page.tsx index bbdd191..e0e33bc 100644 --- a/app/test/page.tsx +++ b/app/test/page.tsx @@ -1,127 +1,11 @@ "use client"; -import { useState } from "react"; -import { - EligibilityInfoButton, - EligibilityNumberInputList, - EligibilityOptionSelector, - EligibilityPriceInput, - EligibilitySelect, -} from "@/src/features/eligibility"; -import { DatePicker } from "@/src/shared/ui/datePicker/datePicker"; +import EligibilityLoadingState from "@/src/features/eligibility/ui/common/eligibilityLoadingState"; export default function DefaultTest() { - const [income, setIncome] = useState(""); - const [incomeError, setIncomeError] = useState(false); - - // 유효성 검증 예시: 값이 비어있거나 0이면 에러 - const validateIncome = (value: string) => { - if (!value || value === "0") { - setIncomeError(true); - } else { - setIncomeError(false); - } - }; - return ( -
- - - console.log("정보 클릭")} /> - - - ( - <> - 총{" "} - - {Number(values.under6 || 0) + Number(values.over7 || 0)} - {" "} - 명의 미성년 자녀가 있어요 - - )} - /> - - console.log("옵션 선택")} - /> - console.log("옵션 선택")} - /> - console.log("옵션 선택")} - /> - console.log("옵션 선택")} - /> - { - setIncome(value); - validateIncome(value); - console.log("소득 입력:", value); - }} - onBlur={() => validateIncome(income)} - /> -
+
+ +
); } diff --git a/next.config.ts b/next.config.ts index 130bac2..0f94eca 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,40 @@ import type { Configuration, RuleSetRule } from "webpack"; const nextConfig: NextConfig = { reactStrictMode: false, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "img1.kakaocdn.net", + pathname: "/**", + }, + { + protocol: "http", + hostname: "img1.kakaocdn.net", + pathname: "/**", + }, + { + protocol: "https", + hostname: "t1.kakaocdn.net", + pathname: "/**", + }, + { + protocol: "http", + hostname: "t1.kakaocdn.net", + pathname: "/**", + }, + { + protocol: "https", + hostname: "k.kakaocdn.net", + pathname: "/**", + }, + { + protocol: "http", + hostname: "k.kakaocdn.net", + pathname: "/**", + }, + ], + }, webpack(config: Configuration) { const fileLoaderRule = config.module?.rules?.find( (rule): rule is RuleSetRule => diff --git a/public/info/info_asset.png b/public/info/info_asset.png new file mode 100644 index 0000000..4a56349 Binary files /dev/null and b/public/info/info_asset.png differ diff --git a/public/info/info_car.png b/public/info/info_car.png new file mode 100644 index 0000000..4f26abc Binary files /dev/null and b/public/info/info_car.png differ diff --git a/public/info/info_house.png b/public/info/info_house.png new file mode 100644 index 0000000..5af8761 Binary files /dev/null and b/public/info/info_house.png differ diff --git a/src/assets/images/eligibility/eligibilityLoadingImg.tsx b/src/assets/images/eligibility/eligibilityLoadingImg.tsx new file mode 100644 index 0000000..ca78e88 --- /dev/null +++ b/src/assets/images/eligibility/eligibilityLoadingImg.tsx @@ -0,0 +1,145 @@ +import { SVGProps } from "react"; + +export const EligibilityLoadingImg = (props: SVGProps) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default EligibilityLoadingImg; diff --git a/src/assets/images/eligibility/resultBannerImg.tsx b/src/assets/images/eligibility/resultBannerImg.tsx new file mode 100644 index 0000000..ae78623 --- /dev/null +++ b/src/assets/images/eligibility/resultBannerImg.tsx @@ -0,0 +1,114 @@ +import { SVGProps } from "react"; + +interface ResultBannerImgProps extends SVGProps { + width?: number | string; + height?: number | string; +} + +const ResultBannerImg = ({ width = 96, height = 96, ...props }: ResultBannerImgProps) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default ResultBannerImg; diff --git a/src/assets/images/svgFile/eligibility/eligibilityLoadingImg.svg b/src/assets/images/svgFile/eligibility/eligibilityLoadingImg.svg new file mode 100644 index 0000000..422375d --- /dev/null +++ b/src/assets/images/svgFile/eligibility/eligibilityLoadingImg.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/svgFile/eligibility/resultBannerImg.svg b/src/assets/images/svgFile/eligibility/resultBannerImg.svg new file mode 100644 index 0000000..2195dd9 --- /dev/null +++ b/src/assets/images/svgFile/eligibility/resultBannerImg.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/entities/home/hooks/homeHooks.ts b/src/entities/home/hooks/homeHooks.ts index d792ca1..85364ed 100644 --- a/src/entities/home/hooks/homeHooks.ts +++ b/src/entities/home/hooks/homeHooks.ts @@ -100,28 +100,33 @@ export const useGlobalPageNation = ({ }); }; +const recommendedFetchedKey = (userId: string) => `home-recommended-fetched:${userId ?? "anon"}`; + export const useRecommendedNotice = () => { + const { userName } = useOAuthStore(); + const isBrowser = typeof window !== "undefined"; + + const fetched = + isBrowser && !!userName + ? sessionStorage.getItem(recommendedFetchedKey(userName)) === "query" + : false; + return useInfiniteQuery, Error>({ - queryKey: ["HOME_RECOMMENDED"], + queryKey: ["HOME_RECOMMENDED", userName], initialPageParam: 1, retry: false, + enabled: isBrowser && !!userName && !fetched, + staleTime: Infinity, + gcTime: Infinity, 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; + const data = await getNoticeByPinPoint>({ + url: HOME_RECOMMENDED_ENDPOINT, + params: { page: Number(pageParam), offSet: 10 }, + }); + + sessionStorage.setItem(recommendedFetchedKey(userName), "query"); + return data; }, + getNextPageParam: lastPage => (lastPage.hasNext ? lastPage.pages + 1 : undefined), }); }; diff --git a/src/entities/listings/hooks/useListingDetailSheetHooks.ts b/src/entities/listings/hooks/useListingDetailSheetHooks.ts index a75e163..af15d20 100644 --- a/src/entities/listings/hooks/useListingDetailSheetHooks.ts +++ b/src/entities/listings/hooks/useListingDetailSheetHooks.ts @@ -9,7 +9,7 @@ export const useListingDetailNoticeSheet = ({ id, url }: UseListingsHooksWith return useQuery({ queryKey: [url], - enabled: !!id, + enabled: !!id && !!url, staleTime: 1000 * 60 * 5, queryFn: () => diff --git a/src/features/eligibility/api/diagnosisApi.ts b/src/features/eligibility/api/diagnosisApi.ts new file mode 100644 index 0000000..392a2fd --- /dev/null +++ b/src/features/eligibility/api/diagnosisApi.ts @@ -0,0 +1,20 @@ +import { http, API_BASE_URL_V2 } from "@/src/shared/api/http"; +import { + DIAGNOSIS_ENDPOINT, + DIAGNOSIS_LATEST_ENDPOINT, +} from "@/src/shared/api/endpoints"; +import type { IResponse } from "@/src/shared/types/response"; +import type { DiagnosisLatestData, DiagnosisResultData } from "./diagnosisTypes"; +import type { DiagnosisPostRequest } from "./diagnosisTypes"; + +const v2Options = { baseURL: API_BASE_URL_V2 }; + +/** GET /v2/diagnosis/latest - 청약 진단 최신 결과 조회 (POST 응답과 구조 상이) */ +export function getDiagnosisLatest() { + return http.get>(DIAGNOSIS_LATEST_ENDPOINT, undefined, v2Options); +} + +/** POST /v2/diagnosis - 청약 진단 제출 */ +export function postDiagnosis(data: D) { + return http.post, D>(DIAGNOSIS_ENDPOINT, data, v2Options); +} diff --git a/src/features/eligibility/api/diagnosisTypes.ts b/src/features/eligibility/api/diagnosisTypes.ts new file mode 100644 index 0000000..3adbde6 --- /dev/null +++ b/src/features/eligibility/api/diagnosisTypes.ts @@ -0,0 +1,136 @@ +/** POST /v2/diagnosis 요청 body - API DTO Enum 타입 */ + +export const DIAGNOSIS_GENDER = ["남성", "여성", "미정"] as const; +export type DiagnosisGender = (typeof DIAGNOSIS_GENDER)[number]; + +export const DIAGNOSIS_ACCOUNT_YEARS = [ + "6개월 미만", + "6개월 이상 ~ 1년 미만", + "1년 이상 ~ 2년 미만", + "2년 이상", +] as const; +export type DiagnosisAccountYears = (typeof DIAGNOSIS_ACCOUNT_YEARS)[number]; + +export const DIAGNOSIS_ACCOUNT_DEPOSIT = [ + "0회 ~ 5회", + "6회 ~ 11회", + "12회 ~ 23회", + "24회 ~ 35회", + "36회 ~ 48회", + "49회 ~ 59회", + "60회 이상", +] as const; +export type DiagnosisAccountDeposit = (typeof DIAGNOSIS_ACCOUNT_DEPOSIT)[number]; + +export const DIAGNOSIS_ACCOUNT = ["600만원 이하", "600만원 이상"] as const; +export type DiagnosisAccount = (typeof DIAGNOSIS_ACCOUNT)[number]; + +export const DIAGNOSIS_EDUCATION_STATUS = [ + "대학교 재학 중이거나 다음 학기에 입학 예정", + "대학교 휴학 중이며 다음 학기 복학 예정", + "대학교 혹은 고등학교 졸업/중퇴 후 2년 이내", + "졸업/중퇴 후 2년이 지났지만 대학원에 재학 중", + "해당 사항 없음", +] as const; +export type DiagnosisEducationStatus = (typeof DIAGNOSIS_EDUCATION_STATUS)[number]; + +export const DIAGNOSIS_INCOME_LEVEL = [ + "1구간", + "2구간", + "3구간", + "4구간", + "5구간", + "6구간", + "기타", +] as const; +export type DiagnosisIncomeLevel = (typeof DIAGNOSIS_INCOME_LEVEL)[number]; + +export const DIAGNOSIS_HOUSING_STATUS = [ + "나는 무주택자지만 우리 가구원중 주택 소유자가 있어요", + "우리집 가구원 모두 주택을 소유하고 있지 않아요", + "주택을 소유하고 있어요", +] as const; +export type DiagnosisHousingStatus = (typeof DIAGNOSIS_HOUSING_STATUS)[number]; + +export const DIAGNOSIS_SPECIAL_CATEGORY = [ + "주거급여 수급자", + "생계/의료급여 수급자", + "한부모 가정", + "보호대상 한부모 가정", + "친인척 위탁가정", + "대리양육가정", + "철거민", + "국가 유공자 본인/가구", + "위안부 피해자 본인/가구", + "북한이탈주민 본인", + "장애인 등록자/장애인 가구", + "영구임대 퇴거자", + "장기복무 제대군인", + "주거 취약계층/긴급 주거지원 대상자", + "위탁가정/보육원 시설종료2년이내, 종료예정자", + "귀한 국군포로 본인", + "교통사고 유자녀 가정", + "산단근로자", + "보증 거절자", + "나 또는 배우자가 노부모를 1년 이상 부양", + "그룹홈 거주", + "조손가족", +] as const; +export type DiagnosisSpecialCategory = (typeof DIAGNOSIS_SPECIAL_CATEGORY)[number]; + +/** POST /v2/diagnosis 요청 body */ +export interface DiagnosisPostRequest { + gender: DiagnosisGender; + birthday: string; + monthPay: number; + hasAccount: boolean; + accountYears: DiagnosisAccountYears; + accountDeposit: DiagnosisAccountDeposit; + account: DiagnosisAccount; + maritalStatus: boolean; + marriageYears: number; + unbornChildrenCount: number; + under6ChildrenCount: number; + over7MinorChildrenCount: number; + educationStatus: DiagnosisEducationStatus; + hasCar: boolean; + carValue: number; + isHouseholdHead: boolean; + isSingle: boolean; + fetusCount: number; + minorCount: number; + adultCount: number; + incomeLevel: DiagnosisIncomeLevel; + housingStatus: DiagnosisHousingStatus; + housingYears: number; + propertyAsset: number; + carAsset: number; + financialAsset: number; + hasSpecialCategory: DiagnosisSpecialCategory[]; +} + +/** POST /v2/diagnosis 응답 data (공통 제외) */ +export interface DiagnosisResultData { + eligible: boolean; + decisionMessage: string; + recommended: string[]; + /** 내 소득분위 (예: "1분위")*/ + incomeLevel?: string; + /** 나의 지원 가능 대상 */ + targetGroups?: string[]; +} + +/** GET /v2/diagnosis/latest 응답 data (POST 응답과 별도 구조) */ +export interface DiagnosisLatestData { + age: number; + availableRentalTypes: string[]; + availableSupplyTypes: string[]; + diagnosedAt: string; + diagnosisId: number; + diagnosisResult: string; + eligible: boolean; + gender: string; + myIncomeLevel: string; + nickname: string; + recommended: string[]; +} diff --git a/src/features/eligibility/api/mapEligibilityToDiagnosisRequest.ts b/src/features/eligibility/api/mapEligibilityToDiagnosisRequest.ts new file mode 100644 index 0000000..3561d9a --- /dev/null +++ b/src/features/eligibility/api/mapEligibilityToDiagnosisRequest.ts @@ -0,0 +1,220 @@ +import type { EligibilityData } from "../model/eligibilityStore"; +import type { DiagnosisPostRequest, DiagnosisSpecialCategory } from "./diagnosisTypes"; +import { + DIAGNOSIS_ACCOUNT_YEARS, + DIAGNOSIS_ACCOUNT_DEPOSIT, + DIAGNOSIS_ACCOUNT, + DIAGNOSIS_EDUCATION_STATUS, + DIAGNOSIS_HOUSING_STATUS, + DIAGNOSIS_INCOME_LEVEL, +} from "./diagnosisTypes"; + +/** Store housingSubscriptionPeriod key → API accountYears */ +const ACCOUNT_YEARS_MAP: Record = { + "1": "6개월 미만", + "2": "6개월 이상 ~ 1년 미만", + "3": "1년 이상 ~ 2년 미만", + "4": "2년 이상", +}; + +/** Store housingSubscriptionPaymentCount key → API accountDeposit */ +const ACCOUNT_DEPOSIT_MAP: Record = { + "1": "0회 ~ 5회", + "2": "6회 ~ 11회", + "3": "12회 ~ 23회", + "4": "24회 ~ 35회", + "5": "36회 ~ 48회", + "6": "49회 ~ 59회", + "7": "60회 이상", +}; + +/** Store totalPaymentAmount id → API account (600만원) */ +const ACCOUNT_MAP: Record = { + "1": "600만원 이상", + "2": "600만원 이하", +}; + +/** Store householdHousingOwnershipStatus → API housingStatus */ +const HOUSING_STATUS_MAP: Record = { + "1": "나는 무주택자지만 우리 가구원중 주택 소유자가 있어요", + "2": "우리집 가구원 모두 주택을 소유하고 있지 않아요", + "3": "주택을 소유하고 있어요", +}; + +/** Store youngSingleStudentStatus id → API educationStatus */ +const EDUCATION_STATUS_MAP: Record = { + "1": "해당 사항 없음", + "2": "대학교 재학 중이거나 다음 학기에 입학 예정", + "3": "대학교 휴학 중이며 다음 학기 복학 예정", + "4": "대학교 혹은 고등학교 졸업/중퇴 후 2년 이내", + "5": "졸업/중퇴 후 2년이 지났지만 대학원에 재학 중", +}; + +function toNum(s: string | null | undefined): number { + if (s == null || s === "") return 0; + const n = Number(s); + return Number.isFinite(n) ? n : 0; +} + +function formatBirthday(date: Date | null): string { + if (!date) return ""; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +/** + * 스토어 monthlyIncome → 원 단위. + * (UI에서 만원으로 넣으면 150, 원으로 넣으면 1500000 저장되는 경우 모두 처리) + */ +function monthlyIncomeToWon(monthlyIncome: string | null): number { + const n = toNum(monthlyIncome); + if (n <= 0) return 0; + return n <= 10000 ? n * 10000 : n; +} + +/** 월소득(만원 기준 비교) + 수급자 여부 → API incomeLevel */ +function toIncomeLevel( + monthlyIncome: string | null, + benefitTypes: string[] +): (typeof DIAGNOSIS_INCOME_LEVEL)[number] { + if (benefitTypes?.length) return "1구간"; + const won = monthlyIncomeToWon(monthlyIncome); + const manwon = won / 10000; + if (manwon <= 0) return "1구간"; + if (manwon <= 150) return "1구간"; + if (manwon <= 250) return "2구간"; + if (manwon <= 350) return "3구간"; + if (manwon <= 450) return "4구간"; + if (manwon <= 550) return "5구간"; + if (manwon <= 650) return "6구간"; + return "기타"; +} + +/** benefitTypes store id → API hasSpecialCategory */ +const BENEFIT_TO_SPECIAL: Record = { + "1": "주거급여 수급자", + "2": "생계/의료급여 수급자", +}; + +/** familyTypes store id → API hasSpecialCategory */ +const FAMILY_TO_SPECIAL: Record = { + "1": "친인척 위탁가정", + "2": "대리양육가정", + "3": "한부모 가정", + "4": "보호대상 한부모 가정", +}; + +/** specialEligibilityTypes store id → API hasSpecialCategory */ +const SPECIAL_ELIGIBILITY_TO_API: Record = { + "1": "국가 유공자 본인/가구", + "2": "위안부 피해자 본인/가구", + "3": "북한이탈주민 본인", + "4": "장애인 등록자/장애인 가구", + "5": "교통사고 유자녀 가정", + "6": "영구임대 퇴거자", + "7": "영구임대 퇴거자", + "8": "주거 취약계층/긴급 주거지원 대상자", + "9": "산단근로자", + "10": "보증 거절자", +}; + +/** marriedHouseholdFamilyTypes store id → API hasSpecialCategory */ +const MARRIED_HOUSEHOLD_FAMILY_TO_SPECIAL: Record = { + "1": "나 또는 배우자가 노부모를 1년 이상 부양", + "2": "조손가족", +}; + +/** Store 라벨(또는 id) → API hasSpecialCategory (라벨로 저장된 경우 대응) */ +const SPECIAL_LABEL_TO_API: Record = { + "국가 유공자 본인/가구": "국가 유공자 본인/가구", + "위안부 피해자 본인/가구": "위안부 피해자 본인/가구", + "북한이탈주민 본인": "북한이탈주민 본인", + "장애인 등록자/장애인 가구": "장애인 등록자/장애인 가구", + "교통사고 유자녀 가정": "교통사고 유자녀 가정", + "부도 공공임대 퇴거자": "영구임대 퇴거자", + "영구임대 퇴거자": "영구임대 퇴거자", + "주거 취약계층/긴급 주거지원 대상자": "주거 취약계층/긴급 주거지원 대상자", + "산단 근로자": "산단근로자", + 산단근로자: "산단근로자", + 보증거절자: "보증 거절자", + "보증 거절자": "보증 거절자", +}; + +function mapHasSpecialCategory(data: EligibilityData): DiagnosisSpecialCategory[] { + const set = new Set(); + + (data.benefitTypes ?? []).forEach(id => { + const api = BENEFIT_TO_SPECIAL[id]; + if (api) set.add(api); + }); + (data.familyTypes ?? []).forEach(id => { + const api = FAMILY_TO_SPECIAL[id]; + if (api) set.add(api); + }); + (data.specialEligibilityTypes ?? []).forEach(val => { + const api = SPECIAL_ELIGIBILITY_TO_API[val] ?? SPECIAL_LABEL_TO_API[val]; + if (api) set.add(api); + }); + (data.marriedHouseholdFamilyTypes ?? []).forEach(id => { + const api = MARRIED_HOUSEHOLD_FAMILY_TO_SPECIAL[id]; + if (api) set.add(api); + }); + + return Array.from(set); +} + +/** + * EligibilityData(store) → POST /v2/diagnosis 요청 body 로 변환 (API DTO Enum 준수) + */ +export function mapEligibilityToDiagnosisRequest(data: EligibilityData): DiagnosisPostRequest { + const hasAccount = data.hasHousingSubscriptionSavings === "1"; + const maritalStatus = data.isNewlyMarried === true; + const isSingle = !maritalStatus && data.marriageStatus !== "1"; + + const spouse = data.spouseChildrenInfo ?? data.marriedHouseholdChildrenInfo; + const unbornChildrenCount = spouse?.expectedBirth ?? 0; + const under6ChildrenCount = data.childrenInfo?.under6 ?? spouse?.under6 ?? 0; + const over7MinorChildrenCount = data.childrenInfo?.over7 ?? spouse?.over7 ?? 0; + const minorCount = under6ChildrenCount + over7MinorChildrenCount; + const adultCount = maritalStatus ? 2 : 1; + + const housingStatus = + HOUSING_STATUS_MAP[data.householdHousingOwnershipStatus ?? ""] ?? + "우리집 가구원 모두 주택을 소유하고 있지 않아요"; + + const monthPay = monthlyIncomeToWon(data.monthlyIncome); + + const gender = data.gender === "1" ? "남성" : data.gender === "2" ? "여성" : "미정"; + + return { + gender, + birthday: formatBirthday(data.birthDate), + monthPay, + hasAccount, + accountYears: ACCOUNT_YEARS_MAP[data.housingSubscriptionPeriod ?? ""] ?? "2년 이상", + accountDeposit: ACCOUNT_DEPOSIT_MAP[data.housingSubscriptionPaymentCount ?? ""] ?? "0회 ~ 5회", + account: ACCOUNT_MAP[data.totalPaymentAmount ?? ""] ?? "600만원 이하", + maritalStatus, + marriageYears: toNum(data.marriagePeriod), + unbornChildrenCount, + under6ChildrenCount, + over7MinorChildrenCount, + educationStatus: EDUCATION_STATUS_MAP[data.youngSingleStudentStatus ?? ""] ?? "해당 사항 없음", + hasCar: data.hasCar === "1", + carValue: toNum(data.carAssetValue ?? data.householdCarAssetValue), + isHouseholdHead: data.householdRole === "1", + isSingle, + fetusCount: unbornChildrenCount, + minorCount, + adultCount, + incomeLevel: toIncomeLevel(data.monthlyIncome, data.benefitTypes ?? []), + housingStatus, + housingYears: toNum(data.housingDisposalYears), + propertyAsset: toNum(data.landAssetValue ?? data.householdLandAssetValue), + carAsset: toNum(data.carAssetValue ?? data.householdCarAssetValue), + financialAsset: toNum(data.financialAssetValue ?? data.householdFinancialAssetValue), + hasSpecialCategory: mapHasSpecialCategory(data), + }; +} diff --git a/src/features/eligibility/hooks/useDiagnosisLatest.ts b/src/features/eligibility/hooks/useDiagnosisLatest.ts new file mode 100644 index 0000000..716d46f --- /dev/null +++ b/src/features/eligibility/hooks/useDiagnosisLatest.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { getDiagnosisLatest } from "../api/diagnosisApi"; +import type { DiagnosisLatestData } from "../api/diagnosisTypes"; + +const QUERY_KEY = ["diagnosis", "latest"]; + +/** 마이페이지 등에서 자격진단 최신 결과 조회 (GET /v2/diagnosis/latest) */ +export function useDiagnosisLatest() { + const query = useQuery({ + queryKey: QUERY_KEY, + queryFn: async () => { + const res = await getDiagnosisLatest(); + return (res?.data ?? null) as DiagnosisLatestData | null; + }, + retry: false, + }); + + return { + data: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + refetch: query.refetch, + }; +} diff --git a/src/features/eligibility/hooks/useDiagnosisResult.ts b/src/features/eligibility/hooks/useDiagnosisResult.ts new file mode 100644 index 0000000..9b5abcc --- /dev/null +++ b/src/features/eligibility/hooks/useDiagnosisResult.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useEligibilityStore } from "../model/eligibilityStore"; +import { postDiagnosis } from "../api/diagnosisApi"; +import { mapEligibilityToDiagnosisRequest } from "../api/mapEligibilityToDiagnosisRequest"; +import type { DiagnosisResultData } from "../api/diagnosisTypes"; + +export function useDiagnosisResult() { + const data = useEligibilityStore(); + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const submit = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const body = mapEligibilityToDiagnosisRequest(data); + const response = await postDiagnosis(body); + const resultData = response.data as DiagnosisResultData | undefined; + if (resultData != null) { + setResult(resultData); + return resultData; + } + setResult(null); + return null; + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)); + setError(e); + setResult(null); + throw e; + } finally { + setIsLoading(false); + } + }, [data]); + + return { result, isLoading, error, submit }; +} diff --git a/src/features/eligibility/model/diagnosisResultStore.ts b/src/features/eligibility/model/diagnosisResultStore.ts new file mode 100644 index 0000000..67bee26 --- /dev/null +++ b/src/features/eligibility/model/diagnosisResultStore.ts @@ -0,0 +1,22 @@ +import { create } from "zustand"; +import type { DiagnosisResultData } from "../api/diagnosisTypes"; + +export interface DiagnosisResultMeta { + incomeLevel?: string; +} + +interface DiagnosisResultState { + result: DiagnosisResultData | null; + incomeLevel: string | null; + setResult: (result: DiagnosisResultData | null, meta?: DiagnosisResultMeta) => void; +} + +export const useDiagnosisResultStore = create(set => ({ + result: null, + incomeLevel: null, + setResult: (result, meta) => + set({ + result, + incomeLevel: result ? (meta?.incomeLevel ?? null) : null, + }), +})); diff --git a/src/features/eligibility/model/eligibilityConstants.ts b/src/features/eligibility/model/eligibilityConstants.ts new file mode 100644 index 0000000..1eb4552 --- /dev/null +++ b/src/features/eligibility/model/eligibilityConstants.ts @@ -0,0 +1,12 @@ +/** 자격 진단 로딩 화면 문구 */ +export const ELIGIBILITY_LOADING_TITLE = "맞춤 보고서를 작성 중이에요!"; +export const ELIGIBILITY_LOADING_SUBTITLE_LINE1 = "모두의 꿈인 내 집 마련"; +export const ELIGIBILITY_LOADING_SUBTITLE_LINE2 = "나에게 맞는 추천 집은?"; + +/** 자격 진단 결과(입력 정보 확인) 화면 문구 */ +export const ELIGIBILITY_RESULT_PAGE_TITLE = "입력 정보 확인"; +export const ELIGIBILITY_RESULT_BANNER_TITLE = (userName: string) => + `${userName}님의 진단 결과입니다`; +export const ELIGIBILITY_RESULT_BANNER_SUBTITLE = + "입력하신 정보가 맞는지 확인해 보세요"; +export const ELIGIBILITY_RESULT_BUTTON = "결과보기"; diff --git a/src/features/eligibility/model/eligibilityDecisionTree.ts b/src/features/eligibility/model/eligibilityDecisionTree.ts index 5d889b1..66e303a 100644 --- a/src/features/eligibility/model/eligibilityDecisionTree.ts +++ b/src/features/eligibility/model/eligibilityDecisionTree.ts @@ -402,7 +402,7 @@ export const eligibilityDecisionTree: StepConfig[] = [ return null; }, getNextStep: () => { - return "diagnosisEnd"; + return null; }, }, @@ -1033,6 +1033,7 @@ export const eligibilityDecisionTree: StepConfig[] = [ props: { title: "세대주, 세대원의 차이가 궁금하다면?", description: "", + sheetContentType: "house", }, }, ], @@ -1172,6 +1173,7 @@ export const eligibilityDecisionTree: StepConfig[] = [ props: { title: "총자산 계산법이 궁금하다면?", description: "", + sheetContentType: "asset", }, }, ], @@ -1182,7 +1184,7 @@ export const eligibilityDecisionTree: StepConfig[] = [ return null; }, getNextStep: () => { - return "diagnosisEnd"; + return null; }, }, @@ -1294,6 +1296,7 @@ export const eligibilityDecisionTree: StepConfig[] = [ props: { title: "인정되는 자동차 기준이 궁금하다면?", description: "", + sheetContentType: "car", }, }, { @@ -1314,6 +1317,7 @@ export const eligibilityDecisionTree: StepConfig[] = [ props: { title: "총자산 계산법이 궁금하다면?", description: "", + sheetContentType: "asset", }, }, ], @@ -1335,7 +1339,7 @@ export const eligibilityDecisionTree: StepConfig[] = [ }, getNextStep: data => { // 다음 단계로 이동 (추후 결정) - return "diagnosisEnd"; + return null; }, }, @@ -1369,6 +1373,7 @@ export const eligibilityDecisionTree: StepConfig[] = [ props: { title: "세대주, 세대원의 차이가 궁금하다면?", description: "", + sheetContentType: "house", }, }, ], @@ -1745,6 +1750,7 @@ export const eligibilityDecisionTree: StepConfig[] = [ props: { title: "인정되는 자동차 기준이 궁금하다면?", description: "", + sheetContentType: "car", }, }, { @@ -1784,26 +1790,9 @@ export const eligibilityDecisionTree: StepConfig[] = [ }, getNextStep: data => { // 다음 단계로 이동 (추후 결정) - return "diagnosisEnd"; + return null; }, }, - - // diagnosisEnd - { - id: "diagnosisEnd", - groupId: "diagnosisEnd", - components: [ - { - type: "statusBanner", - props: { - title: "진단이 종료되었습니다", - description: "", - }, - }, - ], - validation: () => null, - getNextStep: () => null, - }, ]; /** diff --git a/src/features/eligibility/model/eligibilityResultModel.ts b/src/features/eligibility/model/eligibilityResultModel.ts new file mode 100644 index 0000000..fa5fd9d --- /dev/null +++ b/src/features/eligibility/model/eligibilityResultModel.ts @@ -0,0 +1,82 @@ +import type { EligibilityData } from "./eligibilityStore"; +import { calculateAge } from "./eligibilityDecisionTree"; + +export interface EligibilityResultListItem { + label: string; + value: string; +} + +const formatGender = (v: string | null) => (v === "1" ? "남성" : v === "2" ? "여성" : "-"); +const formatHouseholdRole = (v: string | null) => + v === "1" ? "세대주" : v === "2" ? "세대원" : "-"; +const formatHouseholdComposition = (v: string | null) => + v === "1" ? "1인 가구" : v === "2" ? "가족과 함께" : v === "3" ? "공동생활가정" : "-"; +const formatHousing = (v: string | null) => + v === "1" ? "무주택가구" : v === "2" ? "주택 소유" : v ? "해당 없음" : "-"; +const formatCar = (v: string | null) => (v === "1" ? "있음" : v === "2" ? "없음" : "-"); + +/** + * store에 저장된 EligibilityData를 결과 확인 화면용 리스트 아이템으로 변환 + */ +export function getEligibilityResultListItems(data: EligibilityData): EligibilityResultListItem[] { + const age = calculateAge(data.birthDate); + const ageStr = age != null ? `만 ${age}세` : "-"; + const genderStr = formatGender(data.gender); + const incomeStr = data.benefitTypes?.length + ? `소득 1분위, 주거급여 수급자` + : data.monthlyIncome + ? `월소득 ${data.monthlyIncome}만원` + : "-"; + const studentStr = + data.youngSingleStudentStatus && data.youngSingleStudentStatus !== "1" + ? "대학생, 부모님 소득 1분위" + : "-"; + const subscriptionStr = + data.hasHousingSubscriptionSavings === "1" + ? `${data.housingSubscriptionPeriod ?? "00"}년 ${data.housingSubscriptionPaymentCount ?? "00"}회 납입, ${data.totalPaymentAmount === "2" ? "6000만원 이하" : "6000만원 이상"}` + : data.hasHousingSubscriptionSavings === "2" + ? "해당 없음" + : "-"; + const marriageStr = + data.isNewlyMarried === true + ? `기혼, 기간 ${data.marriagePeriod ?? "00"}년` + : data.marriageStatus === "1" + ? "예정" + : "-"; + const childrenStr = data.childrenInfo + ? `${data.childrenInfo.under6 + data.childrenInfo.over7}명, 대리 양육 가정 외` + : data.hasRegisteredChildren === "1" + ? "있음" + : "-"; + const householdStr = + [ + data.householdRole ? formatHouseholdRole(data.householdRole) : null, + data.householdComposition ? formatHouseholdComposition(data.householdComposition) : null, + ] + .filter(Boolean) + .join(", ") || "-"; + const housingStr = + data.hasOwnHousing === "2" || data.householdHousingOwnershipStatus === "2" + ? "무주택가구" + : formatHousing(data.hasOwnHousing); + const carStr = formatCar(data.hasHouseholdCar ?? data.hasCar); + const assetStr = + data.isTotalAssetUnder337Million === "1" || data.isHouseholdTotalAssetUnder337Million === "1" + ? "3억 3천 7백만원 이하" + : data.financialAssetValue || data.householdFinancialAssetValue + ? "해당" + : "-"; + + return [ + { label: "성별/나이", value: `${genderStr}, ${ageStr}` }, + { label: "소득", value: incomeStr }, + { label: "대학생 여부", value: studentStr }, + { label: "청약저축", value: subscriptionStr }, + { label: "결혼 여부", value: marriageStr }, + { label: "자녀 여부", value: childrenStr }, + { label: "세대 정보", value: householdStr }, + { label: "주택 소유 여부", value: housingStr }, + { label: "자동차 소유 여부", value: carStr }, + { label: "총자산", value: assetStr }, + ]; +} diff --git a/src/features/eligibility/model/index.ts b/src/features/eligibility/model/index.ts index ab98a8e..f7ec0b0 100644 --- a/src/features/eligibility/model/index.ts +++ b/src/features/eligibility/model/index.ts @@ -1,2 +1,14 @@ export { eligibilityContentMap, ELIGIBILITY_STEPS } from "./eligibilityContentMap"; +export { + ELIGIBILITY_LOADING_SUBTITLE_LINE1, + ELIGIBILITY_LOADING_SUBTITLE_LINE2, + ELIGIBILITY_LOADING_TITLE, + ELIGIBILITY_RESULT_BANNER_SUBTITLE, + ELIGIBILITY_RESULT_BUTTON, + ELIGIBILITY_RESULT_PAGE_TITLE, +} from "./eligibilityConstants"; export type { EligibilityStepContent } from "./eligibilityContentMap"; +export { + getEligibilityResultListItems, + type EligibilityResultListItem, +} from "./eligibilityResultModel"; diff --git a/src/features/eligibility/ui/common/eligibilityComponentRenderer.tsx b/src/features/eligibility/ui/common/eligibilityComponentRenderer.tsx index db5e96c..931e906 100644 --- a/src/features/eligibility/ui/common/eligibilityComponentRenderer.tsx +++ b/src/features/eligibility/ui/common/eligibilityComponentRenderer.tsx @@ -9,7 +9,7 @@ import { EligibilityOptionSelector } from "./eligibilityOptionSelector"; import { EligibilitySelect } from "./eligibilitySelect"; import { EligibilityPriceInput } from "./eligibilityPriceInput"; import { EligibilityNumberInputList } from "./eligibilityNumberInputList"; -import { EligibilityInfoButton } from "./eligibilityInfoButton"; +import { EligibilityInfoButtonWithSheet } from "./eligibilityInfoButtonWithSheet"; import { DatePicker } from "@/src/shared/ui/datePicker/datePicker"; import { Checkbox } from "@/src/shared/lib/headlessUi/checkBox/checkbox"; import { motion, AnimatePresence } from "framer-motion"; @@ -296,17 +296,21 @@ export const EligibilityComponentRenderer = ({ config }: EligibilityComponentRen } case "infoButton": { - // action prop이 있으면 동적으로 핸들러 생성 + // sheetContentType이 있으면 클릭 시 바텀시트, 없으면 onClick/action으로 라우팅 + const sheetContentType = config.props.sheetContentType; let onClick = config.props.onClick; - if (config.props.action === "home") { - onClick = () => router.push("/home"); - } else if (config.props.action === "back") { - onClick = () => router.back(); + if (!sheetContentType) { + if (config.props.action === "home") { + onClick = () => router.push("/home"); + } else if (config.props.action === "back") { + onClick = () => router.back(); + } } return ( - diff --git a/src/features/eligibility/ui/common/eligibilityInfoButton.tsx b/src/features/eligibility/ui/common/eligibilityInfoButton.tsx deleted file mode 100644 index e3b1efa..0000000 --- a/src/features/eligibility/ui/common/eligibilityInfoButton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { Button } from "@/src/shared/lib/headlessUi/button/button"; -import { ChevronRight } from "lucide-react"; -import { InfoButoonImg } from "@/src/assets/images/eligibility/InfoButoonImg"; - -export interface EligibilityInfoButtonProps { - /** 버튼 텍스트 (길이에 따라 자동으로 2줄로 표시) */ - text: string; - /** 클릭 핸들러 */ - onClick?: () => void; - /** 추가 클래스명 */ - className?: string; -} - -export const EligibilityInfoButton = ({ text, onClick, className }: EligibilityInfoButtonProps) => { - return ( - - ); -}; diff --git a/src/features/eligibility/ui/common/eligibilityInfoButtonWithSheet.tsx b/src/features/eligibility/ui/common/eligibilityInfoButtonWithSheet.tsx new file mode 100644 index 0000000..dc4fe8f --- /dev/null +++ b/src/features/eligibility/ui/common/eligibilityInfoButtonWithSheet.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import { cn } from "@/lib/utils"; +import { useMobileSheetPortal } from "@/src/shared/context/mobileSheetPortalContext"; +import { Button } from "@/src/shared/lib/headlessUi/button/button"; +import { + BottomSheet, + BottomSheetContent, + BottomSheetTitle, +} from "@/src/shared/lib/headlessUi"; +import { ChevronRight } from "lucide-react"; +import { InfoButoonImg } from "@/src/assets/images/eligibility/InfoButoonImg"; + +/** 바텀시트에 표시할 콘텐츠 타입 (타입별 제목·이미지) */ +export type EligibilityInfoSheetContentType = "asset" | "car" | "house"; + +const INFO_SHEET_CONFIG: Record< + EligibilityInfoSheetContentType, + { title: string; imageSrc: string } +> = { + asset: { + title: "총자산 계산법 한눈에 보기", + imageSrc: "/info/info_asset.png", + }, + car: { + title: "자동차 기준 한눈에 보기", + imageSrc: "/info/info_car.png", + }, + house: { + title: "세대주,세대원 기준 한눈에 보기", + imageSrc: "/info/info_house.png", + }, +}; + +export interface EligibilityInfoButtonWithSheetProps { + /** 버튼 텍스트 */ + text: string; + /** 바텀시트 콘텐츠 타입. 있으면 클릭 시 시트 오픈, 없으면 onClick 사용 */ + sheetContentType?: EligibilityInfoSheetContentType; + /** 시트를 쓰지 않을 때 클릭 핸들러 (action: home/back 또는 커스텀) */ + onClick?: () => void; + /** 추가 클래스명 */ + className?: string; +} + +export const EligibilityInfoButtonWithSheet = ({ + text, + sheetContentType, + onClick, + className, +}: EligibilityInfoButtonWithSheetProps) => { + const [sheetOpen, setSheetOpen] = useState(false); + const portalRef = useMobileSheetPortal(); + const container = portalRef?.current ?? undefined; + const scrollRef = useRef(null); + + useEffect(() => { + if (sheetOpen) { + scrollRef.current?.scrollTo(0, 0); + } + }, [sheetOpen]); + + const handleClick = () => { + if (sheetContentType) { + setSheetOpen(true); + } else { + onClick?.(); + } + }; + + const config = sheetContentType ? INFO_SHEET_CONFIG[sheetContentType] : null; + + return ( + <> + + + {config && ( + + + {config.title} +
+

+ {config.title} +

+
+ {config.title} +
+ +
+
+ +
+
+
+ )} + + ); +}; diff --git a/src/features/eligibility/ui/common/eligibilityLoadingState.tsx b/src/features/eligibility/ui/common/eligibilityLoadingState.tsx new file mode 100644 index 0000000..8e66b4b --- /dev/null +++ b/src/features/eligibility/ui/common/eligibilityLoadingState.tsx @@ -0,0 +1,28 @@ +import EligibilityLoadingImg from "@/src/assets/images/eligibility/eligibilityLoadingImg"; +import { + ELIGIBILITY_LOADING_SUBTITLE_LINE1, + ELIGIBILITY_LOADING_SUBTITLE_LINE2, + ELIGIBILITY_LOADING_TITLE, +} from "@/src/features/eligibility/model/eligibilityConstants"; + +const EligibilityLoadingState = () => { + return ( +
+
+ +
+

+ {ELIGIBILITY_LOADING_TITLE} +

+

+ {ELIGIBILITY_LOADING_SUBTITLE_LINE1} +
+ {ELIGIBILITY_LOADING_SUBTITLE_LINE2} +

+
+
+
+ ); +}; + +export default EligibilityLoadingState; diff --git a/src/features/eligibility/ui/common/index.ts b/src/features/eligibility/ui/common/index.ts index 48b8183..ecaa49d 100644 --- a/src/features/eligibility/ui/common/index.ts +++ b/src/features/eligibility/ui/common/index.ts @@ -8,8 +8,11 @@ export type { export { EligibilityHelpButton } from "./eligibilityHelpButton"; export type { EligibilityHelpButtonProps } from "./eligibilityHelpButton"; -export { EligibilityInfoButton } from "./eligibilityInfoButton"; -export type { EligibilityInfoButtonProps } from "./eligibilityInfoButton"; +export { EligibilityInfoButtonWithSheet } from "./eligibilityInfoButtonWithSheet"; +export type { + EligibilityInfoButtonWithSheetProps, + EligibilityInfoSheetContentType, +} from "./eligibilityInfoButtonWithSheet"; export { EligibilityPriceInput } from "./eligibilityPriceInput"; export type { EligibilityPriceInputProps } from "./eligibilityPriceInput"; diff --git a/src/features/eligibility/ui/eligibilityNextButton.tsx b/src/features/eligibility/ui/eligibilityNextButton.tsx index ced9872..786d412 100644 --- a/src/features/eligibility/ui/eligibilityNextButton.tsx +++ b/src/features/eligibility/ui/eligibilityNextButton.tsx @@ -70,14 +70,9 @@ export const EligibilityNextButton = () => { } return; } - // diagnosisEnd에서 다음 클릭 시 홈으로 이동 - if (currentStepId === "diagnosisEnd" && isLastStep) { - router.push("/home"); - return; - } - // 마지막 단계인 경우 진단종료 페이지로 이동 + // 마지막 단계인 경우 입력 정보 확인(결과) 페이지로 이동 if (isLastStep) { - router.push("/eligibility?step=diagnosisEnd"); + router.push("/eligibility/result"); return; } @@ -95,7 +90,7 @@ export const EligibilityNextButton = () => { onClick={handleClick} disabled={isDisabled} > - {currentStepId === "diagnosisEnd" ? "홈으로 이동하기" : "다음"} + {isLastStep ? "결과 확인" : "다음"} ); }; diff --git a/src/features/eligibility/ui/index.ts b/src/features/eligibility/ui/index.ts index 81abb22..316cbcb 100644 --- a/src/features/eligibility/ui/index.ts +++ b/src/features/eligibility/ui/index.ts @@ -1,2 +1,3 @@ export * from "./common"; export { EligibilityNextButton } from "./eligibilityNextButton"; +export * from "./result"; diff --git a/src/features/eligibility/ui/result/diagnosisResultBanner.tsx b/src/features/eligibility/ui/result/diagnosisResultBanner.tsx new file mode 100644 index 0000000..567ba94 --- /dev/null +++ b/src/features/eligibility/ui/result/diagnosisResultBanner.tsx @@ -0,0 +1,63 @@ +"use client"; + +import ResultBannerImg from "@/src/assets/images/eligibility/resultBannerImg"; +import { cn } from "@/lib/utils"; + +export interface DiagnosisResultBannerProps { + /** 사용자 이름 */ + userName: string; + /** 소득 구간 (예: "4구간" → "4분위"로 표시) */ + incomeLevel: string | null; + /** 추천 유형 요약 (예: ["청년 특별공급", "신혼부부 특별공급"] → "청년 특별공급, 신혼부부 특별공급 으로 신청이 가능합니다") */ + applicationTypeSummary: string[]; + className?: string; +} + +/** 소득 구간 문자열을 분위 표시로 변환 (예: "4구간" → "4분위") */ +function toIncomeBunwi(incomeLevel: string | null): string { + if (!incomeLevel) return "0분위"; + const match = incomeLevel.replace("구간", "").trim(); + return /^\d+$/.test(match) ? `${match}분위` : incomeLevel; +} + +export const DiagnosisResultBanner = ({ + userName, + incomeLevel, + applicationTypeSummary, + className, +}: DiagnosisResultBannerProps) => { + const bunwi = toIncomeBunwi(incomeLevel); + const summaryText = + applicationTypeSummary.length > 0 + ? `${applicationTypeSummary.join(", ")} 으로 신청이 가능합니다` + : "추천 매물이 있습니다"; + + return ( +
+
+ +
+
+

+ + {userName}님은
+
+ + 소득 {bunwi} + +

+

+ + {summaryText} + +

+
+
+ ); +}; diff --git a/src/features/eligibility/ui/result/diagnosisResultConstants.ts b/src/features/eligibility/ui/result/diagnosisResultConstants.ts new file mode 100644 index 0000000..10259fb --- /dev/null +++ b/src/features/eligibility/ui/result/diagnosisResultConstants.ts @@ -0,0 +1,31 @@ +/** 진단결과 임대주택 유형별 설명 (API recommended 파싱용 라벨과 매칭) */ +export const HOUSING_TYPE_DESCRIPTIONS: Record = { + 통합공공임대: + "저소득층, 젊은 층 및 장애인·국가유공자 등 사회 취약 계층 등의 주거안정을 목적으로 공급하는 공공임대주택", + 국민임대: + "저소득 무주택 세대의 주거 안정을 위해 국가와 지자체가 공급하는 국민임대주택", + 행복주택: + "청년·신혼부부·노인 등 주거 취약계층을 위한 공공 주거지원 주택", + 공공임대: + "국가·지자체 등이 건설하거나 매입하여 저소득층 등에 임대하는 공공임대주택", + 영구임대: + "저소득 무주택자에게 평생 거주권을 부여하는 영구임대주택", + 장기전세: + "전세금을 지원하여 장기간 거주할 수 있도록 하는 주택", + 매입임대: + "국가·지자체가 기존 주택을 매입하여 저소득층 등에 임대하는 주택", + 전세임대: + "전세 계약을 통해 저소득층 등에 임대하는 주택", +}; + +/** 진단결과 임대주택 유형별 태그 배경색 (tailwind 또는 임의 클래스) */ +export const HOUSING_TYPE_TAG_CLASS: Record = { + 통합공공임대: "bg-teal-100 text-teal-800", + 국민임대: "bg-amber-100 text-amber-800", + 행복주택: "bg-emerald-100 text-emerald-800", + 공공임대: "bg-amber-100 text-amber-800", + 영구임대: "bg-violet-100 text-violet-800", + 장기전세: "bg-rose-100 text-rose-800", + 매입임대: "bg-violet-100 text-violet-800", + 전세임대: "bg-orange-100 text-orange-800", +}; diff --git a/src/features/eligibility/ui/result/diagnosisResultItem.tsx b/src/features/eligibility/ui/result/diagnosisResultItem.tsx new file mode 100644 index 0000000..83906a4 --- /dev/null +++ b/src/features/eligibility/ui/result/diagnosisResultItem.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { HOUSING_TYPE_TAG_CLASS } from "./diagnosisResultConstants"; + +export interface DiagnosisResultItemProps { + /** 임대주택 유형 (예: "통합공공임대") */ + housingType: string; + /** 유형 설명 문단 */ + description: string; + /** 신청 가능 유형 목록 (예: ["일반 공급", "청년 특별공급"]) */ + applicationTypes: string[]; + className?: string; +} + +const DEFAULT_TAG_CLASS = "bg-greyscale-grey-100 text-greyscale-grey-700"; + +export const DiagnosisResultItem = ({ + housingType, + description, + applicationTypes, + className, +}: DiagnosisResultItemProps) => { + const tagClass = HOUSING_TYPE_TAG_CLASS[housingType] ?? DEFAULT_TAG_CLASS; + + return ( +
+
+ + {housingType} + +

+ {description} +

+
+
+ + 신청 가능 유형 + +
+ {applicationTypes.map((type, index) => ( + + {type} + + ))} +
+
+
+ ); +}; diff --git a/src/features/eligibility/ui/result/diagnosisResultList.tsx b/src/features/eligibility/ui/result/diagnosisResultList.tsx new file mode 100644 index 0000000..b35779d --- /dev/null +++ b/src/features/eligibility/ui/result/diagnosisResultList.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useMemo } from "react"; +import { DiagnosisResultItem } from "./diagnosisResultItem"; +import { HOUSING_TYPE_DESCRIPTIONS } from "./diagnosisResultConstants"; + +export interface DiagnosisResultListProps { + /** API recommended 배열 (예: ["통합공공임대 : 청년 특별공급", "통합공공임대 : 신혼부부 특별공급"]) */ + recommended: string[]; + className?: string; +} + +const SECTION_TITLE = "신청 가능한 임대주택"; + +/** "통합공공임대 : 청년 특별공급" 형태를 { housingType, applicationType } 로 파싱 */ +function parseRecommendedItem(item: string): { housingType: string; applicationType: string } { + const sep = " : "; + const idx = item.indexOf(sep); + if (idx === -1) { + return { housingType: item.trim(), applicationType: "" }; + } + return { + housingType: item.slice(0, idx).trim(), + applicationType: item.slice(idx + sep.length).trim(), + }; +} + +/** recommended 배열을 housingType 기준으로 묶어서, 유형별 신청 가능 유형 목록으로 변환 */ +function groupByHousingType( + recommended: string[] +): Array<{ housingType: string; applicationTypes: string[] }> { + const map = new Map>(); + for (const raw of recommended) { + const { housingType, applicationType } = parseRecommendedItem(raw); + if (!housingType) continue; + if (!map.has(housingType)) map.set(housingType, new Set()); + if (applicationType) map.get(housingType)!.add(applicationType); + } + return Array.from(map.entries()).map(([housingType, set]) => ({ + housingType, + applicationTypes: Array.from(set), + })); +} + +export const DiagnosisResultList = ({ recommended, className }: DiagnosisResultListProps) => { + const items = useMemo(() => groupByHousingType(recommended), [recommended]); + + if (items.length === 0) return null; + + return ( +
+

+ {SECTION_TITLE} +

+
    + {items.map(({ housingType, applicationTypes }) => ( +
  • + +
  • + ))} +
+
+ ); +}; diff --git a/src/features/eligibility/ui/result/eligibilityResultBanner.tsx b/src/features/eligibility/ui/result/eligibilityResultBanner.tsx new file mode 100644 index 0000000..7abbbe8 --- /dev/null +++ b/src/features/eligibility/ui/result/eligibilityResultBanner.tsx @@ -0,0 +1,30 @@ +"use client"; + +import ResultBannerImg from "@/src/assets/images/eligibility/resultBannerImg"; +import { + ELIGIBILITY_RESULT_BANNER_SUBTITLE, + ELIGIBILITY_RESULT_BANNER_TITLE, +} from "@/src/features/eligibility/model/eligibilityConstants"; + +export interface EligibilityResultBannerProps { + /** 진단 결과 문구에 넣을 사용자 이름 + * TODO: 추후 닉네임으로 API 변경 필요 - 백엔드에 추후 요청 필요 + */ + userName: string; +} + +export const EligibilityResultBanner = ({ userName }: EligibilityResultBannerProps) => { + return ( +
+ +
+

+ {ELIGIBILITY_RESULT_BANNER_TITLE(userName)} +

+

+ {ELIGIBILITY_RESULT_BANNER_SUBTITLE} +

+
+
+ ); +}; diff --git a/src/features/eligibility/ui/result/eligibilityResultList.tsx b/src/features/eligibility/ui/result/eligibilityResultList.tsx new file mode 100644 index 0000000..1433720 --- /dev/null +++ b/src/features/eligibility/ui/result/eligibilityResultList.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { EligibilityData } from "@/src/features/eligibility/model/eligibilityStore"; +import { getEligibilityResultListItems } from "@/src/features/eligibility/model/eligibilityResultModel"; + +export interface EligibilityResultListProps { + data: EligibilityData; +} + +export const EligibilityResultList = ({ data }: EligibilityResultListProps) => { + const items = getEligibilityResultListItems(data); + + return ( +
+
    + {items.map(({ label, value }) => ( +
  • + + {label} + + + {value} + +
  • + ))} +
+
+ ); +}; diff --git a/src/features/eligibility/ui/result/index.ts b/src/features/eligibility/ui/result/index.ts new file mode 100644 index 0000000..98561a7 --- /dev/null +++ b/src/features/eligibility/ui/result/index.ts @@ -0,0 +1,11 @@ +export { EligibilityResultBanner } from "./eligibilityResultBanner"; +export type { EligibilityResultBannerProps } from "./eligibilityResultBanner"; +export { EligibilityResultList } from "./eligibilityResultList"; +export type { EligibilityResultListProps } from "./eligibilityResultList"; + +export { DiagnosisResultBanner } from "./diagnosisResultBanner"; +export type { DiagnosisResultBannerProps } from "./diagnosisResultBanner"; +export { DiagnosisResultItem } from "./diagnosisResultItem"; +export type { DiagnosisResultItemProps } from "./diagnosisResultItem"; +export { DiagnosisResultList } from "./diagnosisResultList"; +export type { DiagnosisResultListProps } from "./diagnosisResultList"; diff --git a/src/features/home/index.ts b/src/features/home/index.ts index feecdcb..97d2772 100644 --- a/src/features/home/index.ts +++ b/src/features/home/index.ts @@ -1,5 +1,5 @@ export * from "./ui/homeContentsCard"; -export * from "./ui/homeActionCardList"; +export * from "./ui/homeAction/homeActionCardList"; export * from "./ui/homeContentsCard"; export * from "./ui/homeHeader"; export * from "./ui/homeHero"; diff --git a/src/features/home/model/homeStore.ts b/src/features/home/model/homeStore.ts index 4c53076..e2b9a3e 100644 --- a/src/features/home/model/homeStore.ts +++ b/src/features/home/model/homeStore.ts @@ -35,7 +35,7 @@ type HomeMaxSheet = { }; export const useHomeMaxTime = create(set => ({ - maxTime: 30, + maxTime: 60, setMaxTime: time => set({ maxTime: time }), reset: () => set({ maxTime: 30 }), })); diff --git a/src/features/home/ui/components/homeFullSheet.tsx b/src/features/home/ui/components/homeFullSheet.tsx index 4c86b30..a2215aa 100644 --- a/src/features/home/ui/components/homeFullSheet.tsx +++ b/src/features/home/ui/components/homeFullSheet.tsx @@ -2,27 +2,35 @@ import { AnimatePresence, motion } from "framer-motion"; import { useSearchParams } from "next/navigation"; -import { useHomeSheetStore } from "../../model/homeStore"; -import { PinpointRowBox } from "./pinpointRowBoxs"; -import { MaxTimeSliderBox } from "./maxTime"; +import { useRef } from "react"; +import { createPortal } from "react-dom"; import { usePinhouseRouter } from "@/src/features/home/hooks/hooks"; import { PinpointSelectedButton } from "@/src/features/home/ui/components/components/pinpointSelectedButton"; +import { usePortalTarget } from "@/src/shared/hooks/usePortalTarget"; +import { useScrollLock } from "@/src/shared/hooks/useScrollLock"; +import { useHomeSheetStore } from "../../model/homeStore"; +import { MaxTimeSliderBox } from "./maxTime"; +import { PinpointRowBox } from "./pinpointRowBoxs"; import type { ReadonlyURLSearchParams } from "next/navigation"; export const HomeSheet = () => { const open = useHomeSheetStore(s => s.open); const searchParams = useSearchParams(); + const anchorRef = useRef(null); + const portalRoot = usePortalTarget("mobile-overlay-root"); const { replaceRouter, handleSetPinpoint, mode } = usePinhouseRouter( searchParams as ReadonlyURLSearchParams ); - return ( + useScrollLock({ locked: open, anchorRef }); + + const content = ( {open && ( <> { /> -

{mode?.label}

@@ -49,7 +56,7 @@ export const HomeSheet = () => { animate={{ x: 0, opacity: 1 }} exit={{ x: -100, opacity: 0 }} transition={{ duration: 0.5, ease: "easeInOut" }} - className="flex h-full flex-col justify-between" + className="z-11 flex h-full flex-col justify-between" > {mode?.key === "pinpoints" && } {mode?.key === "maxTime" && } @@ -67,4 +74,11 @@ export const HomeSheet = () => { )} ); + + return ( + <> + + {portalRoot ? createPortal(content, portalRoot) : content} + + ); }; diff --git a/src/features/home/ui/components/maxTime.tsx b/src/features/home/ui/components/maxTime.tsx index 1ff8d4a..613b341 100644 --- a/src/features/home/ui/components/maxTime.tsx +++ b/src/features/home/ui/components/maxTime.tsx @@ -22,6 +22,7 @@ export const MaxTimeSliderBox = () => { { + const { onListingsPageMove, onEligibilityPageMove } = useHomeActionCard(); + + return ( +
+ + +
+ ); +}; diff --git a/src/features/home/ui/homeAction/pinpointStandard.tsx b/src/features/home/ui/homeAction/pinpointStandard.tsx new file mode 100644 index 0000000..a010d51 --- /dev/null +++ b/src/features/home/ui/homeAction/pinpointStandard.tsx @@ -0,0 +1,34 @@ +import { ArrowUpRight } from "@/src/assets/icons/button/arrowUpRight"; +import { useNoticeCount } from "@/src/entities/home/hooks/homeHooks"; +import { useOAuthStore } from "@/src/features/login/model"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { SliceResponse } from "@/src/entities/home/model/type"; +import { ListingItem } from "@/src/entities/listings/model/type"; +import { getNoticeByPinPoint } from "@/src/entities/home/interface/homeInterface"; +import { HOME_RECOMMENDED_ENDPOINT } from "@/src/shared/api"; + +type PinpointStandardProps = { + onListingsPageMove: () => void; +}; + +export const PinpointStandard = ({ onListingsPageMove }: PinpointStandardProps) => { + const { data } = useNoticeCount(); + const count = data?.count; + return ( +
+
+

+ 핀포인트 기준 +

+
+ +
+
+ +

{count}건

+
+ ); +}; diff --git a/src/features/home/ui/homeAction/qualificationdiagnosis.tsx b/src/features/home/ui/homeAction/qualificationdiagnosis.tsx new file mode 100644 index 0000000..cd93cd0 --- /dev/null +++ b/src/features/home/ui/homeAction/qualificationdiagnosis.tsx @@ -0,0 +1,40 @@ +import { ArrowUpRight } from "@/src/assets/icons/button/arrowUpRight"; +import { useRecommendedNotice } from "@/src/entities/home/hooks/homeHooks"; +import { useDiagnosisResultStore } from "@/src/features/eligibility/model/diagnosisResultStore"; + +type QualificationDiagnosisProps = { + onEligibilityPageMove: () => void; +}; +export const QualificationDiagnosis = ({ onEligibilityPageMove }: QualificationDiagnosisProps) => { + const { data: recommend } = useRecommendedNotice(); + const count = recommend?.pages[0]?.totalCount; + const hasDiagnosisResult = useDiagnosisResultStore(state => state.result != null); + + return ( +
+
+

+ 자격진단 기준 +

+ +
+ +
+
+ +
+

{count ?? 0}건

+ +

{hasDiagnosisResult ? "100% 완료" : "0% 완료"}

+
+
+
+ ); +}; diff --git a/src/features/home/ui/homeActionCardList.tsx b/src/features/home/ui/homeActionCardList.tsx deleted file mode 100644 index 510cbc6..0000000 --- a/src/features/home/ui/homeActionCardList.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; -import { ArrowUpRight } from "@/src/assets/icons/button/arrowUpRight"; -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 { data: recommend } = useRecommendedNotice(); - - const count = data?.count; - const { onListingsPageMove, onEligibilityPageMove } = useHomeActionCard(); - - return ( -
-
-
-

- 핀포인트 기준 -

-
- -
-
- -

{count}건

-
- -
-
-

- 자격진단 기준 -

- -
- -
-
- -
-

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

- -

0% 완료

-
-
-
-
- ); -}; diff --git a/src/features/home/ui/homeQuickStatsList.tsx b/src/features/home/ui/homeQuickStatsList.tsx index bcf3524..4280d47 100644 --- a/src/features/home/ui/homeQuickStatsList.tsx +++ b/src/features/home/ui/homeQuickStatsList.tsx @@ -2,16 +2,13 @@ import { CaretDown } from "@/src/assets/icons/button/caretDown"; import { HomeFiveoclock } from "@/src/assets/icons/home/HomeFiveoclock"; import { HomePushPin } from "@/src/assets/icons/home/homePushpin"; -import { useHomeMaxTime, useHomeSheetStore } from "../model/homeStore"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useOAuthStore } from "../../login/model"; -import { splitAddress, transTime } from "@/src/shared/lib/utils"; +import { useHomeMaxTime } from "../model/homeStore"; +import { transTime } from "@/src/shared/lib/utils"; import { useHomeUseHooks } from "@/src/features/home/ui/homeUseHooks/homeUseHooks"; export const QuickStatsList = () => { - const { maxTime } = useHomeMaxTime(); - const {line2, line1 , onSelectSection} = useHomeUseHooks(); + const { line2, line1, onSelectSection } = useHomeUseHooks(); return (
diff --git a/src/features/home/ui/homeUrgentNoticeList.tsx b/src/features/home/ui/homeUrgentNoticeList.tsx index dd64225..f73b259 100644 --- a/src/features/home/ui/homeUrgentNoticeList.tsx +++ b/src/features/home/ui/homeUrgentNoticeList.tsx @@ -34,7 +34,7 @@ export const UrgentNoticeList = () => {

전체보기 diff --git a/src/features/home/ui/search/homeSearchPopuler.tsx b/src/features/home/ui/search/homeSearchPopuler.tsx index f84272f..d40f522 100644 --- a/src/features/home/ui/search/homeSearchPopuler.tsx +++ b/src/features/home/ui/search/homeSearchPopuler.tsx @@ -24,7 +24,7 @@ export const HomeSearchPopuler = () => { size="sm" onClick={() => handleSearchTag(word.keyword)} className={cn( - "font-suit text-text-greyscale-grey-85 rounded-full border px-3 py-1 text-sm transition-all" + "font-suit text-text-greyscale-grey-85 rounded-full border px-3 py-1 text-sm transition-all hover:border-none hover:bg-primary-blue-300 hover:text-gray-200" )} > {word.keyword} diff --git a/src/features/listings/hooks/listingsHooks.tsx b/src/features/listings/hooks/listingsHooks.tsx index 9d72bf0..e1b40c0 100644 --- a/src/features/listings/hooks/listingsHooks.tsx +++ b/src/features/listings/hooks/listingsHooks.tsx @@ -37,9 +37,9 @@ const normalizeRentType = (rentType: string) => { }; export const getListingIcon = (type: string, housingType: string, size = 78) => { - const Nyear = normalizeRentType(type); + const Near = normalizeRentType(type); - const IconComp = LISTING_ICON_MAP[Nyear]?.[housingType]; + const IconComp = LISTING_ICON_MAP[Near]?.[housingType]; if (!IconComp) return null; return ; @@ -160,14 +160,15 @@ export const HouseICons = (item: ListingNormalized) => { type HouseRentalProps = ListingNormalized & { query: "listingListInfinite" | "listingSearchInfinite" | "notice"; }; -// ListingNormalized +// ListingNormalized export const HouseRental = ({ query, ...item }: HouseRentalProps) => { - const rantalText = getListingsRental(item.type); - if (!rantalText) return null; + const Near = normalizeRentType(item.type); + const rentalText = getListingsRental(Near); + if (!rentalText) return null; return ( - + ); diff --git a/src/features/listings/ui/listingsCardDetail/button/button.tsx b/src/features/listings/ui/listingsCardDetail/button/button.tsx new file mode 100644 index 0000000..0d419aa --- /dev/null +++ b/src/features/listings/ui/listingsCardDetail/button/button.tsx @@ -0,0 +1,22 @@ +import { useDetailFilterResultButton } from "@/src/features/listings/ui/listingsCardDetail/hooks/routerHooks"; + +type ListingCardDetailProps = { + filteredCount: number; + handleCloseSheet: () => void; +}; +export const ListingCardDetailOut = ({ + filteredCount, + handleCloseSheet, +}: ListingCardDetailProps) => { + return ( +
+ +
+ ); +}; diff --git a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DetailFilterSheet.tsx b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DetailFilterSheet.tsx index d32d76f..2c5cca9 100644 --- a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DetailFilterSheet.tsx +++ b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DetailFilterSheet.tsx @@ -1,68 +1,93 @@ "use client"; - import { AnimatePresence, motion } from "framer-motion"; import { useSearchParams } from "next/navigation"; +import { useRef } from "react"; +import { createPortal } from "react-dom"; import { useDetailFilterSheetStore } from "@/src/features/listings/model"; +import { useScrollLock } from "@/src/shared/hooks/useScrollLock"; +import { usePortalTarget } from "@/src/shared/hooks/usePortalTarget"; import { DetailFilterTab } from "./DetailFilterTab"; import { parseDetailSection } from "@/src/features/listings/model"; import { DistanceFilter } from "./DistanceFilter"; import { CostFilter } from "./components/CostFilter"; import { RegionFilter } from "./components/regionFilter"; import { AreaFilter } from "./components/areaFilter"; +import { ListingCardDetailOut } from "@/src/features/listings/ui/listingsCardDetail/button/button"; +import { useDetailFilterResultButton } from "@/src/features/listings/ui/listingsCardDetail/hooks/routerHooks"; export const DetailFilterSheet = () => { const open = useDetailFilterSheetStore(s => s.open); - const closeSheet = useDetailFilterSheetStore(s => s.closeSheet); const searchParams = useSearchParams(); + const anchorRef = useRef(null); + const portalRoot = usePortalTarget("mobile-overlay-root"); const section = parseDetailSection(searchParams); + useScrollLock({ locked: open, anchorRef }); + const { filteredCount, handleCloseSheet } = useDetailFilterResultButton(); - return ( + const content = ( {open && ( <> { + e.stopPropagation(); + handleCloseSheet(); + }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} /> e.stopPropagation()} transition={{ type: "spring", stiffness: 260, damping: 30 }} >

단지 필터

- +
-
- + +
{section === "distance" && } {section === "cost" && } {section === "region" && } {section === "area" && } {/* {section === "around" && } */} - -
+
+
+ +
+ )} ); + + return ( + <> + + {portalRoot ? createPortal(content, portalRoot) : content} + + ); }; diff --git a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DistanceFilter.tsx b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DistanceFilter.tsx index 149cce2..fe7db87 100644 --- a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DistanceFilter.tsx +++ b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DistanceFilter.tsx @@ -8,34 +8,23 @@ import { getDefaultPinPointLabel, mapPinPointToOptions, } from "@/src/features/listings/hooks/listingsHooks"; +import { ListingCardDetailOut } from "@/src/features/listings/ui/listingsCardDetail/button/button"; +import { + useDistanceHooks, + useDistanceVariable, +} from "@/src/features/listings/ui/listingsCardDetail/hooks/distanceHooks"; const SLIDER_MIN = 0; const SLIDER_MAX = 120; export const DistanceFilter = () => { const { data, isFetching } = useListingFilterDetail(); - const pinPointData = data?.pinPoints; - const pinPointList = mapPinPointToOptions(pinPointData); - const dropDownTriggerLabel = getDefaultPinPointLabel(pinPointList); - const hasPinPoints = pinPointList.myPinPoint.length > 0; - const { setPinPointId } = useOAuthStore(); - const { distance, setDistance } = useListingDetailFilter(); - const { filteredCount } = useListingDetailCountStore(); - - const onChageValue = (selectedKey: string) => { - setPinPointId(selectedKey); - }; - - const handleDistanceChange = (values: number[]) => { - const [nextValue] = values; - if (typeof nextValue === "number") { - setDistance(nextValue); - } - }; - - const sliderValue = [distance]; - const formatMinutes = (value: number) => value.toString().padStart(1, "0"); - const formattedDistance = formatMinutes(distance); + const emptyPinPoint: PinPointPlace = { userName: "", pinPoints: [] }; + const { pinPointList, dropDownTriggerLabel, hasPinPoints } = useDistanceVariable( + data ?? emptyPinPoint + ); + const { onChangeValue, handleDistanceChange, sliderValue, formattedDistance } = + useDistanceHooks(); return (
@@ -53,7 +42,7 @@ export const DistanceFilter = () => { types="myPinPoint" data={pinPointList} size="lg" - onChange={onChageValue} + onChange={onChangeValue} disabled={isFetching || !hasPinPoints} > {dropDownTriggerLabel} @@ -74,15 +63,6 @@ export const DistanceFilter = () => { labelSuffix="분" /> - -
- -
); }; diff --git a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/CostFilter.tsx b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/CostFilter.tsx index 39e7004..5a58865 100644 --- a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/CostFilter.tsx +++ b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/CostFilter.tsx @@ -1,132 +1,31 @@ "use client"; - -import { useEffect, useLayoutEffect, useRef, useState, type ChangeEvent } from "react"; import { Checkbox } from "@/src/shared/lib/headlessUi/checkBox/checkbox"; import { Input } from "@/src/shared/ui/input/deafult"; import { HistogramSlider } from "./HistogramSlider"; -import { useParams } from "next/navigation"; -import { useListingDetailNoticeSheet } from "@/src/entities/listings/hooks/useListingDetailSheetHooks"; -import { CostResponse } from "@/src/entities/listings/model/type"; -import { useListingDetailCountStore, useListingDetailFilter } from "@/src/features/listings/model"; - -const DEPOSIT_STEP = 10; -const WON_UNIT = 1; -const GAP = 2; -const BAR_COUNT = 21; -const MAX_INDEX = BAR_COUNT - 1; - -export const HISTOGRAM_VALUES = [ - 10, 13, 15, 16, 17, 15, 14, 13, 14, 15, 16, 17, 18, 15, 12, 10, 14, 13, 12, 11, 10, -]; - -const formatNumber = (value: number) => { - const normalized = Number.isFinite(value) ? value : 0; - return Math.round(normalized).toLocaleString("ko-KR"); -}; -const toKRW = (valueInMan: number) => valueInMan * WON_UNIT; +import { useCostFilter } from "@/src/features/listings/ui/listingsCardDetail/hooks/costHooks"; export const CostFilter = () => { - const [activeIndex, setActiveIndex] = useState(DEPOSIT_STEP); - const { id } = useParams() as { id: string }; - const { data } = useListingDetailNoticeSheet({ - id: id, - url: "cost", - }); - const DEPOSIT_MIN = data?.minPrice ?? 0; - const DEPOSIT_MAX = data?.maxPrice ?? 0; - const HISTOGRAM_MIN = formatNumber(toKRW(DEPOSIT_MIN)); - const HISTOGRAM_MAX = formatNumber(toKRW(DEPOSIT_MAX)); - const AVG_COST = data?.avgPrice ?? 0; - const [isManualDeposit, setIsManualDeposit] = useState(false); - const { setMaxDeposit, maxDeposit, maxMonthPay, setMaxMonthPay } = useListingDetailFilter(); - const [handleDepositInput, setHandleDepositInput] = useState("0"); - const [deposit, setDeposit] = useState("0"); - const { filteredCount } = useListingDetailCountStore(); - - // 슬라이더 인덱스를 가격 범위에 맞춰 실제 보증금 값으로 변환 - const getDepositByIndex = (index: number) => { - if (DEPOSIT_MAX <= DEPOSIT_MIN) return DEPOSIT_MIN; - const step = (DEPOSIT_MAX - DEPOSIT_MIN) / MAX_INDEX; - return Math.round(DEPOSIT_MIN + step * index); - }; - - // 보증금 값을 현재 범위에 맞는 슬라이더 인덱스로 역변환 - const getIndexByDepositValue = (value: number) => { - if (DEPOSIT_MAX <= DEPOSIT_MIN) return 0; - const ratio = (value - DEPOSIT_MIN) / (DEPOSIT_MAX - DEPOSIT_MIN); - const clamped = Math.min(1, Math.max(0, ratio)); - return Math.round(clamped * MAX_INDEX); - }; - - const sliderRef = useRef(null); - const [containerWidth, setContainerWidth] = useState(0); - const maxValue = Math.max(...HISTOGRAM_VALUES); - const normalized = HISTOGRAM_VALUES.map(v => (v / maxValue) * 100); - const barCount = normalized.length; - - // 히스토그램 컨테이너 폭 변화에 맞춰 막대 폭/위치 재계산 - useLayoutEffect(() => { - if (!sliderRef.current) return; - const observer = new ResizeObserver(entries => { - setContainerWidth(entries[0].contentRect.width); - }); - observer.observe(sliderRef.current); - return () => observer.disconnect(); - }, []); - - // 전체 gap/막대 폭 계산 후 슬라이더 핸들의 픽셀/퍼센트 위치 산출 - const totalGap = GAP * (barCount - 1); - const barWidth = barCount ? Math.max(0, (containerWidth - totalGap) / barCount) : 0; - const handleLeftPx = barWidth * activeIndex + GAP * activeIndex + barWidth / 2; - const handleLeftPct = containerWidth ? (handleLeftPx / containerWidth) * 100 : 0; - const maxlength = HISTOGRAM_VALUES.length - 1; - - // API 데이터가 도착하면 평균값을 기준으로 슬라이더·입력 초기화 - useEffect(() => { - if (!data) return; - const baseDeposit = data.avgPrice ?? data.minPrice ?? 0; - const formatted = formatNumber(toKRW(baseDeposit)); - setDeposit(formatted); - setActiveIndex(getIndexByDepositValue(baseDeposit)); - }, [AVG_COST, DEPOSIT_MIN, DEPOSIT_MAX, data]); - - useEffect(() => { - if (!isManualDeposit) { - setMaxDeposit(deposit); - } else { - setMaxDeposit(handleDepositInput); - } - }, [deposit, handleDepositInput]); - - // 슬라이더 인덱스를 실 보증금으로 변환 - const handleDepositChange = (value: string) => { - const index = Number(value); - if (Number.isNaN(index)) return; - setActiveIndex(index); - const depositValue = getDepositByIndex(index); - setDeposit(formatNumber(toKRW(depositValue))); - }; - - // 직접 입력 시 숫자만 추려서 포맷 - const handleDepositChangeText = (event: ChangeEvent) => { - const values = event.target.value; - const numericValue = Number(values.replace(/[^0-9]/g, "")); - setHandleDepositInput(formatNumber(toKRW(numericValue))); - }; - - const handleManualDepositChange = (event: ChangeEvent) => { - const rawValue = event.target.value; - const numericValue = Number(rawValue.replace(/[^0-9]/g, "")); - setMaxMonthPay(rawValue === "" ? "" : formatNumber(numericValue)); - }; - - const handleManualToggle = (checked: boolean | "indeterminate") => { - const nextValue = checked === true; - setIsManualDeposit(nextValue); - }; + const { + avgCostLabel, + histogramMaxLabel, + histogramMinLabel, + isManualDeposit, + maxDeposit, + maxMonthPay, + activeIndex, + deposit, + normalized, + handleLeftPct, + maxlength, + sliderRef, + handleDepositChange, + handleDepositChangeText, + handleManualDepositChange, + handleManualToggle, + } = useCostFilter(); return ( -
+

@@ -134,18 +33,15 @@ export const CostFilter = () => {

이 공고의 평균 보증금은{" "} - - {data ? `${formatNumber(toKRW(AVG_COST))}만원` : "정보 없음"} - {" "} - 입니다. + {avgCostLabel} 입니다.

{ {
- -
- -
); }; diff --git a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/areaFilter.tsx b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/areaFilter.tsx index 4ec727d..63f5e2d 100644 --- a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/areaFilter.tsx +++ b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/areaFilter.tsx @@ -10,6 +10,7 @@ import { Checkbox } from "@/src/shared/lib/headlessUi/checkBox/checkbox"; import { TagButton } from "@/src/shared/ui/button/tagButton"; import { Spinner } from "@/src/shared/ui/spinner/default"; import { useParams } from "next/navigation"; +import { ListingCardDetailOut } from "@/src/features/listings/ui/listingsCardDetail/button/button"; export const AreaFilter = () => { const { id } = useParams() as { id: string }; @@ -56,14 +57,7 @@ export const AreaFilter = () => {
))}
-
- -
+
); }; diff --git a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/regionFilter.tsx b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/regionFilter.tsx index 4288e9f..3dfd626 100644 --- a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/regionFilter.tsx +++ b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/components/regionFilter.tsx @@ -1,22 +1,15 @@ -import { cn } from "@/lib/utils"; import { useListingDetailNoticeSheet } from "@/src/entities/listings/hooks/useListingDetailSheetHooks"; import { DistrictResponse } from "@/src/entities/listings/model/type"; -import { - REGION_CHECKBOX, - useListingDetailCountStore, - useListingDetailFilter, -} from "@/src/features/listings/model"; +import { REGION_CHECKBOX, useListingDetailFilter } from "@/src/features/listings/model"; import { Checkbox } from "@/src/shared/lib/headlessUi/checkBox/checkbox"; -import { TagButton } from "@/src/shared/ui/button/tagButton"; import { Spinner } from "@/src/shared/ui/spinner/default"; import { useParams } from "next/navigation"; -import { useState } from "react"; +import { Tag } from "@/src/features/listings/ui/listingsCardDetail/hooks/regionHooks"; export const RegionFilter = () => { const { id } = useParams() as { id: string }; const regionType = useListingDetailFilter(state => state.region); const setRegion = useListingDetailFilter(state => state.toggleRegionType); - const { filteredCount } = useListingDetailCountStore(); const { data } = useListingDetailNoticeSheet({ id: id, url: "districts", @@ -57,42 +50,6 @@ export const RegionFilter = () => {
))}
-
- -
); }; - -const Tag = ({ - label, - selected, - onClick, -}: { - label: string; - selected: boolean; - onClick: () => void; -}) => { - console.log(selected); - return ( - <> - - {label} - - - ); -}; diff --git a/src/features/listings/ui/listingsCardDetail/components/listingsCardDetailOutOfCriteriaSection.tsx b/src/features/listings/ui/listingsCardDetail/components/listingsCardDetailOutOfCriteriaSection.tsx index e09ee4a..ea7d366 100644 --- a/src/features/listings/ui/listingsCardDetail/components/listingsCardDetailOutOfCriteriaSection.tsx +++ b/src/features/listings/ui/listingsCardDetail/components/listingsCardDetailOutOfCriteriaSection.tsx @@ -2,11 +2,13 @@ import { ListingsCardTile } from "./listingsCardTile"; import { ComplexList } from "@/src/entities/listings/model/type"; type ListingsCardDetailOutOfCriteriaSectionProps = { - listings: ComplexList; + listings: ComplexList, + className?: string }; export const ListingsCardDetailOutOfCriteriaSection = ({ - listings, + listings, + className, }: ListingsCardDetailOutOfCriteriaSectionProps) => { if (listings.complexes.length === 0) return; return ( diff --git a/src/features/listings/ui/listingsCardDetail/components/listingsCardDetailSummary.tsx b/src/features/listings/ui/listingsCardDetail/components/listingsCardDetailSummary.tsx index 8d6e250..04cbb3e 100644 --- a/src/features/listings/ui/listingsCardDetail/components/listingsCardDetailSummary.tsx +++ b/src/features/listings/ui/listingsCardDetail/components/listingsCardDetailSummary.tsx @@ -3,9 +3,11 @@ import { TagButton } from "@/src/shared/ui/button/tagButton"; import { cn } from "@/lib/utils"; export const ListingsCardDetailSummary = ({ - basicInfo, -}: { - basicInfo: ListingDetailResponseWithColor["data"]["basicInfo"]; + basicInfo, + className, + }: { + basicInfo: ListingDetailResponseWithColor["data"]["basicInfo"], + className?: string }) => { return (
diff --git a/src/features/listings/ui/listingsCardDetail/hooks/costHooks.ts b/src/features/listings/ui/listingsCardDetail/hooks/costHooks.ts new file mode 100644 index 0000000..3cbc0bb --- /dev/null +++ b/src/features/listings/ui/listingsCardDetail/hooks/costHooks.ts @@ -0,0 +1,169 @@ +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useCallback, + ChangeEvent, +} from "react"; +import { useParams } from "next/navigation"; +import { useListingDetailNoticeSheet } from "@/src/entities/listings/hooks/useListingDetailSheetHooks"; +import { CostResponse } from "@/src/entities/listings/model/type"; +import { useListingDetailFilter } from "@/src/features/listings/model"; + +const DEPOSIT_STEP = 10; +const WON_UNIT = 1; +const GAP = 2; +const BAR_COUNT = 21; +const MAX_INDEX = BAR_COUNT - 1; + +export const HISTOGRAM_VALUES = [ + 10, 13, 15, 16, 17, 15, 14, 13, 14, 15, 16, 17, 18, 15, 12, 10, 14, 13, 12, 11, 10, +]; + +const formatNumber = (value: number) => { + const normalized = Number.isFinite(value) ? value : 0; + return Math.round(normalized).toLocaleString("ko-KR"); +}; + +const toKRW = (valueInMan: number) => valueInMan * WON_UNIT; + +export const useCostFilter = () => { + const [activeIndex, setActiveIndex] = useState(DEPOSIT_STEP); + const { id } = useParams() as { id: string }; + const { data } = useListingDetailNoticeSheet({ + id, + url: "cost", + }); + const DEPOSIT_MIN = data?.minPrice ?? 0; + const DEPOSIT_MAX = data?.maxPrice ?? 0; + const AVG_COST = data?.avgPrice ?? 0; + const [isManualDeposit, setIsManualDeposit] = useState(false); + const { setMaxDeposit, maxDeposit, maxMonthPay, setMaxMonthPay } = useListingDetailFilter(); + const [handleDepositInput, setHandleDepositInput] = useState("0"); + const [deposit, setDeposit] = useState("0"); + + const sliderRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + const normalized = useMemo(() => { + const maxValue = Math.max(...HISTOGRAM_VALUES); + return HISTOGRAM_VALUES.map(v => (v / maxValue) * 100); + }, []); + + const barCount = normalized.length; + const totalGap = GAP * (barCount - 1); + const barWidth = barCount ? Math.max(0, (containerWidth - totalGap) / barCount) : 0; + const handleLeftPx = barWidth * activeIndex + GAP * activeIndex + barWidth / 2; + const handleLeftPct = containerWidth ? (handleLeftPx / containerWidth) * 100 : 0; + const maxlength = HISTOGRAM_VALUES.length - 1; + + const histogramMinLabel = `${formatNumber(toKRW(DEPOSIT_MIN))} 만`; + const histogramMaxLabel = `${formatNumber(toKRW(DEPOSIT_MAX))} 만`; + const avgCostLabel = data ? `${formatNumber(toKRW(AVG_COST))}만원` : "정보 없음"; + + const getDepositByIndex = useCallback( + (index: number) => { + if (DEPOSIT_MAX <= DEPOSIT_MIN) return DEPOSIT_MIN; + const step = (DEPOSIT_MAX - DEPOSIT_MIN) / MAX_INDEX; + return Math.round(DEPOSIT_MIN + step * index); + }, + [DEPOSIT_MAX, DEPOSIT_MIN] + ); + + const getIndexByDepositValue = useCallback( + (value: number) => { + if (DEPOSIT_MAX <= DEPOSIT_MIN) return 0; + const ratio = (value - DEPOSIT_MIN) / (DEPOSIT_MAX - DEPOSIT_MIN); + const clamped = Math.min(1, Math.max(0, ratio)); + return Math.round(clamped * MAX_INDEX); + }, + [DEPOSIT_MAX, DEPOSIT_MIN] + ); + + useLayoutEffect(() => { + if (!sliderRef.current) return; + const observer = new ResizeObserver(entries => { + setContainerWidth(entries[0].contentRect.width); + }); + observer.observe(sliderRef.current); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!data) return; + const baseDeposit = data.avgPrice ?? data.minPrice ?? 0; + const formatted = formatNumber(toKRW(baseDeposit)); + setDeposit(formatted); + setActiveIndex(getIndexByDepositValue(baseDeposit)); + }, [data, getIndexByDepositValue]); + + useEffect(() => { + if (!isManualDeposit) { + setMaxDeposit(deposit); + } else { + setMaxDeposit(handleDepositInput === "" ? "0" : handleDepositInput); + } + }, [deposit, handleDepositInput, isManualDeposit, setMaxDeposit]); + + const handleDepositChange = useCallback( + (value: string) => { + const index = Number(value); + if (Number.isNaN(index)) return; + setActiveIndex(index); + const depositValue = getDepositByIndex(index); + setDeposit(formatNumber(toKRW(depositValue))); + }, + [getDepositByIndex] + ); + + const handleDepositChangeText = useCallback((event: ChangeEvent) => { + const values = event.target.value; + if (values === "") { + setHandleDepositInput(""); + return; + } + const numericValue = Number(values.replace(/[^0-9]/g, "")); + setHandleDepositInput(formatNumber(toKRW(numericValue))); + }, []); + + const handleManualDepositChange = useCallback( + (event: ChangeEvent) => { + const rawValue = event.target.value; + const numericValue = Number(rawValue.replace(/[^0-9]/g, "")); + setMaxMonthPay(rawValue === "" ? "" : formatNumber(numericValue)); + }, + [setMaxMonthPay] + ); + + const handleManualToggle = useCallback( + (checked: boolean | "indeterminate") => { + const nextValue = checked === true; + setIsManualDeposit(nextValue); + if (nextValue) { + setHandleDepositInput(maxDeposit || "0"); + } + }, + [maxDeposit] + ); + + return { + avgCostLabel, + histogramMaxLabel, + histogramMinLabel, + isManualDeposit, + maxDeposit, + maxMonthPay, + activeIndex, + deposit, + normalized, + handleLeftPct, + maxlength, + sliderRef, + handleDepositChange, + handleDepositChangeText, + handleManualDepositChange, + handleManualToggle, + }; +}; diff --git a/src/features/listings/ui/listingsCardDetail/hooks/distanceHooks.ts b/src/features/listings/ui/listingsCardDetail/hooks/distanceHooks.ts new file mode 100644 index 0000000..7a42e47 --- /dev/null +++ b/src/features/listings/ui/listingsCardDetail/hooks/distanceHooks.ts @@ -0,0 +1,45 @@ +import { + getDefaultPinPointLabel, + mapPinPointToOptions, +} from "@/src/features/listings/hooks/listingsHooks"; +import { useOAuthStore } from "@/src/features/login/model"; +import { useListingDetailFilter } from "@/src/features/listings/model"; +import { PinPointPlace } from "@/src/entities/listings/model/type"; + +export const useDistanceHooks = () => { + const { setPinPointId } = useOAuthStore(); + const { distance, setDistance } = useListingDetailFilter(); + + const onChangeValue = (selectedKey: string) => { + setPinPointId(selectedKey); + }; + + const handleDistanceChange = (values: number[]) => { + const [nextValue] = values; + setDistance(nextValue); + }; + + const sliderValue = [distance]; + const formatMinutes = (value: number) => value.toString().padStart(1, "0"); + const formattedDistance = formatMinutes(distance); + + return { + onChangeValue, + handleDistanceChange, + sliderValue, + formattedDistance, + }; +}; + +export const useDistanceVariable = (data?: PinPointPlace) => { + const pinPointData = data?.pinPoints; + const pinPointList = mapPinPointToOptions(pinPointData); + const dropDownTriggerLabel = getDefaultPinPointLabel(pinPointList); + const hasPinPoints = pinPointList.myPinPoint.length > 0; + + return { + pinPointList, + dropDownTriggerLabel, + hasPinPoints, + }; +}; diff --git a/src/features/listings/ui/listingsCardDetail/hooks/regionHooks.tsx b/src/features/listings/ui/listingsCardDetail/hooks/regionHooks.tsx new file mode 100644 index 0000000..194f4b7 --- /dev/null +++ b/src/features/listings/ui/listingsCardDetail/hooks/regionHooks.tsx @@ -0,0 +1,29 @@ +import { cn } from "@/lib/utils"; +import { TagButton } from "@/src/shared/ui/button/tagButton"; + +export const Tag = ({ + label, + selected, + onClick, +}: { + label: string; + selected: boolean; + onClick: () => void; +}) => { + return ( + <> + + {label} + + + ); +}; diff --git a/src/features/listings/ui/listingsCardDetail/hooks/routerHooks.ts b/src/features/listings/ui/listingsCardDetail/hooks/routerHooks.ts new file mode 100644 index 0000000..2cabf1c --- /dev/null +++ b/src/features/listings/ui/listingsCardDetail/hooks/routerHooks.ts @@ -0,0 +1,37 @@ +import { + useDetailFilterSheetStore, + useListingDetailCountStore, +} from "@/src/features/listings/model"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; + +export const useDetailFilterResultButton = () => { + const { filteredCount } = useListingDetailCountStore(); + const router = useRouter(); + const { id } = useParams() as { id: string }; + const searchParams = useSearchParams(); + const closeSheet = useDetailFilterSheetStore(s => s.closeSheet); + + const resetListingsQuery = () => { + try { + const params = new URLSearchParams(searchParams.toString()); + params.delete("section"); + router.replace(`/listings/${id}`); + } catch (error) { + console.error("[ListingFilterPartialSheet] Failed to reset query", error); + } + }; + + const handleCloseSheet = () => { + try { + closeSheet(); + resetListingsQuery(); + } catch (error) { + console.error("[ListingFilterPartialSheet] Failed to close sheet", error); + } + }; + + return { + filteredCount, + handleCloseSheet, + }; +}; diff --git a/src/features/listings/ui/listingsCardDetail/infra/components/roomTypeDetail.tsx b/src/features/listings/ui/listingsCardDetail/infra/components/roomTypeDetail.tsx index c83a5e6..8cc7ea0 100644 --- a/src/features/listings/ui/listingsCardDetail/infra/components/roomTypeDetail.tsx +++ b/src/features/listings/ui/listingsCardDetail/infra/components/roomTypeDetail.tsx @@ -1,15 +1,16 @@ -"use cilent"; -import { useState, useEffect } from "react"; +"use client"; + +import { useEffect, useState } from "react"; +import { CompareDefaultImage } from "@/src/assets/images/compare/compare"; import { useListingRoomTypeDetail } from "@/src/entities/listings/hooks/useListingDetailHooks"; -import { SmallSpinner } from "@/src/shared/ui/spinner/small/smallSpinner"; -import { formatNumber } from "@/src/shared/lib/numberFormat"; +import { ListingUnitType } from "@/src/entities/listings/model/type"; import { toPyeong } from "@/src/features/listings/model"; +import { LikeType } from "@/src/features/listings/hooks/listingsHooks"; +import { formatNumber } from "@/src/shared/lib/numberFormat"; +import { TagButton } from "@/src/shared/ui/button/tagButton"; +import { SmallSpinner } from "@/src/shared/ui/spinner/small/smallSpinner"; import { DepositSection } from "./components/roomType/depositSection"; import { TypeInfoSection } from "./components/roomType/typeInfoSection"; -import { ListingUnitType } from "@/src/entities/listings/model/type"; -import { TagButton } from "@/src/shared/ui/button/tagButton"; -import { LikeType } from "@/src/features/listings/hooks/listingsHooks"; -import { CompareDefaultImage } from "@/src/assets/images/compare/compare"; export const RoomTypeDetail = ({ listingId }: { listingId: string }) => { const { data, isFetching } = useListingRoomTypeDetail({ @@ -17,11 +18,10 @@ export const RoomTypeDetail = ({ listingId }: { listingId: string }) => { queryK: "useListingRoomTypeDetail", url: "unit", }); + const [currentIndex, setCurrentIndex] = useState(0); const items = data ?? []; const current = items[currentIndex]; - const typeId = current?.typeId; - const liked = current?.liked; const goPrev = () => { setCurrentIndex(p => (p - 1 + items.length) % Math.max(items.length, 1)); @@ -31,15 +31,16 @@ export const RoomTypeDetail = ({ listingId }: { listingId: string }) => { setCurrentIndex(p => (p + 1) % Math.max(items.length, 1)); }; - const isLast = currentIndex + 1 < items.length; + const hasNext = currentIndex + 1 < items.length; useEffect(() => { setCurrentIndex(0); }, [listingId]); if (isFetching && !items.length) { - return ; + return ; } + if (!items.length) { return (
@@ -49,7 +50,7 @@ export const RoomTypeDetail = ({ listingId }: { listingId: string }) => { } return ( -
+
@@ -64,31 +65,34 @@ export const RoomTypeDetail = ({ listingId }: { listingId: string }) => { ))} +
+
{current?.thumbnail ? ( ) : (
- -

도면 이미지를 준비하고 있어요

+ +

이미지 준비 중입니다.

)}
+ {items.length > 1 && ( )} diff --git a/src/features/listings/ui/listingsCardDetail/infra/infraSheet.tsx b/src/features/listings/ui/listingsCardDetail/infra/infraSheet.tsx index ea451e9..70bb479 100644 --- a/src/features/listings/ui/listingsCardDetail/infra/infraSheet.tsx +++ b/src/features/listings/ui/listingsCardDetail/infra/infraSheet.tsx @@ -1,5 +1,9 @@ import { motion, AnimatePresence } from "framer-motion"; +import { useRef } from "react"; +import { createPortal } from "react-dom"; import { CloseButton } from "@/src/assets/icons/button"; +import { usePortalTarget } from "@/src/shared/hooks/usePortalTarget"; +import { useScrollLock } from "@/src/shared/hooks/useScrollLock"; import { InfraSheetProps, RenderContentProps, @@ -31,17 +35,20 @@ const RenderContent = ({ section, listingId }: RenderContentProps) => { }; export const InfraSheet = ({ onClose, sheetState }: InfraSheetProps) => { + const anchorRef = useRef(null); + const portalRoot = usePortalTarget("mobile-overlay-root"); const roomType: RoomTitleDesType | null = sheetState.open ? ROOM_TYPE_TITLE_DES[sheetState.section] : null; + useScrollLock({ locked: sheetState.open, anchorRef }); - return ( + const content = ( {sheetState.open && ( <> { e.stopPropagation(); onClose(); @@ -53,7 +60,7 @@ export const InfraSheet = ({ onClose, sheetState }: InfraSheetProps) => { {
-
+
{sheetState.open && ( )} @@ -84,4 +91,11 @@ export const InfraSheet = ({ onClose, sheetState }: InfraSheetProps) => { )} ); + + return ( + <> + + {portalRoot ? createPortal(content, portalRoot) : content} + + ); }; diff --git a/src/features/listings/ui/listingsContents/listingsBookMark.tsx b/src/features/listings/ui/listingsContents/listingsBookMark.tsx index 41c2e6a..4c3b8bd 100644 --- a/src/features/listings/ui/listingsContents/listingsBookMark.tsx +++ b/src/features/listings/ui/listingsContents/listingsBookMark.tsx @@ -6,13 +6,26 @@ export const ListingBookMark = ({ item, border }: { item: string; border: string aria-label="Toggle bookmark" size="sm" variant="outline" - className={`data-[state=on]:*:[svg]:fill-blue-500 data-[state=on]:*:[svg]:stroke-blue-500 ${border}`} + className={` ${border} data-[state=on]:*:[svg]:fill-blue-500 data-[state=on]:*:[svg]:stroke-blue-500 max-w-full overflow-hidden rounded-[4px]`} > -

{item}

+

{item}

); }; +// export const ListingBookMark = ({ item, border }: { item: string; border: string }) => { +// return ( +// +//

{item}

+//
+// ); +// }; + export const ListingBgBookMark = ({ item, bg, diff --git a/src/features/listings/ui/listingsContents/listingsContentCard.tsx b/src/features/listings/ui/listingsContents/listingsContentCard.tsx index 6410a06..8b6a01e 100644 --- a/src/features/listings/ui/listingsContents/listingsContentCard.tsx +++ b/src/features/listings/ui/listingsContents/listingsContentCard.tsx @@ -27,9 +27,9 @@ export const ListingContentsCard = ({ data }: { data: T[ onClick={() => handleRouter(normalized.id)} >
-
+
-

+

{normalized.supplier}

@@ -38,26 +38,23 @@ export const ListingContentsCard = ({ data }: { data: T[
-
-
{ - e.stopPropagation(); - }} - > +
+
e.stopPropagation()}>
+
-

+

+
-

모집일정

-

+

모집일정

+

{formatApplyPeriod(normalized.applyPeriod)}

diff --git a/src/features/listings/ui/listingsContents/listingsContents.tsx b/src/features/listings/ui/listingsContents/listingsContents.tsx index 919700f..faf2fe0 100644 --- a/src/features/listings/ui/listingsContents/listingsContents.tsx +++ b/src/features/listings/ui/listingsContents/listingsContents.tsx @@ -2,7 +2,7 @@ import { useListingListInfiniteQuery } from "@/src/entities/listings/hooks/useListingHooks"; import { ListingsContentHeader } from "./listingsContentsHeader"; import { ListingContentsList } from "./listingsContentsList"; -import { ListingNoSearchResult } from "../listingsNoSearchResult/listingNoSearchResult"; +import { ListingNoSearchResult } from "@/src/features/listings"; import { Spinner } from "@/src/shared/ui/spinner/default"; export const ListingsContent = ({ viewSet = true }: { viewSet?: boolean }) => { diff --git a/src/features/listings/ui/listingsFullSheet/listingsFullSheet.tsx b/src/features/listings/ui/listingsFullSheet/listingsFullSheet.tsx index e23796e..743d300 100644 --- a/src/features/listings/ui/listingsFullSheet/listingsFullSheet.tsx +++ b/src/features/listings/ui/listingsFullSheet/listingsFullSheet.tsx @@ -1,12 +1,16 @@ "use client"; import { motion, AnimatePresence } from "framer-motion"; -import { useListingsFilterStore } from "../../model/listingsStore"; +import { useFilterSheetStore, useListingsFilterStore } from "../../model/listingsStore"; import { FILTER_TABS, FilterTabKey, TAB_CONFIG } from "../../model"; import { CloseButton } from "@/src/assets/icons/button"; import { useRouter, useSearchParams } from "next/navigation"; import { getIndicatorLeft, getIndicatorWidth } from "../../hooks/listingsHooks"; import { Checkbox } from "@/src/shared/lib/headlessUi/checkBox/checkbox"; import { ListingFilterPartialSheetHooks } from "./hooks"; +import { ReactNode, RefObject, useRef } from "react"; +import { createPortal } from "react-dom"; +import { useScrollLock } from "@/src/shared/hooks/useScrollLock"; +import { usePortalTarget } from "@/src/shared/hooks/usePortalTarget"; export const ListingFilterPartialSheet = () => { const { open, scrollRef, isAtBottom, displayTotal, handleScroll, handleCloseSheet } = @@ -125,7 +129,6 @@ const UseCheckBox = () => { const searchParams = useSearchParams(); const currentTab = (searchParams.get("tab") as FilterTabKey) || "region"; const tabConfig = currentTab ? TAB_CONFIG[currentTab] : null; - const regionType = useListingsFilterStore(s => s.regionType); const rentalTypes = useListingsFilterStore(s => s.rentalTypes); const supplyTypes = useListingsFilterStore(s => s.supplyTypes); @@ -154,12 +157,9 @@ const UseCheckBox = () => { rental: supplyTypes, housing: houseTypes, }[currentTab]; - const isAllSelected = selectedList.length === totalItems.length; - const handleAllSelect = (e: boolean) => { const checked = e; - // 기존 방식 유지: 기존 값 초기화 if (currentTab === "region") resetRegionType(); if (currentTab === "target") resetRentalTypes(); @@ -233,29 +233,44 @@ const FilterSheetContainer = ({ children, }: { onDismiss: () => void; - children: React.ReactNode; + children: ReactNode; }) => { - return ( + const open = useFilterSheetStore(s => s.open); + const anchorRef = useRef(null); + const portalRoot = usePortalTarget("mobile-overlay-root"); + useScrollLock({ locked: open, anchorRef }); + + const content = ( <> { + e.stopPropagation(); + onDismiss(); + }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} /> - e.stopPropagation()} transition={{ type: "spring", stiffness: 260, damping: 30 }} > {children} ); + + return ( + <> + + {portalRoot ? createPortal(content, portalRoot) : content} + + ); }; const FilterSheetHeader = ({ onClose }: { onClose: () => void }) => { @@ -263,9 +278,13 @@ const FilterSheetHeader = ({ onClose }: { onClose: () => void }) => { <>
-
+

공고 필터

-
@@ -281,8 +300,8 @@ const FilterSheetContent = ({ onScroll, isAtBottom, }: { - children: React.ReactNode; - scrollRef: React.RefObject; + children: ReactNode; + scrollRef: RefObject; onScroll: () => void; isAtBottom: boolean; }) => { diff --git a/src/features/login/model/auth.cilent.type.ts b/src/features/login/model/auth.cilent.type.ts index 63ff6c6..ffd5a2a 100644 --- a/src/features/login/model/auth.cilent.type.ts +++ b/src/features/login/model/auth.cilent.type.ts @@ -1,6 +1,8 @@ -export type OAuthProviderType = "KAKAO" | "NAVER"; +import { OAuthProviderType } from "@/src/shared/types"; -/*로그인폼*/ +export type { OAuthProviderType }; + +/** 로그인폼 */ export interface ILoginFormProps { onOuth2Login: (provider: OAuthProviderType) => void; } diff --git a/src/features/mypage/api/index.ts b/src/features/mypage/api/index.ts index da26b1a..dba7a7e 100644 --- a/src/features/mypage/api/index.ts +++ b/src/features/mypage/api/index.ts @@ -1 +1,3 @@ export * from "./withdrawApi"; +export * from "./mypageApi"; +export * from "./profileImageApi"; diff --git a/src/features/mypage/api/mypageApi.ts b/src/features/mypage/api/mypageApi.ts new file mode 100644 index 0000000..c4cfd27 --- /dev/null +++ b/src/features/mypage/api/mypageApi.ts @@ -0,0 +1,26 @@ +import { http } from "@/src/shared/api/http"; +import { + USER_MYPAGE_ENDPOINT, + USER_EDIT_MY_INFO_ENDPOINT, +} from "@/src/shared/api"; +import { MypageUserResponse } from "../model"; + +export const getMypageUser = () => { + return http.get(USER_MYPAGE_ENDPOINT); +}; + +export interface PatchMypageUserBody { + nickname?: string; + imageUrl?: string; +} + +/** + * 마이페이지 개인정보 수정 (닉네임 / 프로필 이미지 URL) + * PATCH /users/mypage + */ +export const patchMypageUser = (body: PatchMypageUserBody) => { + return http.patch( + USER_EDIT_MY_INFO_ENDPOINT, + body + ); +}; diff --git a/src/features/mypage/api/profileImageApi.ts b/src/features/mypage/api/profileImageApi.ts new file mode 100644 index 0000000..f64fc0f --- /dev/null +++ b/src/features/mypage/api/profileImageApi.ts @@ -0,0 +1,30 @@ +import { http } from "@/src/shared/api/http"; +import { IMAGES_PRESIGNED_URL_ENDPOINT } from "@/src/shared/api/endpoints"; +import type { IResponse } from "@/src/shared/types/response"; + +export interface PresignedUrlRequest { + fileName: string; + contentType: string; +} + +export interface PresignedUrlData { + /** PUT 업로드에 사용할 presigned URL */ + presignedUrl: string; + /** 업로드 완료 후 접근할 이미지 URL */ + imageUrl: string; + /** presigned URL 만료 시간(초) */ + expiresIn: number; +} + +export type PresignedUrlResponse = IResponse; + +/** + * 프로필 이미지 업로드용 presigned URL 발급 + * POST /v1/images/presigned-url + */ +export const getPresignedUrl = (body: PresignedUrlRequest) => { + return http.post( + IMAGES_PRESIGNED_URL_ENDPOINT, + body + ); +}; diff --git a/src/features/mypage/hooks/index.ts b/src/features/mypage/hooks/index.ts index 96d20b8..7889b91 100644 --- a/src/features/mypage/hooks/index.ts +++ b/src/features/mypage/hooks/index.ts @@ -1,2 +1,5 @@ export * from "./useWithdraw"; export * from "./useProfile"; +export * from "./useProfileNickname"; +export * from "./useProfilePhotoSheet"; +export * from "./useMypageUser"; diff --git a/src/features/mypage/hooks/useMypageUser.ts b/src/features/mypage/hooks/useMypageUser.ts new file mode 100644 index 0000000..0aaf429 --- /dev/null +++ b/src/features/mypage/hooks/useMypageUser.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { getMypageUser } from "../api/mypageApi"; +import { mypageKeys } from "@/src/shared/config/queryKeys"; + +/** + * 마이페이지 진입 시 사용자 정보 조회 훅 + * GET /users/mypage + */ +export const useMypageUser = () => { + return useQuery({ + queryKey: mypageKeys.user(), + queryFn: getMypageUser, + select: (response) => response.data, // MypageUserData | undefined + staleTime: 5 * 60 * 1000, // 5분 + gcTime: 10 * 60 * 1000, // 10분 + }); +}; diff --git a/src/features/mypage/hooks/useProfile.ts b/src/features/mypage/hooks/useProfile.ts index 597529d..c39b9e2 100644 --- a/src/features/mypage/hooks/useProfile.ts +++ b/src/features/mypage/hooks/useProfile.ts @@ -1,39 +1,73 @@ import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { mypageKeys } from "@/src/shared/config/queryKeys"; +import { getPresignedUrl } from "../api/profileImageApi"; +import { patchMypageUser } from "../api/mypageApi"; + +const MAX_PROFILE_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB + +function getFileName(file: File): string { + const base = file.name ? file.name.replace(/\s+/g, "-") : "profile"; + const ext = file.type === "image/png" ? "png" : "jpg"; + return base.endsWith(`.${ext}`) ? base : `${base.split(".")[0] || "profile"}.${ext}`; +} export const useProfile = () => { - const router = useRouter(); + const queryClient = useQueryClient(); const [isLoading, setIsLoading] = useState(false); + const [isImageUploading, setIsImageUploading] = useState(false); const [profileImage, setProfileImage] = useState(null); const [profileImageUrl, setProfileImageUrl] = useState(null); - // TODO: 프로필 이미지 URL은 ReactQuery로 조회한 API 응답을 사용하도록 교체 - const handleImageUpload = (file: File) => { - // 파일 유효성 검사 + const handleImageUpload = async (file: File) => { if (!file.type.startsWith("image/")) { - console.error("이미지 파일만 업로드 가능합니다."); + toast.error("이미지 파일만 업로드 가능합니다."); return; } - - // 파일 크기 제한 (예: 5MB) - if (file.size > 10 * 1024 * 1024) { - console.error("파일 크기는 5MB 이하여야 합니다."); + if (file.size > MAX_PROFILE_IMAGE_SIZE) { + toast.error("파일 크기는 10MB 이하여야 합니다."); return; } - setProfileImage(file); + setIsImageUploading(true); + try { + // 1. presigned URL 요청 + const fileName = getFileName(file); + const contentType = file.type || "image/jpeg"; + const { data } = await getPresignedUrl({ fileName, contentType }); + const presignedUrl = data?.presignedUrl; + const imageUrl = data?.imageUrl; + if (!presignedUrl || !imageUrl) { + toast.error("이미지 업로드 준비에 실패했어요. 잠시 후 다시 시도해주세요."); + return; + } - // 미리보기 URL 생성 - const url = URL.createObjectURL(file); - setProfileImageUrl(prevUrl => { - if (prevUrl) { - URL.revokeObjectURL(prevUrl); + // 2. presigned URL로 PUT 업로드 + const putRes = await fetch(presignedUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": contentType }, + }); + if (!putRes.ok) { + toast.error("이미지 업로드에 실패했어요. 잠시 후 다시 시도해주세요."); + return; } - return url; - }); - // TODO: 선택된 파일을 백엔드에 업로드하는 API 호출로 교체 - // ex: await uploadProfileImage(file); + // 3. PATCH로 서버에 반영 + await patchMypageUser({ imageUrl }); + + queryClient.invalidateQueries({ queryKey: mypageKeys.user() }); + + // 3단계 모두 성공 시에만 이미지 반영 + setProfileImage(file); + setProfileImageUrl(imageUrl); + } catch (err) { + console.error("프로필 이미지 업로드 실패:", err); + toast.error("프로필 이미지 변경에 실패했어요. 잠시 후 다시 시도해주세요."); + } finally { + setIsImageUploading(false); + } }; const handleRemovePhoto = () => { @@ -49,12 +83,18 @@ export const useProfile = () => { // ex: await removeProfileImage(); }; - const handleProfileUpdate = async (nickname: string) => { + const updateMypageProfile = async (payload: { + nickname?: string; + imageUrl?: string; + }) => { + if (!payload.nickname && !payload.imageUrl) return; setIsLoading(true); try { - // TODO: 실제 API 호출 - // await updateProfile({ nickname, profileImage }); - console.log("프로필 업데이트:", { nickname, profileImage }); + await patchMypageUser({ + ...(payload.nickname !== undefined && { nickname: payload.nickname }), + ...(payload.imageUrl !== undefined && { imageUrl: payload.imageUrl }), + }); + queryClient.invalidateQueries({ queryKey: mypageKeys.user() }); } catch (error) { console.error("프로필 업데이트 실패:", error); } finally { @@ -62,12 +102,21 @@ export const useProfile = () => { } }; + const handleProfileUpdate = async (nickname: string) => { + await updateMypageProfile({ + nickname, + ...(profileImageUrl && { imageUrl: profileImageUrl }), + }); + }; + return { profileImage, profileImageUrl, isLoading, + isImageUploading, handleImageUpload, handleRemovePhoto, handleProfileUpdate, + updateMypageProfile, }; }; diff --git a/src/features/mypage/hooks/useProfileNickname.ts b/src/features/mypage/hooks/useProfileNickname.ts new file mode 100644 index 0000000..e0e7f6b --- /dev/null +++ b/src/features/mypage/hooks/useProfileNickname.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +/** + * 유저 닉네임과 동기화되는 편집 가능한 닉네임 state + * initialNickname이 바뀌면 로컬 state도 갱신됨 + */ +export function useProfileNickname(initialNickname: string) { + const [nickname, setNickname] = useState(initialNickname); + + useEffect(() => { + setNickname(initialNickname); + }, [initialNickname]); + + return [nickname, setNickname] as const; +} diff --git a/src/features/mypage/hooks/useProfilePhotoSheet.ts b/src/features/mypage/hooks/useProfilePhotoSheet.ts new file mode 100644 index 0000000..3331c07 --- /dev/null +++ b/src/features/mypage/hooks/useProfilePhotoSheet.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useProfile } from "./useProfile"; + +/** + * 프로필 사진 변경 시트 + 앨범 선택/삭제 핸들러 + * useProfile 기반으로 시트 열기/닫기 및 파일 입력 처리 + */ +export function useProfilePhotoSheet() { + const [isSheetOpen, setIsSheetOpen] = useState(false); + const fileInputRef = useRef(null); + const { + handleImageUpload, + handleRemovePhoto, + profileImageUrl, + isImageUploading, + isLoading, + handleProfileUpdate, + } = useProfile(); + + const handleCameraClick = () => setIsSheetOpen(true); + + const handleSelectFromAlbum = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + handleImageUpload(file); + setIsSheetOpen(false); + } + }; + + const handleRemovePhotoClick = () => { + handleRemovePhoto(); + setIsSheetOpen(false); + }; + + return { + isSheetOpen, + setIsSheetOpen, + fileInputRef, + profileImageUrl, + isImageUploading, + isLoading, + handleCameraClick, + handleSelectFromAlbum, + handleFileChange, + handleRemovePhotoClick, + handleProfileUpdate, + }; +} diff --git a/src/features/mypage/hooks/useWithdraw.ts b/src/features/mypage/hooks/useWithdraw.ts index 96819ad..8f4a261 100644 --- a/src/features/mypage/hooks/useWithdraw.ts +++ b/src/features/mypage/hooks/useWithdraw.ts @@ -1,5 +1,6 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { withdrawUser } from "../api/withdrawApi"; import { logout } from "@/src/features/login/utils/logout"; import { WITHDRAW_REASONS } from "../model/mypageConstants"; @@ -29,6 +30,7 @@ export const useWithdraw = () => { //router.push("/login"); } catch (error) { console.error("탈퇴 실패:", error); + toast.error("탈퇴 처리에 실패했어요. 잠시 후 다시 시도해주세요."); setIsLoading(false); setIsModalOpen(false); } diff --git a/src/features/mypage/model/index.ts b/src/features/mypage/model/index.ts index 521caa7..0a6c498 100644 --- a/src/features/mypage/model/index.ts +++ b/src/features/mypage/model/index.ts @@ -1,2 +1,3 @@ export * from "./withdraw.type"; export * from "./profile.type"; +export * from "./mypageUser.type"; diff --git a/src/features/mypage/model/mypageConstants.ts b/src/features/mypage/model/mypageConstants.ts index 5be44b6..591a875 100644 --- a/src/features/mypage/model/mypageConstants.ts +++ b/src/features/mypage/model/mypageConstants.ts @@ -1,3 +1,64 @@ +/** 마이페이지 메인 화면 문구 */ +export const MYPAGE_TITLE = "마이페이지"; +/** 마이페이지 헤더에 표시할 제목 (공백 포함) */ +export const MYPAGE_HEADER_TITLE = "마이 페이지"; +export const MYPAGE_HEADER_SEARCH_PLACEHOLDER = "검색"; +export const MYPAGE_LOADING_TITLE = "마이페이지 불러오는 중"; +export const MYPAGE_LOADING_DESCRIPTION = "잠시만 기다려주세요."; +export const MYPAGE_ERROR_TEXT = + "정보를 가져오지 못했어요
네트워크 상태를 확인하거나 잠시 후 다시 시도해주세요."; + +/** 프로필 화면 문구 */ +export const MYPAGE_PROFILE_HEADER_TITLE = "프로필 설정"; +export const MYPAGE_PROFILE_LOADING_TITLE = "프로필 불러오는 중"; + +export const MYPAGE_SECTION_MY_INFO = "내 정보"; +export const MYPAGE_SECTION_MY_ACTIVITY = "내 활동"; + +export const MYPAGE_LABEL_INTEREST_ENV = "관심 주변 환경 설정"; +export const MYPAGE_LABEL_PINPOINTS = "핀포인트 설정"; +/** 핀포인트 설정 페이지 (pinpoints) */ +export const MYPAGE_PINPOINTS_HEADER_TITLE = "핀포인트 설정"; +export const MYPAGE_PINPOINTS_DESCRIPTION = + "나만의 핀포인트를 찍고\n원하는 거리 안의 임대주택을 찾아보세요!"; +export const MYPAGE_PINPOINTS_ADD_BUTTON = "핀포인트 추가"; +export const MYPAGE_PINPOINTS_DEFAULT_NAME = "핀 포인트"; + +export const MYPAGE_LABEL_SAVED_LIST = "저장 목록"; +export const MYPAGE_LABEL_RECENT_ADS = "최근 본 공고"; + +/** PinReportSection */ +export const MYPAGE_PIN_REPORT_TITLE = "핀 보고서"; +export const MYPAGE_PIN_REPORT_DESCRIPTION_LINES = [ + "자격진단으로", + "임대주택 지원 가능 여부를 확인하고", + "맞춤 보고서를 받아보세요", +] as const; +export const MYPAGE_PIN_REPORT_BUTTON = "자격진단 하러가기"; +export const MYPAGE_PIN_REPORT_REDIAGNOSIS = "재진단"; +export const MYPAGE_PIN_REPORT_VIEW_DETAIL = "자세히 보기"; +export const MYPAGE_PIN_REPORT_INCOME_LABEL = "내 소득분위"; +export const MYPAGE_PIN_REPORT_TARGET_LABEL = "나의 지원 가능 대상"; +export const MYPAGE_PIN_REPORT_HOUSING_LABEL = "신청 가능한 임대주택"; + +/** 설정 화면 (settings) */ +export const MYPAGE_SETTINGS_TITLE = "설정"; +export const MYPAGE_SETTINGS_PROFILE = "프로필 설정"; +export const MYPAGE_SETTINGS_LOGOUT = "로그아웃"; +export const MYPAGE_SETTINGS_WITHDRAW = "회원 탈퇴"; + +/** UserInfoCard 등 기본값/접근성 문구 */ +export const MYPAGE_DEFAULT_USER_NAME = "유저명"; +export const MYPAGE_DEFAULT_USER_EMAIL = "userid@email.com"; +export const MYPAGE_PROFILE_IMAGE_ALT = "프로필 사진"; + +/** 탈퇴 관련 */ +export const MYPAGE_WITHDRAW_HEADER_TITLE = "회원 탈퇴"; +export const WITHDRAW_BANNER_TITLE = + "그동안 핀하우스를 이용해 주셔서 감사합니다."; +export const WITHDRAW_BANNER_DESCRIPTION = + "탈퇴 사유를 알려주시면 서비스 개선에 참고하겠습니다."; + export const WITHDRAW_REASONS = [ { id: "1", label: "추천 결과가 내 조건과 잘 맞지 않아요" }, { id: "2", label: "원하는 공고/단지가 부족해요" }, diff --git a/src/features/mypage/model/mypageUser.type.ts b/src/features/mypage/model/mypageUser.type.ts new file mode 100644 index 0000000..d1250ec --- /dev/null +++ b/src/features/mypage/model/mypageUser.type.ts @@ -0,0 +1,21 @@ +import { IResponse, OAuthProviderType } from "@/src/shared/types"; + +/** GET /users/mypage 응답의 data 필드 */ +export interface MypageUserData { + userId: string; + provider: OAuthProviderType; + name: string; + nickName: string; + email: string; + phoneNumber: string | null; + role: string; + gender: string; + profileImage: string; + birthday: string; + facilityTypes: string[]; +} + +/** 공통 응답 확장 */ +export interface MypageUserResponse extends IResponse { + data: MypageUserData; +} diff --git a/src/features/mypage/model/profile.type.ts b/src/features/mypage/model/profile.type.ts index 6b9bb73..3d88b4f 100644 --- a/src/features/mypage/model/profile.type.ts +++ b/src/features/mypage/model/profile.type.ts @@ -1,8 +1,10 @@ +import { OAuthProviderType } from "@/src/shared/types"; + export interface ProfileData { nickname: string; email: string; profileImageUrl?: string | null; - provider?: "naver" | "kakao" | "google"; + provider?: OAuthProviderType; badgeCount?: number; } diff --git a/src/features/mypage/ui/index.ts b/src/features/mypage/ui/index.ts index 8edd366..6dd5a39 100644 --- a/src/features/mypage/ui/index.ts +++ b/src/features/mypage/ui/index.ts @@ -1,5 +1,6 @@ export * from "./withdrawForm"; export * from "./withdrawBanner"; +export * from "./myPageHeader"; export * from "./profileForm"; export * from "./profileAvatar"; export * from "./profileNicknameInput"; @@ -9,3 +10,4 @@ export * from "./mypageSection"; export * from "./userInfoCard"; export * from "./pinReportSection"; export * from "./mypageMenuItem"; +export * from "./mypageSettingsMenu"; diff --git a/src/features/mypage/ui/myPageHeader.tsx b/src/features/mypage/ui/myPageHeader.tsx new file mode 100644 index 0000000..1d4e8a5 --- /dev/null +++ b/src/features/mypage/ui/myPageHeader.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { SearchLine } from "@/src/assets/icons/home"; +import { MYPAGE_HEADER_TITLE } from "@/src/features/mypage/model/mypageConstants"; +import { useHomeHeaderHooks } from "@/src/widgets/homeSection/hooks/homeHeaderHooks"; +import { useRouter } from "next/navigation"; + +/** + * 마이페이지 전용 헤더 (로고 없음, 제목 텍스트만) + */ +export const MyPageHeader = () => { + const router = useRouter(); + + return ( +
+

{MYPAGE_HEADER_TITLE}

+
+ +
+
+ ); +}; diff --git a/src/features/mypage/ui/mypageSection.tsx b/src/features/mypage/ui/mypageSection.tsx index 62f09ae..cb9c81e 100644 --- a/src/features/mypage/ui/mypageSection.tsx +++ b/src/features/mypage/ui/mypageSection.tsx @@ -1,25 +1,33 @@ "use client"; -import { ReactNode } from "react"; +import { useId } from "react"; import { MypageMenuItem, MypageMenuItemProps } from "./mypageMenuItem"; -export interface MypageSectionProps { - title: string; - items: MypageMenuItemProps[]; +export interface MypageMenuSectionProps { + title: string; + items: MypageMenuItemProps[]; } -export const MypageSection = ({ title, items }: MypageSectionProps) => { - return ( -
-
-

- {title} -

-
- {items.map((item, index) => ( - - ))} -
- ); +export const MypageMenuSection = ({ title, items }: MypageMenuSectionProps) => { + const headingId = useId(); + + return ( +
+
+

+ {title} +

+
+ {items.map((item, index) => ( + + ))} +
+ ); }; diff --git a/src/features/mypage/ui/mypageSettingsMenu.tsx b/src/features/mypage/ui/mypageSettingsMenu.tsx new file mode 100644 index 0000000..f91c5da --- /dev/null +++ b/src/features/mypage/ui/mypageSettingsMenu.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Link from "next/link"; + +const ITEM_CLASS = + "font-regular block w-full px-5 py-3 text-base leading-[140%] tracking-[-0.02em] text-greyscale-grey-900 hover:no-underline"; +const DIVIDER_CLASS = + "-mx-[20px] h-[9px] border-t border-greyscale-grey-50 bg-greyscale-grey-25"; + +export type MypageSettingsMenuItem = + | { type: "link"; label: string; href: string } + | { type: "button"; label: string; onClick: () => void }; + +export interface MypageSettingsMenuProps { + items: MypageSettingsMenuItem[]; +} + +export const MypageSettingsMenu = ({ items }: MypageSettingsMenuProps) => { + return ( +
+ {items.map((item, index) => ( + + {index == 1 &&
} + {item.type === "link" ? ( + + {item.label} + + ) : ( + + )} + + ))} +
+ ); +}; diff --git a/src/features/mypage/ui/pinReportSection.tsx b/src/features/mypage/ui/pinReportSection.tsx index 1009481..ea78cc3 100644 --- a/src/features/mypage/ui/pinReportSection.tsx +++ b/src/features/mypage/ui/pinReportSection.tsx @@ -1,35 +1,155 @@ "use client"; +import { useMemo } from "react"; +import { ChevronRight, RefreshCw } from "lucide-react"; import { Button } from "@/src/shared/lib/headlessUi"; +import { + MYPAGE_PIN_REPORT_BUTTON, + MYPAGE_PIN_REPORT_DESCRIPTION_LINES, + MYPAGE_PIN_REPORT_TITLE, + MYPAGE_PIN_REPORT_REDIAGNOSIS, + MYPAGE_PIN_REPORT_VIEW_DETAIL, + MYPAGE_PIN_REPORT_INCOME_LABEL, + MYPAGE_PIN_REPORT_TARGET_LABEL, + MYPAGE_PIN_REPORT_HOUSING_LABEL, +} from "@/src/features/mypage/model/mypageConstants"; +import type { DiagnosisLatestData } from "@/src/features/eligibility/api/diagnosisTypes"; interface PinReportSectionProps { - onDiagnosisClick?: () => void; + /** GET /diagnosis/latest 결과. 있으면 요약 카드, 없으면 '자격진단 하러가기' 빈 상태 표시 */ + diagnosisResult: DiagnosisLatestData | null; + onDiagnosisClick?: () => void; + onRediagnosisClick?: () => void; + onViewDetailClick?: () => void; } -export const PinReportSection = ({ - onDiagnosisClick +const PIN_REPORT_HEADING_ID = "pin-report-heading"; +const TAG_CLASS = + "inline-flex items-center rounded-full bg-primary-blue-25 px-2.5 py-1 text-xs font-medium text-primary-blue-400"; + +/** 나의 지원 가능 대상: gender + availableSupplyTypes */ +function getTargetGroupLabels(data: DiagnosisLatestData): string[] { + const list: string[] = []; + if (data.gender) list.push(data.gender); + if (Array.isArray(data.availableSupplyTypes)) { + list.push(...data.availableSupplyTypes); + } + return list; +} + +export const PinReportSection = ({ + diagnosisResult, + onDiagnosisClick, + onRediagnosisClick, + onViewDetailClick, }: PinReportSectionProps) => { + const hasResult = + diagnosisResult != null && + Array.isArray(diagnosisResult.recommended) && + diagnosisResult.recommended.length > 0; + const incomeLevel = diagnosisResult?.myIncomeLevel ?? null; + const targetGroups = useMemo( + () => (diagnosisResult ? getTargetGroupLabels(diagnosisResult) : []), + [diagnosisResult] + ); + const housingTypes = diagnosisResult?.availableRentalTypes ?? []; + + if (hasResult) { return ( -
-
-

- 핀 보고서 -

+
+
+

+ {MYPAGE_PIN_REPORT_TITLE} +

+ +
+ +
+ {incomeLevel != null && incomeLevel !== "" && ( +
+

+ {MYPAGE_PIN_REPORT_INCOME_LABEL} +

+ {incomeLevel}
-
-

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

- + )} + {housingTypes.length > 0 && ( +
+

+ {MYPAGE_PIN_REPORT_HOUSING_LABEL} +

+
+ {housingTypes.map(name => ( + + {name} + + ))} +
- + )} + {onViewDetailClick && ( +
+ +
+ )}
+
); + } + + return ( +
+
+

+ {MYPAGE_PIN_REPORT_TITLE} +

+
+
+

+ {MYPAGE_PIN_REPORT_DESCRIPTION_LINES.map((line, i) => ( + + {i > 0 &&
} + {line} +
+ ))} +

+ +
+
+ ); }; diff --git a/src/features/mypage/ui/profileAvatar.tsx b/src/features/mypage/ui/profileAvatar.tsx index 9bfb1bf..a824088 100644 --- a/src/features/mypage/ui/profileAvatar.tsx +++ b/src/features/mypage/ui/profileAvatar.tsx @@ -8,13 +8,20 @@ import { ProfileDefaultImg } from "@/src/assets/images/mypage/ProfileDefaultImg" export interface ProfileAvatarProps { /** 프로필 이미지 URL */ imageUrl?: string | null; + /** 이미지 업로드 중 여부 (로딩 오버레이 표시) */ + isUploading?: boolean; /** 카메라 아이콘 클릭 핸들러 */ onCameraClick?: () => void; /** 추가 클래스명 */ className?: string; } -export const ProfileAvatar = ({ imageUrl, onCameraClick, className }: ProfileAvatarProps) => { +export const ProfileAvatar = ({ + imageUrl, + isUploading = false, + onCameraClick, + className, +}: ProfileAvatarProps) => { return (
{/* 프로필 이미지 */} @@ -26,12 +33,18 @@ export const ProfileAvatar = ({ imageUrl, onCameraClick, className }: ProfileAva
)} + {isUploading && ( +
+ +
+ )}
{/* 카메라 아이콘 */} -
- ); +export const UserInfoCard = ({ user, onSettingsClick }: UserInfoCardProps) => { + const profileImageUrl = user?.profileImage?.trim() || null; + const userName = user?.nickName ?? MYPAGE_DEFAULT_USER_NAME; + const userEmail = user?.email ?? MYPAGE_DEFAULT_USER_EMAIL; + + return ( +
+
+ {profileImageUrl ? ( + {MYPAGE_PROFILE_IMAGE_ALT} + ) : ( + + )} +
+
+ + {userName} + + + {userEmail} + +
+ +
+ ); }; diff --git a/src/features/mypage/ui/withdrawForm.tsx b/src/features/mypage/ui/withdrawForm.tsx index fe906eb..f8976a4 100644 --- a/src/features/mypage/ui/withdrawForm.tsx +++ b/src/features/mypage/ui/withdrawForm.tsx @@ -6,6 +6,8 @@ import { WITHDRAW_BUTTON_TEXT, WITHDRAW_REASONS, WITHDRAW_TITLE } from "../model import { SurveyButton } from "@/src/shared/ui/button/surveyButton"; import { Modal } from "@/src/shared/ui/modal/default/modal"; +const WITHDRAW_REASONS_HEADING_ID = "withdraw-reasons-heading"; + export const WithdrawForm = () => { const { selectedReasons, @@ -14,41 +16,35 @@ export const WithdrawForm = () => { handleWithdrawConfirm, handleModalCancel, isModalOpen, + isLoading, } = useWithdraw(); - const handleModalButtonClick = (buttonIndex: number, buttonLabel: string) => { + const handleModalButtonClick = (buttonIndex: number, _buttonLabel: string) => { if (buttonIndex === 0) { - // 취소 버튼 handleModalCancel(); } else if (buttonIndex === 1) { - // 탈퇴하기 버튼 handleWithdrawConfirm(); } }; const handleOptionClick = (optionId: string) => { - let newSelectedIds: string[]; - - if (selectedReasons.includes(optionId)) { - // 이미 선택된 경우 제거 - newSelectedIds = selectedReasons.filter(id => id !== optionId); - } else { - // 선택되지 않은 경우 추가 - newSelectedIds = [...selectedReasons, optionId]; - } - + const newSelectedIds = selectedReasons.includes(optionId) + ? selectedReasons.filter(id => id !== optionId) + : [...selectedReasons, optionId]; handleReasonsChange(newSelectedIds); }; return ( <>
- {/* 탈퇴 사유 선택 */} -
-

+
+

{WITHDRAW_TITLE}

-
+
{WITHDRAW_REASONS.map(reason => { const isSelected = selectedReasons.includes(reason.id); return ( @@ -57,29 +53,31 @@ export const WithdrawForm = () => { title={reason.label} pressed={isSelected} onPressedChange={() => handleOptionClick(reason.id)} - className={"w-full pl-5 text-sm"} + className="w-full pl-5 text-sm" /> ); })}
-
+
- {/* 탈퇴하기 버튼 */}

- {/* 탈퇴 확인 모달 */} - + ); }; diff --git a/src/shared/api/endpoints.ts b/src/shared/api/endpoints.ts index e7ea4c4..21f59d0 100644 --- a/src/shared/api/endpoints.ts +++ b/src/shared/api/endpoints.ts @@ -93,8 +93,10 @@ export const COMPLEX_INFRA_ENDPOINT = "/complexes/infra"; * 진단 API */ -// 청약 진단 API +// 청약 진단 API (v2) export const DIAGNOSIS_ENDPOINT = "/diagnosis"; +// 청약 진단 최신 결과 조회 (v2) +export const DIAGNOSIS_LATEST_ENDPOINT = "/diagnosis/latest"; // 질문 설명 API export const DIAGNOSIS_INFO_ENDPOINT = "/diagnosis/info"; @@ -138,3 +140,8 @@ export const SCHOOL_SEARCH_ENDPOINT = "/school/search"; * 좋아요 API */ export const LIKE_ENDPOINT = "/likes"; + +/** + * 이미지 API + */ +export const IMAGES_PRESIGNED_URL_ENDPOINT = "/images/presigned-url"; diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts index 908848a..974304f 100644 --- a/src/shared/api/http.ts +++ b/src/shared/api/http.ts @@ -46,6 +46,12 @@ const processQueue = (error: any, token: string | null = null) => { const BASE_URL = process.env.NEXT_PUBLIC_API_URL; +/** v1 base URL을 v2로 치환 (특정 API만 v2 사용 시 baseURL 옵션으로 전달) */ +export const API_BASE_URL_V2 = + typeof process.env.NEXT_PUBLIC_API_URL === "string" + ? process.env.NEXT_PUBLIC_API_URL.replace(/\/v1\/?$/, "/v2") + : ""; + const api: AxiosInstance = axios.create({ baseURL: BASE_URL, withCredentials: true, diff --git a/src/shared/config/queryKeys.ts b/src/shared/config/queryKeys.ts index ea893ae..ce38fb2 100644 --- a/src/shared/config/queryKeys.ts +++ b/src/shared/config/queryKeys.ts @@ -18,6 +18,12 @@ export const pinPointKeys = { detail: (id: string) => [...pinPointKeys.details(), id] as const, } as const; +// Mypage 관련 QueryKeys +export const mypageKeys = { + all: ["mypage"] as const, + user: () => [...mypageKeys.all, "user"] as const, +} as const; + // Listing 관련 QueryKeys export const listingKeys = { all: ["listing"] as const, diff --git a/src/shared/context/mobileSheetPortalContext.tsx b/src/shared/context/mobileSheetPortalContext.tsx new file mode 100644 index 0000000..1ad0ab8 --- /dev/null +++ b/src/shared/context/mobileSheetPortalContext.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { createContext, useContext, useRef, type RefObject } from "react"; + +const MobileSheetPortalContext = createContext | null>(null); + +export function useMobileSheetPortal(): RefObject | null { + return useContext(MobileSheetPortalContext); +} + +/** 폰 프레임 내부 시트 포탈 ref를 제공 (globalRender에서 사용) */ +export function MobileSheetPortalProvider({ + children, + portalRef, +}: { + children: React.ReactNode; + portalRef: RefObject; +}) { + return ( + + {children} + + ); +} diff --git a/src/shared/hooks/usePortalTarget/index.ts b/src/shared/hooks/usePortalTarget/index.ts new file mode 100644 index 0000000..3526fc0 --- /dev/null +++ b/src/shared/hooks/usePortalTarget/index.ts @@ -0,0 +1 @@ +export * from "./usePortalTarget"; diff --git a/src/shared/hooks/usePortalTarget/usePortalTarget.ts b/src/shared/hooks/usePortalTarget/usePortalTarget.ts new file mode 100644 index 0000000..9834b10 --- /dev/null +++ b/src/shared/hooks/usePortalTarget/usePortalTarget.ts @@ -0,0 +1,13 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export const usePortalTarget = (targetId: string) => { + const [portalTarget, setPortalTarget] = useState(null); + + useEffect(() => { + setPortalTarget(document.getElementById(targetId)); + }, [targetId]); + + return portalTarget; +}; diff --git a/src/shared/hooks/useScrollLock/index.ts b/src/shared/hooks/useScrollLock/index.ts new file mode 100644 index 0000000..e3fed0e --- /dev/null +++ b/src/shared/hooks/useScrollLock/index.ts @@ -0,0 +1 @@ +export * from "./useScrollLock"; diff --git a/src/shared/hooks/useScrollLock/useScrollLock.ts b/src/shared/hooks/useScrollLock/useScrollLock.ts new file mode 100644 index 0000000..ad8f273 --- /dev/null +++ b/src/shared/hooks/useScrollLock/useScrollLock.ts @@ -0,0 +1,76 @@ +"use client"; + +import { RefObject, useEffect } from "react"; + +interface UseScrollLockOptions { + locked: boolean; + anchorRef?: RefObject; + lockDocument?: boolean; +} + +const findScrollableAncestor = (element: HTMLElement | null) => { + let current = element?.parentElement ?? null; + + while (current) { + const styles = window.getComputedStyle(current); + const overflowY = styles.overflowY; + const overflow = styles.overflow; + + if ( + overflowY === "auto" || + overflowY === "scroll" || + overflow === "auto" || + overflow === "scroll" + ) { + return current; + } + + current = current.parentElement; + } + + return null; +}; + +export const useScrollLock = ({ + locked, + anchorRef, + lockDocument = true, +}: UseScrollLockOptions) => { + useEffect(() => { + if (!locked) { + return; + } + + const anchor = anchorRef?.current ?? null; + const scrollContainer = findScrollableAncestor(anchor); + const html = document.documentElement; + const body = document.body; + + const prevContainerOverflow = scrollContainer?.style.overflow ?? ""; + const prevContainerOverflowY = scrollContainer?.style.overflowY ?? ""; + const prevHtmlOverflow = html.style.overflow; + const prevBodyOverflow = body.style.overflow; + + if (scrollContainer) { + scrollContainer.style.overflow = "hidden"; + scrollContainer.style.overflowY = "hidden"; + } + + if (lockDocument) { + html.style.overflow = "hidden"; + body.style.overflow = "hidden"; + } + + return () => { + if (scrollContainer) { + scrollContainer.style.overflow = prevContainerOverflow; + scrollContainer.style.overflowY = prevContainerOverflowY; + } + + if (lockDocument) { + html.style.overflow = prevHtmlOverflow; + body.style.overflow = prevBodyOverflow; + } + }; + }, [anchorRef, lockDocument, locked]); +}; diff --git a/src/shared/lib/headlessUi/bottomSheet/bottomSheet.tsx b/src/shared/lib/headlessUi/bottomSheet/bottomSheet.tsx index 3658e41..9fb1b02 100644 --- a/src/shared/lib/headlessUi/bottomSheet/bottomSheet.tsx +++ b/src/shared/lib/headlessUi/bottomSheet/bottomSheet.tsx @@ -14,15 +14,21 @@ const BottomSheetClose = SheetPrimitive.Close; const BottomSheetPortal = SheetPrimitive.Portal; +const overlayClass = (isInsideContainer: boolean) => + cn( + "z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + isInsideContainer ? "absolute inset-0" : "fixed inset-0" + ); + const BottomSheetOverlay = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + /** 포탈 컨테이너 안에 렌더될 때 true → absolute 사용 */ + isInsideContainer?: boolean; + } +>(({ className, isInsideContainer, ...props }, ref) => ( @@ -33,26 +39,34 @@ interface BottomSheetContentProps extends React.ComponentPropsWithoutRef { showCloseButton?: boolean; showOverlay?: boolean; + /** 지정 시 이 엘리먼트 안에 포탈하고, overlay/content를 absolute로 배치 (폰 프레임 내 시트용) */ + container?: HTMLElement | null; } +const contentClass = (isInsideContainer: boolean) => + cn( + "z-50 gap-4 rounded-t-3xl border-t bg-white p-6 shadow-[0px_-16px_24px_-10px_rgba(48,111,255,0.15)] transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + isInsideContainer ? "absolute inset-x-0 bottom-0 left-0 right-0" : "fixed inset-x-0 bottom-0" + ); + const BottomSheetContent = React.forwardRef< React.ElementRef, BottomSheetContentProps ->(({ className, children, showCloseButton = false, showOverlay = true, ...props }, ref) => ( - - {showOverlay && } - - {children} - - -)); +>(({ className, children, showCloseButton = false, showOverlay = true, container, ...props }, ref) => { + const isInsideContainer = Boolean(container); + return ( + + {showOverlay && } + + {children} + + + ); +}); BottomSheetContent.displayName = SheetPrimitive.Content.displayName; const BottomSheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/src/shared/lib/headlessUi/modal/dialog.tsx b/src/shared/lib/headlessUi/modal/dialog.tsx index 6c34511..ed429f0 100644 --- a/src/shared/lib/headlessUi/modal/dialog.tsx +++ b/src/shared/lib/headlessUi/modal/dialog.tsx @@ -32,32 +32,45 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; type DialogContentProps = React.ComponentPropsWithoutRef & { overlayClassName?: string; showCloseButton?: boolean; + /** When set, portal and overlay/content are rendered inside this element with absolute positioning (e.g. mobile frame). */ + container?: HTMLElement | null; }; const DialogContent = React.forwardRef< React.ElementRef, DialogContentProps ->(({ className, children, overlayClassName, showCloseButton = true, ...props }, ref) => ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - -)); +>(({ className, children, overlayClassName, showCloseButton = true, container, ...props }, ref) => { + const isInsideContainer = Boolean(container); + const contentPositionClass = isInsideContainer + ? "absolute left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%]" + : "fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%]"; + const overlayMergedClassName = isInsideContainer + ? cn("!absolute inset-0", overlayClassName?.replace(/\bfixed\b/g, "absolute")) + : overlayClassName; + + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +}); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/src/shared/types/auth.ts b/src/shared/types/auth.ts new file mode 100644 index 0000000..54417ac --- /dev/null +++ b/src/shared/types/auth.ts @@ -0,0 +1,5 @@ +/** + * OAuth 로그인 제공자 타입 + * 로그인/마이페이지 등에서 공통 사용 + */ +export type OAuthProviderType = "KAKAO" | "NAVER"; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 9d2cfb1..b2607bb 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1 +1,2 @@ export * from "./response"; +export * from "./auth"; diff --git a/src/shared/ui/bottomNavigation/bottomNavigation.tsx b/src/shared/ui/bottomNavigation/bottomNavigation.tsx index a551641..e2a9f9a 100644 --- a/src/shared/ui/bottomNavigation/bottomNavigation.tsx +++ b/src/shared/ui/bottomNavigation/bottomNavigation.tsx @@ -40,7 +40,7 @@ function BottomNavigationContent() { compareDetailPageRegex.test(pathname) || (pathname === "/home" && searchParams.has("mode")); - const isMypageActive = pathname === "/mypage" || pathname.startsWith("/mypage/"); + const isMypageActive = pathname === "/mypage" || pathname.startsWith("/mypage/"); if (shouldHide) return null; return (
diff --git a/src/shared/ui/bottomNavigation/frameBottomNavigation.tsx b/src/shared/ui/bottomNavigation/frameBottomNavigation.tsx index 3af48d3..20a2d32 100644 --- a/src/shared/ui/bottomNavigation/frameBottomNavigation.tsx +++ b/src/shared/ui/bottomNavigation/frameBottomNavigation.tsx @@ -37,16 +37,8 @@ export const FrameBottomNav = () => { detailPageRegex.test(pathname) || compareDetailPageRegex.test(pathname) || (pathname === "/listings" && hasListingsTab); - // const searchParams = useSearchParams(); - // const tab = searchParams.get("tab"); - // const shouldHide = - // hiddenRoutes.some(route => pathname.startsWith(route)) || - // hiddenExactRoutes.includes(pathname) || - // pathname.startsWith("/home/search") || - // (pathname === "/listings" && tab !== null) || - // detailPageRegex.test(pathname) || - // compareDetailPageRegex.test(pathname) || - // (pathname === "/home" && searchParams.has("mode")); + + const isMypageActive = pathname === "/mypage" || pathname.startsWith("/mypage/"); if (shouldHide) return null; return ( @@ -77,13 +69,13 @@ export const FrameBottomNav = () => { diff --git a/src/shared/ui/errorState/ErrorState.tsx b/src/shared/ui/errorState/ErrorState.tsx new file mode 100644 index 0000000..c22a0e3 --- /dev/null +++ b/src/shared/ui/errorState/ErrorState.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SearchEmpty } from "@/src/assets/icons/home/searchEmpty"; +import { Button } from "@/src/shared/lib/headlessUi"; +import { AnimatePresence, motion } from "framer-motion"; + +export interface ErrorStateProps { + /** 에러 메시지 (
로 줄바꿈) */ + text?: string; + /** 버튼 클릭 시 호출 (미전달 시 /home 이동) */ + onClick?: () => void; + /** wrapper className */ + className?: string; +} + +/** + * 공용 에러 UI + * ListingNoSearchResult 스타일: 아이콘 + 메시지 + home으로 돌아가기 버튼 + */ +export const ErrorState = ({ + text = "에러가 발생했습니다.", + onClick, + className, +}: ErrorStateProps) => { + const router = useRouter(); + const lines = text.split("
"); + const handleButtonClick = onClick ?? (() => router.push("/home")); + + return ( +
+ + + + {lines.map((line, idx) => ( +

+ {line} +

+ ))} + +
+
+
+ ); +}; diff --git a/src/shared/ui/errorState/index.ts b/src/shared/ui/errorState/index.ts new file mode 100644 index 0000000..fd710f3 --- /dev/null +++ b/src/shared/ui/errorState/index.ts @@ -0,0 +1 @@ +export * from "./ErrorState"; diff --git a/src/shared/ui/globalRender/globalRender.tsx b/src/shared/ui/globalRender/globalRender.tsx index f36ac97..5535e94 100644 --- a/src/shared/ui/globalRender/globalRender.tsx +++ b/src/shared/ui/globalRender/globalRender.tsx @@ -1,9 +1,9 @@ -import { cn } from "@/lib/utils"; import { HomeBottomRender } from "@/src/assets/images/render/homeBottom"; import { HomeRectangleRender } from "@/src/assets/images/render/homeRectangle"; import { HomeRectangleRender2 } from "@/src/assets/images/render/homeRectangle_1"; import { HomeStarRender } from "@/src/assets/images/render/homeStar"; import { SecondaryLogoRender } from "@/src/assets/images/render/secondaryLogo"; +import { MobileFrameWithSheetPortal } from "@/src/shared/ui/globalRender/mobileFrameWithSheetPortal"; import { ReactNode } from "react"; interface Props { @@ -58,20 +58,9 @@ export const HomeLandingRender = ({ children, bottom }: Props) => {
-
-
- -
- {children} -
- -
{bottom}
-
+ + {children} +
diff --git a/src/shared/ui/globalRender/mobileFrameWithSheetPortal.tsx b/src/shared/ui/globalRender/mobileFrameWithSheetPortal.tsx new file mode 100644 index 0000000..1a3f979 --- /dev/null +++ b/src/shared/ui/globalRender/mobileFrameWithSheetPortal.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useRef } from "react"; +import { cn } from "@/lib/utils"; +import { MobileSheetPortalProvider } from "@/src/shared/context/mobileSheetPortalContext"; + +interface MobileFrameWithSheetPortalProps { + children: React.ReactNode; + bottom?: React.ReactNode; + hasBottom: boolean; +} + +/** + * 홈 폰 프레임(375px) 내부에 시트 포탈 컨테이너를 두고, + * 바텀시트가 이 영역 안에서만 뜨도록 ref를 context로 제공합니다. + */ +export function MobileFrameWithSheetPortal({ + children, + bottom, + hasBottom, +}: MobileFrameWithSheetPortalProps) { + const portalRef = useRef(null); + + return ( + +
+
+ +
+ {children} +
+ +
{bottom}
+ +
+ + {/* 바텀시트가 이 컨테이너에만 렌더되도록 포탈 타깃 */} + + + ); +} diff --git a/src/shared/ui/header/header/rightIconDefaultHeader/rightIconDefaultHeader.tsx b/src/shared/ui/header/header/rightIconDefaultHeader/rightIconDefaultHeader.tsx new file mode 100644 index 0000000..e749e08 --- /dev/null +++ b/src/shared/ui/header/header/rightIconDefaultHeader/rightIconDefaultHeader.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { CloseButton, LeftButton } from "@/src/assets/icons/button"; + +type RightIconDefaultHeaderProps = { + title: string; + onRightClick: () => void; +}; + +export const RightIconDefaultHeader = ({ title, onRightClick }: RightIconDefaultHeaderProps) => { + return ( +
+

+ {title} +

+ +
+ ); +}; diff --git a/src/shared/ui/header/index.ts b/src/shared/ui/header/index.ts index a47c509..350bc80 100644 --- a/src/shared/ui/header/index.ts +++ b/src/shared/ui/header/index.ts @@ -1 +1,2 @@ export * from "@/src/shared/ui/header/header/defaultHeader/defaultHeader"; +export * from "@/src/shared/ui/header/header/rightIconDefaultHeader/rightIconDefaultHeader"; diff --git a/src/shared/ui/loadingState/LoadingState.tsx b/src/shared/ui/loadingState/LoadingState.tsx new file mode 100644 index 0000000..facada1 --- /dev/null +++ b/src/shared/ui/loadingState/LoadingState.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Spinner } from "@/src/shared/ui/spinner/default"; + +export interface LoadingStateProps { + /** 로딩 제목 */ + title?: string; + /** 로딩 설명 */ + description?: string; + /** 최소 높이 (기본: 화면 전체) */ + className?: string; +} + +/** + * 공용 로딩 UI + * 섹션/전체 화면 로딩 시 사용 + */ +export const LoadingState = ({ + title = "로딩 중", + description = "잠시만 기다려주세요.", + className, +}: LoadingStateProps) => { + return ( +
+ +
+ ); +}; diff --git a/src/shared/ui/loadingState/index.ts b/src/shared/ui/loadingState/index.ts new file mode 100644 index 0000000..47d4edd --- /dev/null +++ b/src/shared/ui/loadingState/index.ts @@ -0,0 +1 @@ +export * from "./LoadingState"; diff --git a/src/shared/ui/modal/default/modal.tsx b/src/shared/ui/modal/default/modal.tsx index f0df3bb..a3ec896 100644 --- a/src/shared/ui/modal/default/modal.tsx +++ b/src/shared/ui/modal/default/modal.tsx @@ -1,5 +1,9 @@ +"use client"; + +import { useState, useLayoutEffect } from "react"; import { cn } from "@/src/shared/lib/utils"; import { Dialog, DialogContent, DialogTitle } from "@/src/shared/lib/headlessUi/modal/dialog"; +import { useMobileSheetPortal } from "@/src/shared/context/mobileSheetPortalContext"; import { discription, modalContainerPreset, modalOverlayPreset } from "../preset"; import { ModalProps } from "./type"; @@ -12,17 +16,33 @@ export const Modal = ({ className, overlayClassName, onButtonClick, + confirmButtonDisabled = false, + showCloseButton = false, + onClose, }: ModalProps) => { + const portalRef = useMobileSheetPortal(); + const [container, setContainer] = useState(null); + + useLayoutEffect(() => { + if (portalRef?.current) setContainer(portalRef.current); + }, [portalRef]); + if (!open) return null; const modalScript = discription[type]; return ( - + { + if (!value) onClose?.(); + }} + >
@@ -30,18 +50,25 @@ export const Modal = ({
- {modalScript?.btnlabel?.map((item, index) => ( - - ))} + {modalScript?.btnlabel?.map((item, index) => { + const isSinglePrimary = (modalScript?.btnlabel?.length ?? 0) === 1; + const variant = isSinglePrimary ? "solid" : index === 0 ? "outline" : "solid"; + const theme = isSinglePrimary ? "mainBlue" : index === 0 ? "black" : "mainBlue"; + return ( + + ); + })}
diff --git a/src/shared/ui/modal/default/type.ts b/src/shared/ui/modal/default/type.ts index c0b69c9..fefc7a0 100644 --- a/src/shared/ui/modal/default/type.ts +++ b/src/shared/ui/modal/default/type.ts @@ -18,4 +18,10 @@ export interface ModalProps { className?: string; overlayClassName?: string; onButtonClick?: (buttonIndex: number, buttonLabel: string) => void; + /** 확인(두 번째) 버튼 비활성화 (예: 제출 중) */ + confirmButtonDisabled?: boolean; + /** 우측 상단 X 버튼 노출 여부. true일 때만 표시 */ + showCloseButton?: boolean; + /** X 버튼 또는 외부 클릭으로 닫을 때 호출 (showCloseButton 사용 시 상태 정리용) */ + onClose?: () => void; } diff --git a/src/shared/ui/modal/preset.ts b/src/shared/ui/modal/preset.ts index 2175cdb..72bf802 100644 --- a/src/shared/ui/modal/preset.ts +++ b/src/shared/ui/modal/preset.ts @@ -5,7 +5,7 @@ export const modalOverlayPreset = export const modalContainerPreset = [ "rounded-xl sm:rounded-xl bg-white p-6 shadow-lg transition-all", - "w-[90%] min-w-[322px] sm:w-[322px] md:w-[400px] lg:w-[500px]", + "w-[90%] min-w-[280px] sm:w-[300px] md:w-[320px]", ] as const; export const filterScript: ModalDescriptProps = { @@ -33,10 +33,22 @@ export const withdrawConfirmScript: ModalDescriptProps = { btnlabel: ["취소", "탈퇴하기"], }; +export const eligibilityPreviousDiagnosisScript: ModalDescriptProps = { + descript: "이전에 진행한 진단 이력이 있어요!\n 새로 시작할까요?", + btnlabel: ["새로 시작하기", "결과보기"], +}; + +export const eligibilityDiagnosisGoScript: ModalDescriptProps = { + descript: "자격진단으로\n나에게 맞는 공고를 확인해볼까요?", + btnlabel: ["자격진단 하러가기"], +}; + export const discription: ModalDescriptMap = { filterSearch: filterScript, quickSearchEnterCheck: quickSearchEnterCheckScript, quickSearchSaveCheck: quickSearchSaveCheckScript, quickSearchResetAlert: quickSearchResetAlertScript, withdrawConfirm: withdrawConfirmScript, + eligibilityPreviousDiagnosis: eligibilityPreviousDiagnosisScript, + eligibilityDiagnosisGo: eligibilityDiagnosisGoScript, }; diff --git a/src/widgets/eligibilitySection/index.ts b/src/widgets/eligibilitySection/index.ts index 5de9319..49468a0 100644 --- a/src/widgets/eligibilitySection/index.ts +++ b/src/widgets/eligibilitySection/index.ts @@ -1 +1,3 @@ export { EligibilitySection } from "./ui/eligibilitySection"; +export { EligibilityResultSection } from "./ui/eligibilityResultSection"; +export { EligibilityFinalResultSection } from "./ui/eligibilityFinalResultSection"; diff --git a/src/widgets/eligibilitySection/ui/eligibilityFinalResultSection.tsx b/src/widgets/eligibilitySection/ui/eligibilityFinalResultSection.tsx new file mode 100644 index 0000000..85d9b19 --- /dev/null +++ b/src/widgets/eligibilitySection/ui/eligibilityFinalResultSection.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { useDiagnosisResultStore } from "@/src/features/eligibility/model/diagnosisResultStore"; +import { DiagnosisResultBanner, DiagnosisResultList } from "@/src/features/eligibility/ui/result"; +import { RightIconDefaultHeader } from "@/src/shared/ui/header"; +import { ErrorState } from "@/src/shared/ui/errorState"; +import { useOAuthStore } from "@/src/features/login/model/authStore"; +import { PageTransition } from "@/src/shared/ui/animation/pageTransition"; + +const FINAL_RESULT_PAGE_TITLE = "진단 결과"; +const INELIGIBLE_MESSAGE = "조건 미충족으로 인해 자격미달입니다."; +const NULL_RESULT_MESSAGE = "결과가 없습니다."; + +/** "통합공공임대 : 청년 특별공급" 에서 신청 유형만 추출 (배너 요약용) */ +function getApplicationTypeSummary(recommended: string[], maxCount = 3): string[] { + const sep = " : "; + const set = new Set(); + for (const raw of recommended) { + const idx = raw.indexOf(sep); + const applicationType = idx === -1 ? raw.trim() : raw.slice(idx + sep.length).trim(); + if (applicationType) set.add(applicationType); + if (set.size >= maxCount) break; + } + return Array.from(set); +} + +export const EligibilityFinalResultSection = () => { + const router = useRouter(); + const result = useDiagnosisResultStore(s => s.result); + const incomeLevel = useDiagnosisResultStore(s => s.incomeLevel); + const { userName } = useOAuthStore(); + + const applicationTypeSummary = useMemo( + () => (result?.recommended ? getApplicationTypeSummary(result.recommended) : []), + [result?.recommended] + ); + + const showErrorState = result === null || !result.eligible; + + if (showErrorState) { + return ( +
+ +
+ router.push("/home")} + /> +
+
+ +
+
+
+ ); + } + + return ( +
+ +
+ router.push("/home")} + /> +
+
+ + {result.recommended.length > 0 && ( + + )} +
+
+
+ ); +}; diff --git a/src/widgets/eligibilitySection/ui/eligibilityResultSection.tsx b/src/widgets/eligibilitySection/ui/eligibilityResultSection.tsx new file mode 100644 index 0000000..e67a7dc --- /dev/null +++ b/src/widgets/eligibilitySection/ui/eligibilityResultSection.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEligibilityStore } from "@/src/features/eligibility/model/eligibilityStore"; +import { useDiagnosisResult } from "@/src/features/eligibility/hooks/useDiagnosisResult"; +import { useDiagnosisResultStore } from "@/src/features/eligibility/model/diagnosisResultStore"; +import { mapEligibilityToDiagnosisRequest } from "@/src/features/eligibility/api/mapEligibilityToDiagnosisRequest"; +import { + ELIGIBILITY_RESULT_BUTTON, + ELIGIBILITY_RESULT_PAGE_TITLE, +} from "@/src/features/eligibility/model/eligibilityConstants"; +import { + EligibilityResultBanner, + EligibilityResultList, +} from "@/src/features/eligibility/ui/result"; +import EligibilityLoadingState from "@/src/features/eligibility/ui/common/eligibilityLoadingState"; +import { DefaultHeader } from "@/src/shared/ui/header"; +import { Button } from "@/src/shared/lib/headlessUi/button/button"; +import { useOAuthStore } from "@/src/features/login/model/authStore"; +import { toast } from "sonner"; +import { PageTransition } from "@/src/shared/ui/animation/pageTransition"; + +export const EligibilityResultSection = () => { + const router = useRouter(); + const data = useEligibilityStore(); + const { userName } = useOAuthStore(); + const { submit, isLoading, error } = useDiagnosisResult(); + const setDiagnosisResult = useDiagnosisResultStore(s => s.setResult); + + const handleResultClick = async () => { + try { + const resultData = await submit(); + if (resultData) { + const body = mapEligibilityToDiagnosisRequest(data); + setDiagnosisResult(resultData, { incomeLevel: body.incomeLevel }); + router.push("/eligibility/result/final"); + } else { + toast.error("진단 결과를 받지 못했어요. 다시 시도해 주세요."); + } + } catch { + toast.error("진단 요청에 실패했어요. 다시 시도해 주세요."); + } + }; + + return ( +
+ +
+ +
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
+ + +
+
+ + {error && ( +

+ {error.message} +

+ )} +
+ + )} +
+
+ ); +}; diff --git a/src/widgets/eligibilitySection/ui/eligibilitySection.tsx b/src/widgets/eligibilitySection/ui/eligibilitySection.tsx index 320ef27..935c2a4 100644 --- a/src/widgets/eligibilitySection/ui/eligibilitySection.tsx +++ b/src/widgets/eligibilitySection/ui/eligibilitySection.tsx @@ -32,22 +32,24 @@ export const EligibilitySection = () => { return (
- {/* Stepper */} - {ELIGIBILITY_STEPS.length > 0 && currentGroup && ( -
- -
- )} - - {/* 단계별 폼 컴포넌트 */} - + {/* Stepper */} + {ELIGIBILITY_STEPS.length > 0 && currentGroup && ( +
+ +
+ )} + + + {/* 단계별 폼 컴포넌트 */} + + + + {/* 다음 버튼 */} +
+ +
- - {/* 다음 버튼 */} -
- -
); }; diff --git a/src/widgets/homeSection/homeSearchSection/homeSearchSection.tsx b/src/widgets/homeSection/homeSearchSection/homeSearchSection.tsx index 1512c06..b4cf009 100644 --- a/src/widgets/homeSection/homeSearchSection/homeSearchSection.tsx +++ b/src/widgets/homeSection/homeSearchSection/homeSearchSection.tsx @@ -1,7 +1,4 @@ "use client"; -import { LeftButton } from "@/src/assets/icons/button"; -import { SearchBar } from "@/src/features/home"; -import { useRouter } from "next/navigation"; import { SearchHeader } from "@/src/shared/ui/header/header/searchHeader/searchHeader"; import { useSearchState } from "@/src/shared/hooks/store"; diff --git a/src/widgets/listingsSection/ui/listingsCardDetailSection/listingsCardDetailSection.tsx b/src/widgets/listingsSection/ui/listingsCardDetailSection/listingsCardDetailSection.tsx index 590c653..8ecf956 100644 --- a/src/widgets/listingsSection/ui/listingsCardDetailSection/listingsCardDetailSection.tsx +++ b/src/widgets/listingsSection/ui/listingsCardDetailSection/listingsCardDetailSection.tsx @@ -48,7 +48,9 @@ export const ListingsCardDetailSection = ({ id }: { id: string }) => { <>
- {!open && } + @@ -58,7 +60,9 @@ export const ListingsCardDetailSection = ({ id }: { id: string }) => { onFilteredCount={nonFiltered.totalCount} /> - {!open && } +
)} diff --git a/src/widgets/mypageSection/index.ts b/src/widgets/mypageSection/index.ts new file mode 100644 index 0000000..2392dc7 --- /dev/null +++ b/src/widgets/mypageSection/index.ts @@ -0,0 +1,5 @@ +export * from "./ui/MypageSection"; +export * from "./ui/PinpointsSection"; +export * from "./ui/ProfileSection"; +export * from "./ui/SettingsSection"; +export * from "./ui/WithdrawSection"; diff --git a/src/widgets/mypageSection/ui/MypageSection.tsx b/src/widgets/mypageSection/ui/MypageSection.tsx new file mode 100644 index 0000000..e060a8b --- /dev/null +++ b/src/widgets/mypageSection/ui/MypageSection.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +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 { useMypageUser } from "@/src/features/mypage/hooks"; +import { + MYPAGE_ERROR_TEXT, + MYPAGE_LABEL_INTEREST_ENV, + MYPAGE_LABEL_PINPOINTS, + MYPAGE_LABEL_RECENT_ADS, + MYPAGE_LABEL_SAVED_LIST, + MYPAGE_LOADING_DESCRIPTION, + MYPAGE_LOADING_TITLE, + MYPAGE_SECTION_MY_ACTIVITY, + MYPAGE_SECTION_MY_INFO, +} from "@/src/features/mypage/model/mypageConstants"; +import { + MypageMenuSection, + MyPageHeader, + PinReportSection, + UserInfoCard, +} from "@/src/features/mypage/ui"; +import { useDiagnosisLatest } from "@/src/features/eligibility/hooks/useDiagnosisLatest"; +import { useDiagnosisResultStore } from "@/src/features/eligibility/model/diagnosisResultStore"; +import { ErrorState } from "@/src/shared/ui/errorState"; +import { LoadingState } from "@/src/shared/ui/loadingState"; +import { Modal } from "@/src/shared/ui/modal/default/modal"; +import { PageTransition } from "@/src/shared/ui/animation/pageTransition"; + +/** + * 마이페이지 메인 화면 위젯 + * - useMypageUser 호출 및 로딩/에러 처리 + * - 섹션 배치 및 라우팅 + */ +export const MypageSection = () => { + const { data, isLoading, isError } = useMypageUser(); + const { data: diagnosisLatest } = useDiagnosisLatest(); + const setDiagnosisResult = useDiagnosisResultStore(s => s.setResult); + const router = useRouter(); + const [eligibilityModalOpen, setEligibilityModalOpen] = useState(false); + + const openEligibilityModal = () => setEligibilityModalOpen(true); + const handleModalConfirm = () => { + setEligibilityModalOpen(false); + router.push("/eligibility"); + }; + const handleRediagnosis = () => { + router.push("/eligibility"); + }; + const handleViewDetail = () => router.push("/eligibility/result/final"); + + useEffect(() => { + if (diagnosisLatest) { + setDiagnosisResult( + { + eligible: diagnosisLatest.eligible, + decisionMessage: diagnosisLatest.diagnosisResult, + recommended: diagnosisLatest.recommended, + }, + { incomeLevel: diagnosisLatest.myIncomeLevel } + ); + } + }, [diagnosisLatest, setDiagnosisResult]); + + if (isLoading) { + return ( + + ); + } + + if (isError) { + return ( + + ); + } + + return ( +
+ + +
+ router.push("/mypage/settings")} /> + + + + setEligibilityModalOpen(false)} + onButtonClick={handleModalConfirm} + /> + + , + label: MYPAGE_LABEL_INTEREST_ENV, + onClick: () => { + alert("관심 주변 환경 설정 미구현 상태"); + }, + }, + { + icon: , + label: MYPAGE_LABEL_PINPOINTS, + onClick: () => router.push("/mypage/pinpoints"), + }, + ]} + /> + + , + label: MYPAGE_LABEL_SAVED_LIST, + onClick: () => { + alert("저장 목록 이동 미구현 상태"); + }, + }, + { + icon: , + label: MYPAGE_LABEL_RECENT_ADS, + onClick: () => { + alert("최근 본 공고 이동 미구현 상태"); + }, + }, + ]} + /> +
+
+
+ ); +}; diff --git a/src/widgets/mypageSection/ui/PinpointsSection.tsx b/src/widgets/mypageSection/ui/PinpointsSection.tsx new file mode 100644 index 0000000..22bb868 --- /dev/null +++ b/src/widgets/mypageSection/ui/PinpointsSection.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { MapPin } from "@/src/assets/icons/onboarding"; +import { useAddressStore } from "@/src/entities/address"; +import { AddressSearch } from "@/src/features/addressSearch"; +import { useAddPinpoint } from "@/src/features/mypage/hooks/useAddPinpoint"; +import { + MYPAGE_LABEL_PINPOINTS, + MYPAGE_PINPOINTS_ADD_BUTTON, + MYPAGE_PINPOINTS_DEFAULT_NAME, + MYPAGE_PINPOINTS_DESCRIPTION, + MYPAGE_PINPOINTS_HEADER_TITLE, +} from "@/src/features/mypage/model/mypageConstants"; +import { Button } from "@/src/shared/lib/headlessUi"; +import { DefaultHeader } from "@/src/shared/ui/header"; +import { PageTransition } from "@/src/shared/ui/animation/pageTransition"; + +/** + * 마이페이지 핀포인트 설정 화면 위젯 + */ +export const PinpointsSection = () => { + const router = useRouter(); + const { address, pinPoint } = useAddressStore(); + const { addPinpoint, isLoading } = useAddPinpoint({ + onSuccess: () => { + router.push("/mypage"); + }, + onError: () => { + toast.error("핀포인트 추가에 실패했어요. 잠시 후 다시 시도해주세요."); + }, + }); + + const handleAddPinpoint = () => { + if (!address) return; + addPinpoint({ + address, + name: pinPoint || MYPAGE_PINPOINTS_DEFAULT_NAME, + first: true, + }); + }; + + return ( +
+ +
+ +
+
+
+
+ +
+

{MYPAGE_LABEL_PINPOINTS}

+

+ {MYPAGE_PINPOINTS_DESCRIPTION} +

+
+ +
+
+ {address ? ( +
+ +
+ ) : null} +
+
+ ); +}; diff --git a/src/widgets/mypageSection/ui/ProfileSection.tsx b/src/widgets/mypageSection/ui/ProfileSection.tsx new file mode 100644 index 0000000..5198ba2 --- /dev/null +++ b/src/widgets/mypageSection/ui/ProfileSection.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useMypageUser } from "@/src/features/mypage/hooks"; +import { + MYPAGE_ERROR_TEXT, + MYPAGE_LOADING_DESCRIPTION, + MYPAGE_PROFILE_HEADER_TITLE, + MYPAGE_PROFILE_LOADING_TITLE, +} from "@/src/features/mypage/model/mypageConstants"; +import { ProfileForm } from "@/src/features/mypage/ui"; +import { PageTransition } from "@/src/shared/ui/animation/pageTransition"; +import { ErrorState } from "@/src/shared/ui/errorState"; +import { DefaultHeader } from "@/src/shared/ui/header"; +import { LoadingState } from "@/src/shared/ui/loadingState"; + +/** + * 마이페이지 프로필 화면 위젯 + * - useMypageUser 호출 및 로딩/에러 처리 + * - ProfileForm 렌더 + */ +export const ProfileSection = () => { + const { data, isLoading, isError } = useMypageUser(); + + if (isLoading) { + return ( + + ); + } + + if (isError) { + return ( + + ); + } + + return ( +
+ +
+ +
+
+
+ +
+
+
+ ); +}; diff --git a/src/widgets/mypageSection/ui/SettingsSection.tsx b/src/widgets/mypageSection/ui/SettingsSection.tsx new file mode 100644 index 0000000..3eab101 --- /dev/null +++ b/src/widgets/mypageSection/ui/SettingsSection.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { logout } from "@/src/features/login/utils/logout"; +import { + MYPAGE_SETTINGS_LOGOUT, + MYPAGE_SETTINGS_PROFILE, + MYPAGE_SETTINGS_TITLE, + MYPAGE_SETTINGS_WITHDRAW, +} from "@/src/features/mypage/model/mypageConstants"; +import { MypageSettingsMenu, type MypageSettingsMenuItem } from "@/src/features/mypage/ui"; +import { PageTransition } from "@/src/shared/ui/animation/pageTransition"; +import { DefaultHeader } from "@/src/shared/ui/header"; + +/** + * 마이페이지 설정 화면 위젯 + * - 프로필 설정 / 로그아웃 / 회원 탈퇴 메뉴 + */ +export const SettingsSection = () => { + const menuItems: MypageSettingsMenuItem[] = [ + { type: "link", label: MYPAGE_SETTINGS_PROFILE, href: "/mypage/profile" }, + { type: "button", label: MYPAGE_SETTINGS_LOGOUT, onClick: logout }, + { type: "link", label: MYPAGE_SETTINGS_WITHDRAW, href: "/mypage/withdraw" }, + ]; + + return ( +
+ +
+ +
+
+
+ +
+
+
+ ); +}; diff --git a/src/widgets/mypageSection/ui/WithdrawSection.tsx b/src/widgets/mypageSection/ui/WithdrawSection.tsx new file mode 100644 index 0000000..0d6b127 --- /dev/null +++ b/src/widgets/mypageSection/ui/WithdrawSection.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { WithdrawBanner, WithdrawForm } from "@/src/features/mypage/ui"; +import { + MYPAGE_WITHDRAW_HEADER_TITLE, + WITHDRAW_BANNER_DESCRIPTION, + WITHDRAW_BANNER_TITLE, +} from "@/src/features/mypage/model/mypageConstants"; +import { DefaultHeader } from "@/src/shared/ui/header"; +import { PageTransition } from "@/src/shared/ui/animation/pageTransition"; + +/** + * 마이페이지 회원 탈퇴 화면 위젯 + */ +export const WithdrawSection = () => { + return ( +
+ +
+ +
+
+ +
+
+ +
+ +
+ ); +}; diff --git a/tailwind.config.mjs b/tailwind.config.mjs index dca2081..c70ab3a 100644 --- a/tailwind.config.mjs +++ b/tailwind.config.mjs @@ -73,7 +73,7 @@ export default { 200: "#CECED7", 300: "#BBBAC5", 400: "#9F9FAB", - 500: "#7F7FBF", + 500: "#7F7F8F", 600: "#676472", 700: "#4F4B5C", 800: "#2E293D", @@ -217,6 +217,7 @@ export default { }, boxShadow: { "md-16": "0 8px 16px 0 rgba(0, 0, 0, 0.15)", + "result-card": "0px 2px 12px -8px rgba(17, 12, 34, 0.06)", }, }, },