diff --git a/app/eligibility/page.tsx b/app/eligibility/page.tsx index 01e43c4..9544940 100644 --- a/app/eligibility/page.tsx +++ b/app/eligibility/page.tsx @@ -1,23 +1,66 @@ "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 { DiagnosisResultData } 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; + if (data != null && typeof data === "object" && "eligible" in data) { + setDiagnosisResult(data as DiagnosisResultData); + 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/src/features/eligibility/api/diagnosisApi.ts b/src/features/eligibility/api/diagnosisApi.ts new file mode 100644 index 0000000..4bb125e --- /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 { DiagnosisResultData } from "./diagnosisTypes"; +import type { DiagnosisPostRequest } from "./diagnosisTypes"; + +const v2Options = { baseURL: API_BASE_URL_V2 }; + +/** GET /v2/diagnosis/latest - 청약 진단 최신 결과 조회 */ +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..0c728d7 --- /dev/null +++ b/src/features/eligibility/api/diagnosisTypes.ts @@ -0,0 +1,121 @@ +/** 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 (공통 제외). GET /diagnosis/latest 응답에 소득분위·지원대상이 포함 */ +export interface DiagnosisResultData { + eligible: boolean; + decisionMessage: string; + recommended: string[]; + /** 내 소득분위 (예: "1분위")*/ + incomeLevel?: string; + /** 나의 지원 가능 대상 */ + targetGroups?: 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..a2e58c7 --- /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 { DiagnosisResultData } 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 DiagnosisResultData | 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/ui/result/diagnosisResultBanner.tsx b/src/features/eligibility/ui/result/diagnosisResultBanner.tsx new file mode 100644 index 0000000..d159eb5 --- /dev/null +++ b/src/features/eligibility/ui/result/diagnosisResultBanner.tsx @@ -0,0 +1,65 @@ +"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..d9d21f2 --- /dev/null +++ b/src/features/eligibility/ui/result/diagnosisResultItem.tsx @@ -0,0 +1,64 @@ +"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..57836f2 --- /dev/null +++ b/src/features/eligibility/ui/result/diagnosisResultList.tsx @@ -0,0 +1,77 @@ +"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/index.ts b/src/features/eligibility/ui/result/index.ts index b79c9d2..98561a7 100644 --- a/src/features/eligibility/ui/result/index.ts +++ b/src/features/eligibility/ui/result/index.ts @@ -2,3 +2,10 @@ 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/ui/homeActionCardList.tsx b/src/features/home/ui/homeActionCardList.tsx index 510cbc6..f97d534 100644 --- a/src/features/home/ui/homeActionCardList.tsx +++ b/src/features/home/ui/homeActionCardList.tsx @@ -1,12 +1,13 @@ "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"; +import { useDiagnosisResultStore } from "@/src/features/eligibility/model/diagnosisResultStore"; export const ActionCardList = () => { const { data } = useNoticeCount(); const { data: recommend } = useRecommendedNotice(); + const hasDiagnosisResult = useDiagnosisResultStore(state => state.result != null); const count = data?.count; const { onListingsPageMove, onEligibilityPageMove } = useHomeActionCard(); @@ -46,13 +47,13 @@ export const ActionCardList = () => {

- {recommend?.pages?.length ? recommend?.pages?.length : "0"}건 + {recommend?.pages?.[0]?.totalElements ?? 0}건

-

0% 완료

+

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

diff --git a/src/features/mypage/model/mypageConstants.ts b/src/features/mypage/model/mypageConstants.ts index 6b83e51..591a875 100644 --- a/src/features/mypage/model/mypageConstants.ts +++ b/src/features/mypage/model/mypageConstants.ts @@ -35,6 +35,11 @@ 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 = "설정"; diff --git a/src/features/mypage/ui/pinReportSection.tsx b/src/features/mypage/ui/pinReportSection.tsx index 79ac1fe..b3d5ab6 100644 --- a/src/features/mypage/ui/pinReportSection.tsx +++ b/src/features/mypage/ui/pinReportSection.tsx @@ -1,21 +1,145 @@ "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 { DiagnosisResultData } from "@/src/features/eligibility/api/diagnosisTypes"; +import { useDiagnosisResultStore } from "@/src/features/eligibility/model/diagnosisResultStore"; interface PinReportSectionProps { + /** 자격진단 최신 결과. 있으면 요약 카드, 없으면 '자격진단 하러가기' 빈 상태 표시 */ + diagnosisResult: DiagnosisResultData | null; onDiagnosisClick?: () => void; + onRediagnosisClick?: () => void; + onViewDetailClick?: () => void; } const PIN_REPORT_HEADING_ID = "pin-report-heading"; +const TAG_CLASS = + "inline-flex items-center rounded-full bg-primary-blue-100 px-2.5 py-1 text-xs font-medium text-primary-blue-700"; + +/** recommended 항목에서 housingType만 추출 (예: "통합공공임대 : 청년 특별공급" → "통합공공임대") */ +function getUniqueHousingTypes(recommended: string[]): string[] { + const set = new Set(); + const sep = " : "; + for (const raw of recommended) { + const idx = raw.indexOf(sep); + const housingType = (idx === -1 ? raw : raw.slice(0, idx)).trim(); + if (housingType) set.add(housingType); + } + return Array.from(set); +} export const PinReportSection = ({ + diagnosisResult, onDiagnosisClick, + onRediagnosisClick, + onViewDetailClick, }: PinReportSectionProps) => { + const storeIncomeLevel = useDiagnosisResultStore(s => s.incomeLevel); + + const hasResult = diagnosisResult != null && Array.isArray(diagnosisResult.recommended); + const incomeLevel = + diagnosisResult?.incomeLevel ?? storeIncomeLevel ?? null; + const targetGroups = diagnosisResult?.targetGroups ?? []; + const housingTypes = useMemo( + () => + hasResult && diagnosisResult?.recommended?.length + ? getUniqueHousingTypes(diagnosisResult.recommended) + : [], + [hasResult, diagnosisResult?.recommended] + ); + + if (hasResult) { + return ( +
+
+

+ {MYPAGE_PIN_REPORT_TITLE} +

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

+ {MYPAGE_PIN_REPORT_INCOME_LABEL} +

+ {incomeLevel} +
+ )} + + {targetGroups.length > 0 && ( +
+

+ {MYPAGE_PIN_REPORT_TARGET_LABEL} +

+
+ {targetGroups.map(label => ( + + {label} + + ))} +
+
+ )} + + {housingTypes.length > 0 && ( +
+

+ {MYPAGE_PIN_REPORT_HOUSING_LABEL} +

+
+ {housingTypes.map(name => ( + + {name} + + ))} +
+
+ )} + + {onViewDetailClick && ( +
+ +
+ )} +
+
+ ); + } + return (
{ 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/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/ui/errorState/ErrorState.tsx b/src/shared/ui/errorState/ErrorState.tsx index a375d6c..c22a0e3 100644 --- a/src/shared/ui/errorState/ErrorState.tsx +++ b/src/shared/ui/errorState/ErrorState.tsx @@ -29,10 +29,7 @@ export const ErrorState = ({ return (
- home으로 돌아가기 + 홈으로 돌아가기 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/modal/default/modal.tsx b/src/shared/ui/modal/default/modal.tsx index ceb8746..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"; @@ -13,17 +17,32 @@ export const Modal = ({ 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?.(); + }} + >
@@ -31,20 +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 d60802c..fefc7a0 100644 --- a/src/shared/ui/modal/default/type.ts +++ b/src/shared/ui/modal/default/type.ts @@ -20,4 +20,8 @@ export interface ModalProps { 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 26e470b..49468a0 100644 --- a/src/widgets/eligibilitySection/index.ts +++ b/src/widgets/eligibilitySection/index.ts @@ -1,2 +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..2d6b24e --- /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 index aefab08..e67a7dc 100644 --- a/src/widgets/eligibilitySection/ui/eligibilityResultSection.tsx +++ b/src/widgets/eligibilitySection/ui/eligibilityResultSection.tsx @@ -2,6 +2,9 @@ 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, @@ -10,39 +13,74 @@ 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/mypageSection/ui/MypageSection.tsx b/src/widgets/mypageSection/ui/MypageSection.tsx index f7e57cc..795e371 100644 --- a/src/widgets/mypageSection/ui/MypageSection.tsx +++ b/src/widgets/mypageSection/ui/MypageSection.tsx @@ -1,5 +1,6 @@ "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"; @@ -23,8 +24,12 @@ import { 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"; /** * 마이페이지 메인 화면 위젯 @@ -33,7 +38,28 @@ import { LoadingState } from "@/src/shared/ui/loadingState"; */ 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(diagnosisLatest, { + incomeLevel: diagnosisLatest.incomeLevel, + }); + } + }, [diagnosisLatest, setDiagnosisResult]); if (isLoading) { return ( @@ -56,55 +82,65 @@ export const MypageSection = () => { return (
- -
- router.push("/mypage/settings")} - /> + + +
+ router.push("/mypage/settings")} /> - router.push("/eligibility")} - /> + - , - label: MYPAGE_LABEL_INTEREST_ENV, - onClick: () => { - alert("관심 주변 환경 설정 미구현 상태"); - }, - }, - { - icon: , - label: MYPAGE_LABEL_PINPOINTS, - onClick: () => router.push("/mypage/pinpoints"), - }, - ]} - /> + setEligibilityModalOpen(false)} + onButtonClick={handleModalConfirm} + /> - , - label: MYPAGE_LABEL_SAVED_LIST, - onClick: () => { - alert("저장 목록 이동 미구현 상태"); - }, - }, - { - icon: , - label: MYPAGE_LABEL_RECENT_ADS, - onClick: () => { - alert("최근 본 공고 이동 미구현 상태"); - }, - }, - ]} - /> -
+ , + 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 index 95b3e3e..22bb868 100644 --- a/src/widgets/mypageSection/ui/PinpointsSection.tsx +++ b/src/widgets/mypageSection/ui/PinpointsSection.tsx @@ -15,6 +15,7 @@ import { } 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"; /** * 마이페이지 핀포인트 설정 화면 위젯 @@ -42,37 +43,40 @@ export const PinpointsSection = () => { return (
-
- + +
+
-
-
+
+
-
-

