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/listings/ui/listingsCardDetail/button/button.tsx b/src/features/listings/ui/listingsCardDetail/button/button.tsx index 58e000d..0d419aa 100644 --- a/src/features/listings/ui/listingsCardDetail/button/button.tsx +++ b/src/features/listings/ui/listingsCardDetail/button/button.tsx @@ -1,10 +1,13 @@ -import { useDetailFilterResultButton } from "@/src/features/listings/ui/listingsCardDetail/hooks/hooks"; +import { useDetailFilterResultButton } from "@/src/features/listings/ui/listingsCardDetail/hooks/routerHooks"; type ListingCardDetailProps = { filteredCount: number; handleCloseSheet: () => void; }; -export const ListingCardDetailOut = ({ filteredCount, handleCloseSheet }: ListingCardDetailProps) => { +export const ListingCardDetailOut = ({ + filteredCount, + handleCloseSheet, +}: ListingCardDetailProps) => { return (
); -}; \ No newline at end of file +}; diff --git a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DetailFilterSheet.tsx b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DetailFilterSheet.tsx index 0088d21..2c5cca9 100644 --- a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DetailFilterSheet.tsx +++ b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DetailFilterSheet.tsx @@ -13,7 +13,7 @@ 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/hooks"; +import { useDetailFilterResultButton } from "@/src/features/listings/ui/listingsCardDetail/hooks/routerHooks"; export const DetailFilterSheet = () => { const open = useDetailFilterSheetStore(s => s.open); diff --git a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DistanceFilter.tsx b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DistanceFilter.tsx index 3fd3c2c..fe7db87 100644 --- a/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DistanceFilter.tsx +++ b/src/features/listings/ui/listingsCardDetail/components/DetailSectionFilter/DistanceFilter.tsx @@ -9,34 +9,22 @@ import { 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 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 (
@@ -54,7 +42,7 @@ export const DistanceFilter = () => { types="myPinPoint" data={pinPointList} size="lg" - onChange={onChageValue} + onChange={onChangeValue} disabled={isFetching || !hasPinPoints} > {dropDownTriggerLabel} @@ -75,7 +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 6d710b5..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,134 +1,28 @@ "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"; -import { ListingCardDetailOut } from "@/src/features/listings/ui/listingsCardDetail/button/button"; - -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 === "" ? "0" :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; - console.log(values) - if(values === ""){ - return setHandleDepositInput(""); - } - 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 (
@@ -139,18 +33,15 @@ export const CostFilter = () => {

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

{ 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", @@ -58,35 +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/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/hooks.ts b/src/features/listings/ui/listingsCardDetail/hooks/routerHooks.ts similarity index 93% rename from src/features/listings/ui/listingsCardDetail/hooks/hooks.ts rename to src/features/listings/ui/listingsCardDetail/hooks/routerHooks.ts index 36c6cc2..2cabf1c 100644 --- a/src/features/listings/ui/listingsCardDetail/hooks/hooks.ts +++ b/src/features/listings/ui/listingsCardDetail/hooks/routerHooks.ts @@ -28,13 +28,10 @@ export const useDetailFilterResultButton = () => { } catch (error) { console.error("[ListingFilterPartialSheet] Failed to close sheet", error); } - }; - - return { - filteredCount, - handleCloseSheet, - } - + return { + filteredCount, + handleCloseSheet, + }; };