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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions app/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import CompareSidebar from "../components/mypage/CompareSidebar";
import CompareMain from "../components/mypage/CompareMain";
import { useSearchParams, useRouter } from "next/navigation";
Expand All @@ -12,15 +12,25 @@ import { getCurateQueryOptions } from "@/shared/api/generated/personalized-curat
import { useQueries } from "@tanstack/react-query";
import { DealInfoDealType } from "@/shared/api/generated/model/dealInfoDealType";
import { formatNumberToKoreanPrice } from "@/app/utils/format";
import { useUserStore } from "@/app/store/userStore";

import { Suspense } from "react";

// Shared interfaces for simplicity in this file
// ... House interface was here ...
// ... MOCK_HOUSES, MOCK_CURATION, MOCK_BASIC_INFO were here ...

const CONDITION_MAP: Record<number, string> = {
1: "소음",
2: "환경",
3: "안전",
4: "접근성",
5: "편의",
};

const ComparePageContent = () => {
const router = useRouter();
const { user } = useUserStore();
const searchParams = useSearchParams();
const idsParam = searchParams.get("ids");
const initialIds = idsParam ? idsParam.split(",") : [];
Expand Down Expand Up @@ -116,15 +126,33 @@ const ComparePageContent = () => {
);
const curationInfo = curationQueries[index]?.data?.data;

// 점수 정보를 배열로 변환하여 정렬
const allScores = [
{ label: "소음", score: compareInfo?.noiseScore || 0 },
{ label: "환경", score: compareInfo?.environmentScore || 0 },
{ label: "안전", score: compareInfo?.safetyScore || 0 },
{ label: "접근성", score: compareInfo?.accessibilityScore || 0 },
{ label: "편의", score: compareInfo?.convenienceScore || 0 },
];

// 점수 높은 순으로 상위 3개 추출
const top3Strengths = allScores
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map((s) => s.label);

// 사용자의 선호 조건 (한글 명칭으로 변환)
const userPrefs =
user?.preferredConditions?.map((c) => CONDITION_MAP[c]) || [];

// 매물의 강점(상위 3개)과 사용자의 선호 조건의 교집합 필터링
const personalizedTags = top3Strengths.filter((tag) =>
userPrefs.includes(tag),
) as ("소음" | "안전" | "접근성" | "편의" | "환경")[];

return {
summary: curationInfo?.description || "큐레이션 분석 중입니다...",
tags: ["안전", "편의"] as (
| "소음"
| "안전"
| "접근성"
| "편의"
| "환경"
)[], // 백엔드에서 제공하지 않으므로 기본태그
tags: personalizedTags,
scores: {
소음: compareInfo?.noiseScore || 0,
환경: compareInfo?.environmentScore || 0,
Expand Down
26 changes: 22 additions & 4 deletions app/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,28 @@ import { logoutAction } from "@/app/login/actions";
import { useRouter, usePathname } from "next/navigation";

import { usePropertyStore } from "@/app/store/propertyStore";
import { useModuleStore } from "@/app/store/moduleStore";
import { GetMainPropertyPropertyType } from "@/shared/api/generated/model/getMainPropertyPropertyType";

export default function Navbar() {
const router = useRouter();
const pathname = usePathname();
const { user, clearUser } = useUserStore();
const { selectedPropertyType, setPropertyType, syncWithUserPreference } =
usePropertyStore();
const {
selectedPropertyType,
setPropertyType,
syncWithUserPreference,
resetFilters,
} = usePropertyStore();
const { resetToUserPreference } = useModuleStore();
const [mounted, setMounted] = useState(false);

// hydration 에러 방지
useEffect(() => {
setMounted(true);
// Cascading render 방지를 위해 비동기 처리
setTimeout(() => {
setMounted(true);
}, 0);
}, []);

// 초기 유저 선호도 동기화
Expand Down Expand Up @@ -65,7 +74,13 @@ export default function Navbar() {
];

const handleMenuClick = (type: GetMainPropertyPropertyType) => {
// 1. 주거 형태 변경
setPropertyType(type);
// 2. 검색 필터 초기화
resetFilters();
// 3. 우선순위 모듈 초기화 (유저 선호도로 복구)
resetToUserPreference(user?.preferredConditions || []);
// 4. 지도 페이지로 이동
router.push("/map");
};

Expand Down Expand Up @@ -109,7 +124,10 @@ export default function Navbar() {
{mounted && user ? (
<>
{/* 찜한 목록 */}
<button className="font-semibold text-[#434343] leading-[1.1] text-[16px] hover:text-black transition-colors">
<button
onClick={() => router.push("/mypage?menu=liked")}
className="font-semibold text-[#434343] leading-[1.1] text-[16px] hover:text-black transition-colors"
>
찜한 목록
</button>
<div className="bg-border-1 h-5.5 w-px" />
Expand Down
167 changes: 160 additions & 7 deletions app/components/list/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ListDetail from "./ListDetail";
import PriorityToggle from "../ui/PriorityToggle";
import { useUserStore } from "@/app/store/userStore";
import { useModuleStore, ModuleId } from "@/app/store/moduleStore";
import { useMapModeStore } from "@/app/store/mapModeStore";
import { useGetMainProperty } from "@/shared/api/generated/main-property-controller/main-property-controller";
import { DealInfoDealType } from "@/shared/api/generated/model/dealInfoDealType";
import { formatNumberToKoreanPrice } from "@/app/utils/format";
Expand All @@ -31,6 +32,10 @@ const PROPERTY_TYPE_MAP: Record<string, string> = {

import { usePropertyStore } from "@/app/store/propertyStore";

import { useSearchProperty } from "@/shared/api/generated/main-property-controller/main-property-controller";
import { PropertySearchRequestDTODealType } from "@/shared/api/generated/model/propertySearchRequestDTODealType";
import { PropertySearchRequestDTOPropertyType } from "@/shared/api/generated/model/propertySearchRequestDTOPropertyType";

const List = () => {
const [selectedId, setSelectedId] = useState<number | null>(null);
const [hasMounted, setHasMounted] = useState(false);
Expand All @@ -39,7 +44,17 @@ const List = () => {
const { activeModules, toggleModule } = useModuleStore();
const { toggleZzim } = usePropertyZzim();
const { addViewedId } = useRecentViewStore();
const { selectedPropertyType } = usePropertyStore();
const {
selectedPropertyType,
keyword,
selectedTypes,
depositRange,
monthlyRentRange,
salePriceRange,
spaceRange,
} = usePropertyStore();

const { setPropertiesOnMap } = useMapModeStore();

useEffect(() => {
const timer = setTimeout(() => {
Expand All @@ -55,24 +70,145 @@ const List = () => {
if (hasMounted) {
const propertyIdParam = searchParams.get("propertyId");
if (propertyIdParam) {
setSelectedId(Number(propertyIdParam));
// Cascading render 방지를 위해 비동기 처리
setTimeout(() => {
setSelectedId(Number(propertyIdParam));
}, 0);
}
}
}, [hasMounted, searchParams]);

const { data: apiResponse, isLoading } = useGetMainProperty(
// 필터가 하나라도 적용되었는지 확인 (주거 형태 필터 제외, 우선순위 바 활성화 포함)
const isFilterActive =
keyword.trim() !== "" ||
selectedTypes.length > 0 ||
spaceRange[0] !== 0 ||
spaceRange[1] !== 60 ||
activeModules.length > 0;

// 공통 ID 맵
const mIdMap: Record<ModuleId, number> = {
noise: 1,
environment: 2,
safety: 3,
accessibility: 4,
convenience: 5,
};

// 요청할 우선순위 조건 생성 (활성 모듈 우선 + 프로필 선호도 보완 + 최대 3개 제한)
const getSelectConditions = () => {
const activeIds = activeModules.map((m) => mIdMap[m]);
const preferredIds = user?.preferredConditions || [];
// 활성 모듈을 앞에 두고 중복 제거 후 최대 3개 추출
return Array.from(new Set([...activeIds, ...preferredIds])).slice(0, 3);
};

// 메인 매물 조회 (기본 추천)
const mainPropertyQuery = useGetMainProperty(
{
select: user?.preferredConditions || [],
select: Array.from(new Set(user?.preferredConditions || [])).slice(0, 3),
propertyType: selectedPropertyType,
size: 10,
size: 30,
},
{
query: {
enabled: !!user && hasMounted && !isFilterActive,
},
},
);

// 매물 검색 조회 (필터/키워드 적용 시)
const searchPropertyQuery = useSearchProperty(
{
requestDTO: {
keyword: keyword || undefined,
propertyType:
selectedPropertyType as PropertySearchRequestDTOPropertyType,
dealType: selectedTypes.includes("매매")
? PropertySearchRequestDTODealType.SALE
: selectedTypes.includes("전세")
? PropertySearchRequestDTODealType.LEASE
: selectedTypes.includes("월세")
? PropertySearchRequestDTODealType.RENT
: undefined,
minDeposit: selectedTypes.some((t) => ["월세", "전세"].includes(t))
? depositRange[0]
: undefined,
maxDeposit: selectedTypes.some((t) => ["월세", "전세"].includes(t))
? depositRange[1] >= 100000
? undefined
: depositRange[1]
: undefined,
minMonthlyRent: selectedTypes.includes("월세")
? monthlyRentRange[0]
: undefined,
maxMonthlyRent: selectedTypes.includes("월세")
? monthlyRentRange[1] >= 500
? undefined
: monthlyRentRange[1]
: undefined,
minPrice: selectedTypes.includes("매매")
? salePriceRange[0]
: undefined,
maxPrice: selectedTypes.includes("매매")
? salePriceRange[1] >= 1000000
? undefined
: salePriceRange[1]
: undefined,
minExclusiveArea: spaceRange[0] * 3.3058, // 평 -> m2 변환
maxExclusiveArea:
spaceRange[1] >= 60 ? undefined : spaceRange[1] * 3.3058,
selectConditions: getSelectConditions(),
size: 30,
},
},
{
query: {
enabled: !!user && hasMounted,
enabled: !!user && hasMounted && isFilterActive,
},
},
);

const apiResponse = isFilterActive
? searchPropertyQuery.data
: mainPropertyQuery.data;
const isLoading = isFilterActive
? searchPropertyQuery.isLoading
: mainPropertyQuery.isLoading;

// 현재 리스트의 매물들을 지도에 표시하기 위해 동기화
useEffect(() => {
if (apiResponse?.data?.items) {
const markers = apiResponse.data.items
.filter((item) => item.latitude && item.longitude)
.map((item) => {
let priceStr = "";
let dealLabel = "";
const deal = item.dealInfo;
if (deal?.dealType === DealInfoDealType.RENT) {
priceStr = `${deal.deposit}/${deal.monthlyRent}`;
dealLabel = "월세";
} else if (deal?.dealType === DealInfoDealType.LEASE) {
priceStr = formatNumberToKoreanPrice(deal.price || 0);
dealLabel = "전세";
} else if (deal?.dealType === DealInfoDealType.SALE) {
priceStr = formatNumberToKoreanPrice(deal.price || 0);
dealLabel = "매매";
}

return {
id: item.propertyId || 0,
lat: item.latitude!,
lng: item.longitude!,
title: item.apartmentName || "",
price: priceStr,
dealType: dealLabel,
};
});
setPropertiesOnMap(markers);
}
}, [apiResponse, setPropertiesOnMap]);

const houses =
apiResponse?.data?.items?.map((item, index) => {
let priceStr = "";
Expand All @@ -85,6 +221,7 @@ const List = () => {
priceStr = `매매 ${formatNumberToKoreanPrice(deal.price || 0)}`;
}

const conditions = item.conditions || [];
return {
id: item.propertyId || 0,
rank: index + 1,
Expand All @@ -94,7 +231,8 @@ const List = () => {
type: PROPERTY_TYPE_MAP[item.propertyType || ""] || "아파트",
area: `${item.supplyArea || 0}/${item.exclusiveArea || 0}m²`,
floor: `${item.floor || 0}/${item.totalFloor || 0}층`,
tags: (item.conditions || []).map((c) => CONDITION_MAP[c] || ""),
tags: conditions.map((c) => CONDITION_MAP[c] || ""),
conditionIds: conditions,
isLiked: item.liked,
};
}) || [];
Expand All @@ -104,6 +242,20 @@ const List = () => {
activeModules.forEach((id) => toggleModule(id));
};

const handleTagClick = (conditionId: number) => {
const idMap: Record<number, ModuleId> = {
1: "noise",
2: "environment",
3: "safety",
4: "accessibility",
5: "convenience",
};
const moduleId = idMap[conditionId];
if (moduleId) {
toggleModule(moduleId);
}
};

// Hydration을 방지하기 위해 마운트 전에는 뼈대만 렌더링하거나 기본값을 렌더링
const username = hasMounted ? user?.username : "";
const displayActiveModules = hasMounted ? activeModules : [];
Expand Down Expand Up @@ -182,6 +334,7 @@ const List = () => {
setSelectedId(id);
addViewedId(id);
}}
onTagClick={handleTagClick}
onToggleZzim={toggleZzim}
/>
))
Expand Down
Loading
Loading