- {MYPAGE_LABEL_PINPOINTS} -

-

+

+

{MYPAGE_LABEL_PINPOINTS}

+

{MYPAGE_PINPOINTS_DESCRIPTION} -

-
+

+
-
+
{address ? ( -
+
-
+
) : null} +
); }; diff --git a/src/widgets/mypageSection/ui/ProfileSection.tsx b/src/widgets/mypageSection/ui/ProfileSection.tsx index 4cb5040..5198ba2 100644 --- a/src/widgets/mypageSection/ui/ProfileSection.tsx +++ b/src/widgets/mypageSection/ui/ProfileSection.tsx @@ -8,6 +8,7 @@ import { 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"; @@ -41,15 +42,18 @@ export const ProfileSection = () => { return (
-
- + +
+
- +
+
); }; diff --git a/src/widgets/mypageSection/ui/SettingsSection.tsx b/src/widgets/mypageSection/ui/SettingsSection.tsx index ff19a9c..3eab101 100644 --- a/src/widgets/mypageSection/ui/SettingsSection.tsx +++ b/src/widgets/mypageSection/ui/SettingsSection.tsx @@ -7,10 +7,8 @@ import { MYPAGE_SETTINGS_TITLE, MYPAGE_SETTINGS_WITHDRAW, } from "@/src/features/mypage/model/mypageConstants"; -import { - MypageSettingsMenu, - type MypageSettingsMenuItem, -} from "@/src/features/mypage/ui"; +import { MypageSettingsMenu, type MypageSettingsMenuItem } from "@/src/features/mypage/ui"; +import { PageTransition } from "@/src/shared/ui/animation/pageTransition"; import { DefaultHeader } from "@/src/shared/ui/header"; /** @@ -26,13 +24,15 @@ export const SettingsSection = () => { return (
-
- -
-
-
- -
+ +
+ +
+
+
+ +
+
); }; diff --git a/src/widgets/mypageSection/ui/WithdrawSection.tsx b/src/widgets/mypageSection/ui/WithdrawSection.tsx index df11155..0d6b127 100644 --- a/src/widgets/mypageSection/ui/WithdrawSection.tsx +++ b/src/widgets/mypageSection/ui/WithdrawSection.tsx @@ -2,11 +2,12 @@ import { WithdrawBanner, WithdrawForm } from "@/src/features/mypage/ui"; import { - MYPAGE_WITHDRAW_HEADER_TITLE, + 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"; /** * 마이페이지 회원 탈퇴 화면 위젯 @@ -14,19 +15,24 @@ import { DefaultHeader } from "@/src/shared/ui/header"; export const WithdrawSection = () => { return (
-
- + +
+
- -
-
- -
+ +
+
+ +
+
); };