diff --git a/frontend/src/apis/url.ts b/frontend/src/apis/url.ts index 7bf577230..71c936628 100644 --- a/frontend/src/apis/url.ts +++ b/frontend/src/apis/url.ts @@ -14,6 +14,8 @@ export const ENDPOINT = { CHECKLIST_CUSTOM: '/custom-checklist', CHECKLIST_ID: (id: number) => `/checklists/${id}`, CHECKLIST_ID_V1: (id: number) => `/v1/checklists/${id}`, + //compare + CHECKLIST_COMPARE: (roomId1: number, roomId2: number) => `/v1/checklists/compare?id=${roomId1}&id=${roomId2}`, // like LIKE: (id: number | ':id') => `/checklists/${id}/like`, diff --git a/frontend/src/assets/assets.tsx b/frontend/src/assets/assets.tsx index 60cdffe81..be699ace6 100644 --- a/frontend/src/assets/assets.tsx +++ b/frontend/src/assets/assets.tsx @@ -16,6 +16,13 @@ import Retry from '@/assets/icons/common/retry.svg'; import SmallCheck from '@/assets/icons/common/small-check.svg'; import PlusBlack from '@/assets/icons/plusMinus/plus-black.svg'; import PlusWhite from '@/assets/icons/plusMinus/plus-white.svg'; + +//face-icon +import FaceBadIcon from '@/assets/icons/faceIcon/face-icon-bad.svg'; +import FaceGoodIcon from '@/assets/icons/faceIcon/face-icon.good.svg'; +import FaceNoneIcon from '@/assets/icons/faceIcon/face-icon.none.svg'; +import FaceSosoIcon from '@/assets/icons/faceIcon/face-icon.soso.svg'; + // room import Building from '@/assets/icons/room/building.svg'; import Calendar from '@/assets/icons/room/calendar.svg'; @@ -77,6 +84,10 @@ export { DropdownMark, Error404, Error500, + FaceBadIcon, + FaceGoodIcon, + FaceNoneIcon, + FaceSosoIcon, InputRequiredDot, KakaoLogo, LampIcon, diff --git a/frontend/src/assets/icons/answer/bad.tsx b/frontend/src/assets/icons/answer/bad.tsx index 1b3f5c9a0..7bd0fa527 100644 --- a/frontend/src/assets/icons/answer/bad.tsx +++ b/frontend/src/assets/icons/answer/bad.tsx @@ -1,6 +1,13 @@ -const Bad = ({ color, ...rest }: React.SVGProps) => { +const Bad = ({ color, width, ...rest }: React.SVGProps) => { return ( - + ) => { +const Good = ({ color, width, ...rest }: React.SVGProps) => { return ( - + ); diff --git a/frontend/src/assets/icons/faceIcon/face-icon-bad.svg b/frontend/src/assets/icons/faceIcon/face-icon-bad.svg new file mode 100644 index 000000000..87b1728ee --- /dev/null +++ b/frontend/src/assets/icons/faceIcon/face-icon-bad.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/faceIcon/face-icon.good.svg b/frontend/src/assets/icons/faceIcon/face-icon.good.svg new file mode 100644 index 000000000..47e287926 --- /dev/null +++ b/frontend/src/assets/icons/faceIcon/face-icon.good.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/faceIcon/face-icon.none.svg b/frontend/src/assets/icons/faceIcon/face-icon.none.svg new file mode 100644 index 000000000..c2641728c --- /dev/null +++ b/frontend/src/assets/icons/faceIcon/face-icon.none.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/faceIcon/face-icon.soso.svg b/frontend/src/assets/icons/faceIcon/face-icon.soso.svg new file mode 100644 index 000000000..5ae9767ff --- /dev/null +++ b/frontend/src/assets/icons/faceIcon/face-icon.soso.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/components/RoomCompare/CategoryDetailModal.tsx b/frontend/src/components/RoomCompare/CategoryDetailModal.tsx new file mode 100644 index 000000000..576d630a1 --- /dev/null +++ b/frontend/src/components/RoomCompare/CategoryDetailModal.tsx @@ -0,0 +1,19 @@ +import Modal from '@/components/_common/Modal/Modal'; + +interface Props { + isOpen: boolean; + closeModal: () => void; +} + +const CategoryDetailModal = ({ isOpen, closeModal }: Props) => { + return ( + + 카테고리 비교 + +
카테고리 비교 내용이 들어갑니다.
+ + + ); +}; + +export default CategoryDetailModal; diff --git a/frontend/src/components/RoomCompare/CategoryScore.tsx b/frontend/src/components/RoomCompare/CategoryScore.tsx new file mode 100644 index 000000000..f47b63fc5 --- /dev/null +++ b/frontend/src/components/RoomCompare/CategoryScore.tsx @@ -0,0 +1,53 @@ +import styled from '@emotion/styled'; + +import FaceIcon from '@/components/_common/FaceIcon/FaceIcon'; +import FlexBox from '@/components/_common/FlexBox/FlexBox'; +import { MIN_GOOD_SCORE, MIN_SOSO_SCORE } from '@/constants/system'; +import { boxShadow, flexCenter } from '@/styles/common'; + +interface Props { + roomId: number; + categoryId: number; + score: number | null; + openCategoryModal: (roomId: number, categoryId: number) => void; +} + +const CategoryScore = ({ roomId, categoryId, score, openCategoryModal }: Props) => { + const calcFaceIcon = (score: number | null) => { + if (score === null) return 'NONE'; + if (score >= MIN_GOOD_SCORE) return 'GOOD'; + if (score >= MIN_SOSO_SCORE) return 'SOSO'; + return 'BAD'; + }; + + return ( + + + + + + openCategoryModal(roomId, categoryId)}>{score === null ? '-' : `${score}%`} + + + ); +}; + +export default CategoryScore; + +const S = { + CategoryItemBox: styled.div` + width: 7rem; + + ${flexCenter} + text-align: center; + `, + Score: styled.span` + width: 4rem; + padding: 6px 8px; + border: 1px solid ${({ theme }) => theme.palette.grey300}; + + font-size: ${({ theme }) => theme.text.size.small}; + border-radius: 8px; + ${boxShadow} + `, +}; diff --git a/frontend/src/components/RoomCompare/CompareCard.tsx b/frontend/src/components/RoomCompare/CompareCard.tsx new file mode 100644 index 000000000..b93f53d90 --- /dev/null +++ b/frontend/src/components/RoomCompare/CompareCard.tsx @@ -0,0 +1,100 @@ +import styled from '@emotion/styled'; + +import SubwayStations from '@/components/_common/Subway/SubwayStations'; +import CategoryScore from '@/components/RoomCompare/CategoryScore'; +import CompareCardItem from '@/components/RoomCompare/CompareCardItem'; +import { EMPTY_INDICATOR } from '@/constants/system'; +import { boxShadow, flexColumn, title1, title4 } from '@/styles/common'; +import { ChecklistCompare } from '@/types/checklistCompare'; + +interface Props { + room: ChecklistCompare; + index: number; + openOptionModal: () => void; + openCategoryModal: (roomId: number, categoryId: number) => void; +} + +const CompareCard = ({ room, openOptionModal, openCategoryModal }: Props) => { + return ( + + {room.address}} /> + {room.floor ? `${room.floor}층` : EMPTY_INDICATOR}} /> + + {room.deposit ?? EMPTY_INDICATOR}/{room.rent ?? EMPTY_INDICATOR} + + } + /> + {`${room.structure ?? EMPTY_INDICATOR}/${room.size ? `${room.size}평` : EMPTY_INDICATOR}`} + } + /> + {room.contractTerm}개월} /> + } + /> + {room.options.length}개} + /> + {/*카테고리별 질문 평점 섹션*/} + {room.categories.map(category => ( + + } + /> + ))} + + ); +}; + +export default CompareCard; + +const S = { + Container: styled.div` + width: 100%; + padding: 20px 4px; + box-sizing: border-box; + ${flexColumn}; + align-items: center; + gap: 30px; + `, + RankWrapper: styled.div` + ${flexColumn} + gap: 5px; + `, + Rank: styled.div` + ${title1} + `, + Item: styled.div` + display: flex; + width: 100%; + + font-size: ${({ theme }) => theme.text.size.small}; + line-height: 1.5; + letter-spacing: 0.05rem; + text-align: center; + justify-content: center; + word-break: keep-all; + `, + OptionButton: styled.button` + ${title4} + padding: 12px 16px; + border: 1px solid ${({ theme }) => theme.palette.grey300}; + border-radius: 8px; + ${boxShadow} + `, +}; diff --git a/frontend/src/components/RoomCompare/CompareCardCategoryItem.tsx b/frontend/src/components/RoomCompare/CompareCardCategoryItem.tsx new file mode 100644 index 000000000..f327c62be --- /dev/null +++ b/frontend/src/components/RoomCompare/CompareCardCategoryItem.tsx @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; +import React from 'react'; + +import { flexCenter, flexColumn } from '@/styles/common'; + +interface Props { + label?: string; + isLabeled?: boolean; + item: React.ReactNode; + height?: number; + score: number; +} + +const CompareCardItem = ({ label, isLabeled = false, item, height, score }: Props) => { + return ( + + {label} + {item} + {score}% + + ); +}; + +export default CompareCardItem; + +const S = { + ItemContainer: styled.div<{ height?: number }>` + width: 100%; + height: ${({ height }) => height && height}rem; + ${flexColumn}; + gap: 1rem; + align-items: center; + `, + ItemText: styled.div` + height: 100%; + + ${flexCenter}; + font-size: ${({ theme }) => theme.text.size.small}; + line-height: 1.5rem; + `, + Score: styled.div` + font-size: ${({ theme }) => theme.text.size.xSmall}; + `, + Label: styled.div<{ isLabeled: boolean }>` + height: 1.5rem; + margin-bottom: 0.5rem; + + color: ${({ theme }) => theme.palette.grey500}; + font-size: ${({ theme }) => theme.text.size.xSmall}; + `, +}; diff --git a/frontend/src/components/RoomCompare/CompareCardItem.tsx b/frontend/src/components/RoomCompare/CompareCardItem.tsx new file mode 100644 index 000000000..7f7aa2140 --- /dev/null +++ b/frontend/src/components/RoomCompare/CompareCardItem.tsx @@ -0,0 +1,49 @@ +import styled from '@emotion/styled'; +import React from 'react'; + +import { flexCenter, flexColumn } from '@/styles/common'; + +interface Props { + label?: string; + isLabeled?: boolean; + item: React.ReactNode; + height?: number; +} + +const CompareCardItem = ({ label, isLabeled = false, item, height }: Props) => { + return ( + + {label} + {item} + + ); +}; + +export default CompareCardItem; + +const S = { + ItemContainer: styled.div<{ height?: number }>` + width: 100%; + height: ${({ height }) => height && height}rem; + ${flexColumn}; + gap: 1rem; + align-items: center; + `, + ItemText: styled.div` + height: 100%; + + ${flexCenter}; + font-size: ${({ theme }) => theme.text.size.small}; + line-height: 1.5rem; + `, + Score: styled.div` + font-size: ${({ theme }) => theme.text.size.xSmall}; + `, + Label: styled.div<{ isLabeled: boolean }>` + height: 1.5rem; + margin-bottom: 0.5rem; + + color: ${({ theme }) => theme.palette.grey500}; + font-size: ${({ theme }) => theme.text.size.xSmall}; + `, +}; diff --git a/frontend/src/components/RoomCompare/OptionDetailModal.tsx b/frontend/src/components/RoomCompare/OptionDetailModal.tsx new file mode 100644 index 000000000..7d8dddb10 --- /dev/null +++ b/frontend/src/components/RoomCompare/OptionDetailModal.tsx @@ -0,0 +1,84 @@ +import styled from '@emotion/styled'; + +import Bad from '@/assets/icons/answer/bad'; +import Good from '@/assets/icons/answer/good'; +import Modal from '@/components/_common/Modal/Modal'; +import { flexCenter, omitText } from '@/styles/common'; +import theme from '@/styles/theme'; + +interface Props { + roomTitle1: string; + roomTitle2: string; + isOpen: boolean; + closeModal: () => void; + hasOptions: hasOption[]; +} + +interface hasOption { + optionName: string; + hasRoom1: boolean; + hasRoom2: boolean; +} + +const OptionDetailModal = ({ roomTitle1, roomTitle2, isOpen, closeModal, hasOptions }: Props) => { + return ( + + 옵션 비교 + + + 옵션 + {roomTitle1} + {roomTitle2} + {hasOptions.map(option => { + const { optionName, hasRoom1, hasRoom2 } = option; + return ( + <> + {optionName} + + {hasRoom1 ? ( + + ) : ( + + )} + + + {hasRoom2 ? ( + + ) : ( + + )} + + + ); + })} + 총 개수 + 3개 + 5개 + + + + ); +}; + +export default OptionDetailModal; + +const S = { + Container: styled.div` + display: grid; + grid-template-columns: 0.8fr 1fr 1fr; + `, + ItemText: styled.div<{ isBold?: boolean; hasBorder?: boolean }>` + padding: 0.6rem 1rem; + + font-weight: ${({ theme, isBold }) => isBold && theme.text.weight.bold}; + ${omitText}; + text-align: center; + border-bottom: ${({ hasBorder, theme }) => hasBorder && `0.1rem solid ${theme.palette.grey200}};`}; + `, + Item: styled.div` + ${flexCenter}; + height: 2rem; + padding: 0.6rem 1rem; + border-bottom: 0.1rem solid ${({ theme }) => theme.palette.grey200}; + `, +}; diff --git a/frontend/src/components/RoomCompare/RoomMarker.tsx b/frontend/src/components/RoomCompare/RoomMarker.tsx new file mode 100644 index 000000000..ea3eccfc0 --- /dev/null +++ b/frontend/src/components/RoomCompare/RoomMarker.tsx @@ -0,0 +1,33 @@ +import styled from '@emotion/styled'; + +import { flexCenter } from '@/styles/common'; + +type Size = 'medium' | 'small'; +const RoomMarker = ({ type, size = 'medium', onClick }: { type: 'A' | 'B'; size?: Size; onClick?: () => void }) => { + return ( + + {type} + + ); +}; + +const S = { + RoomMarker: styled.span<{ type: string; size: Size }>` + display: inline; + width: ${({ size }) => (size === 'medium' ? '2.6rem' : '2rem')}; + height: ${({ size }) => (size === 'medium' ? '2.6rem' : '2rem')}; + + ${flexCenter}; + flex-shrink: 0; + + background-color: ${({ type, theme }) => (type === 'A' ? theme.palette.yellow600 : theme.palette.green600)}; + + color: white; + border-radius: 50%; + + font-weight: ${({ theme }) => theme.text.weight.bold}; + font-size: ${({ theme, size }) => (size === 'medium' ? theme.text.size.medium : theme.text.size.xSmall)}; + `, +}; + +export default RoomMarker; diff --git a/frontend/src/components/_common/FaceIcon/FaceIcon.tsx b/frontend/src/components/_common/FaceIcon/FaceIcon.tsx new file mode 100644 index 000000000..614db0973 --- /dev/null +++ b/frontend/src/components/_common/FaceIcon/FaceIcon.tsx @@ -0,0 +1,23 @@ +import { SVGProps } from 'react'; + +import { FaceBadIcon, FaceGoodIcon, FaceNoneIcon, FaceSosoIcon } from '@/assets/assets'; + +type EmotionNameWithNone = 'GOOD' | 'SOSO' | 'BAD' | 'NONE'; + +interface FaceIconProps extends SVGProps { + emotion: EmotionNameWithNone; + isFilled?: boolean; +} + +const FaceIcon = ({ emotion, ...rest }: FaceIconProps) => { + return ( + <> + {emotion === 'GOOD' && } + {emotion === 'SOSO' && } + {emotion === 'BAD' && } + {emotion === 'NONE' && } + + ); +}; + +export default FaceIcon; diff --git a/frontend/src/components/_common/Header/Header.tsx b/frontend/src/components/_common/Header/Header.tsx index 357990235..81e364876 100644 --- a/frontend/src/components/_common/Header/Header.tsx +++ b/frontend/src/components/_common/Header/Header.tsx @@ -19,9 +19,9 @@ const HeaderWrapper = ({ left, right, center, isTransparent = false, ...rest }: <> - {left && {left}} + {left} {center &&
{center}
}
- {right && {right}} + {right}
{!isTransparent && } @@ -58,7 +58,7 @@ const S = { display: flex; justify-content: flex-start; align-items: center; - min-width: 70px; + min-width: 5rem; `, Center: styled.div` ${flexCenter} @@ -67,6 +67,7 @@ const S = { Right: styled.div` display: flex; justify-content: flex-end; + min-width: 5rem; `, TextButton: styled.button` color: ${({ theme }) => theme.palette.black}; diff --git a/frontend/src/components/_common/Map/AddressMap.tsx b/frontend/src/components/_common/Map/AddressMap.tsx index 0ecb18bed..2e33c86d6 100644 --- a/frontend/src/components/_common/Map/AddressMap.tsx +++ b/frontend/src/components/_common/Map/AddressMap.tsx @@ -11,7 +11,7 @@ import loadExternalScriptWithCallback from '../../../utils/loadScript'; /* eslint-disable @typescript-eslint/no-explicit-any */ const AddressMap = ({ location }: { location: string }) => { - const mapContainerRef = useRef(null); + const mapElement = useRef(null); const markerRef = useRef(null); const { createMarker } = createKakaoMapElements(); @@ -21,20 +21,21 @@ const AddressMap = ({ location }: { location: string }) => { const { kakao } = window as any; kakao.maps.load(() => { - if (!mapContainerRef.current) return; + if (!mapElement.current) return; const mapOption = { center: new kakao.maps.LatLng(DEFAULT_POSITION.latitude, DEFAULT_POSITION.longitude), level: 3, }; - // 지도 생성 - const map = new kakao.maps.Map(mapContainerRef.current, mapOption); + + const map = new kakao.maps.Map(mapElement.current, mapOption); + // 주소-좌표 변환 객체 생성 const geocoder = new kakao.maps.services.Geocoder(); - // 주소로 좌표 검색 + // 주소로 좌표 검색하여 해당 좌표로 중심 이동 geocoder.addressSearch(location, (result: any, status: any) => { if (status === kakao.maps.services.Status.OK) { const coords = new kakao.maps.LatLng(result[0].y, result[0].x); - const marker = createMarker(kakao, map, coords); + const marker = createMarker(kakao, map, coords, 'primary'); markerRef.current = marker; map.setCenter(coords); } @@ -59,7 +60,7 @@ const AddressMap = ({ location }: { location: string }) => { <> {location && ( - + diff --git a/frontend/src/components/_common/Map/RealTimeMap.tsx b/frontend/src/components/_common/Map/RealTimeMap.tsx index 87b33dff8..d8aac2aad 100644 --- a/frontend/src/components/_common/Map/RealTimeMap.tsx +++ b/frontend/src/components/_common/Map/RealTimeMap.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { BangBangCryIcon } from '@/assets/assets'; import Button from '@/components/_common/Button/Button'; import { LoadingSpinner } from '@/components/_common/LoadingSpinner/LoadingSpinner'; +import { DEFAULT_POSITION } from '@/constants/map'; import { flexCenter } from '@/styles/common'; import { Position } from '@/types/address'; import createKakaoMapElements from '@/utils/createKakaoMapElements'; @@ -32,7 +33,7 @@ const RealTimeMap = ({ const infoWindowRef = useRef(null); const mapElement = useRef(null); - const { createMap, createMarker, createInfoWindow } = createKakaoMapElements(); + const { createMarker, createInfoWindow } = createKakaoMapElements(); const [realTimeLocationState, setRealTimeLocationState] = useState('loading'); const initializeMap = () => { @@ -40,11 +41,16 @@ const RealTimeMap = ({ kakao.maps.load(() => { /*카카오 맵 생성 */ - const map = createMap(kakao); + const mapOption = { + center: new kakao.maps.LatLng(DEFAULT_POSITION.latitude, DEFAULT_POSITION.longitude), + level: 3, + }; + + const map = new kakao.maps.Map(mapElement.current, mapOption); mapRef.current = map; /*마커 생성*/ - const marker = createMarker(kakao, map, new kakao.maps.LatLng(position.latitude, position.longitude)); + const marker = createMarker(kakao, map, new kakao.maps.LatLng(position.latitude, position.longitude), 'primary'); markerRef.current = marker; /*인포윈도우 생성*/ diff --git a/frontend/src/components/_common/Map/RoomCompareMap.tsx b/frontend/src/components/_common/Map/RoomCompareMap.tsx new file mode 100644 index 000000000..eba7603ca --- /dev/null +++ b/frontend/src/components/_common/Map/RoomCompareMap.tsx @@ -0,0 +1,130 @@ +import styled from '@emotion/styled'; +import { useEffect, useRef } from 'react'; + +import RoomMarker from '@/components/RoomCompare/RoomMarker'; +import { Position } from '@/types/address'; +import createKakaoMapElements from '@/utils/createKakaoMapElements'; +import { getDistanceFromLatLonInKm, getMapLevel } from '@/utils/mapHelper'; + +import loadExternalScriptWithCallback from '../../../utils/loadScript'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +const RoomCompareMap = ({ positions }: { positions: Position[] }) => { + const mapElement = useRef(null); + const mapRef = useRef(null); + + useEffect(() => { + const initializeMap = () => { + const { kakao } = window as any; + + const centerOfPosition = { + latitude: (positions[0].latitude + positions[1].latitude) / 2, + longitude: (positions[0].longitude + positions[1].longitude) / 2, + }; + + const diff = getDistanceFromLatLonInKm( + positions[0].latitude, + positions[0].longitude, + positions[1].latitude, + positions[1].longitude, + ); + + /* 두 지점의 거리를 재서 적당한 Map level 설정 */ + const mapLevel = getMapLevel(diff); + + kakao.maps.load(() => { + if (!mapElement.current) return; + const mapOption = { + center: new kakao.maps.LatLng(centerOfPosition.latitude, centerOfPosition.longitude), + level: mapLevel, + }; + + const map = new kakao.maps.Map(mapElement.current, mapOption); + mapRef.current = map; + + const { createMarker } = createKakaoMapElements(); + + const marker1 = createMarker( + kakao, + map, + new kakao.maps.LatLng(positions[0].latitude, positions[0].longitude), + 'primary', + 'first', + ); + + const marker2 = createMarker( + kakao, + map, + new kakao.maps.LatLng(positions[1].latitude, positions[1].longitude), + 'secondary', + 'second', + ); + + marker1.setMap(map); + marker2.setMap(map); + }); + }; + + if (location) { + loadExternalScriptWithCallback('kakaoMap', initializeMap); + } + }, [location]); + + const handleRoomMarkerClick = (positionIndex: number) => { + const move = () => { + const { kakao } = window as any; + + const moveLatLon = new kakao.maps.LatLng(positions[positionIndex].latitude, positions[positionIndex].longitude); + mapRef.current.setLevel(4); + mapRef.current.panTo(moveLatLon); + }; + + if (mapRef.current) { + loadExternalScriptWithCallback('kakaoMap', move); + } + }; + + return ( + <> + {location && ( + + + + handleRoomMarkerClick(0)} /> + handleRoomMarkerClick(1)} /> + + + + )} + + ); +}; + +const S = { + Box: styled.div` + width: 100%; + height: 20rem; + + background-color: ${({ theme }) => theme.palette.background}; + `, + Map: styled.div` + position: relative; + width: 100%; + height: 100%; + `, + RoomMarkBox: styled.div` + display: flex; + position: absolute; + right: 0; + bottom: 0; + z-index: 10; + + padding: 0.5rem; + + color: ${({ theme }) => theme.palette.white}; + gap: 1rem; + border-radius: 0.3rem; + `, +}; + +export default RoomCompareMap; diff --git a/frontend/src/components/_common/Subway/SubwayLineIcon/SubwayLineIcon.tsx b/frontend/src/components/_common/Subway/SubwayLineIcon/SubwayLineIcon.tsx index b8d1edd3c..71c2f53bf 100644 --- a/frontend/src/components/_common/Subway/SubwayLineIcon/SubwayLineIcon.tsx +++ b/frontend/src/components/_common/Subway/SubwayLineIcon/SubwayLineIcon.tsx @@ -1,30 +1,33 @@ import styled from '@emotion/styled'; -import { flexCenter, title4 } from '@/styles/common'; +import { flexCenter } from '@/styles/common'; import SUBWAY_LINE_PALLETE, { SubwayLineName } from '@/styles/subway'; +type Size = 'small' | 'medium'; interface Props { lineName: SubwayLineName; + size?: Size; } -const SubwayLineIcon = ({ lineName }: Props) => { +const sizeMap = { small: '1.4rem', medium: '2rem' }; + +const SubwayLineIcon = ({ lineName, size = 'medium' }: Props) => { const lineColor = SUBWAY_LINE_PALLETE[lineName]; const isNumberTypeSubwayName = lineName.slice(-2) === '호선' && lineName.length === 3; return ( - - {isNumberTypeSubwayName ? lineName.slice(0, lineName.length - 2) : lineName} + + {isNumberTypeSubwayName ? lineName.slice(0, lineName.length - 2) : lineName} ); }; const S = { - Box: styled.span<{ color: string; isCircle: boolean }>` + Box: styled.span<{ color: string; isCircle: boolean; size: Size }>` display: inline-block; - - width: ${({ isCircle }) => isCircle && '2rem'}; - height: 2rem; + width: ${({ isCircle, size }) => isCircle && sizeMap[size]}; + height: ${({ isCircle, size }) => isCircle && sizeMap[size]}; padding: ${({ isCircle }) => (isCircle ? '0.3rem' : '0.3rem 0.6rem')}; border-radius: 2rem; @@ -32,13 +35,14 @@ const S = { text-align: center; `, - Text: styled.span` + Text: styled.span<{ size: Size }>` width: 100%; height: 100%; ${flexCenter}; color: ${({ theme }) => theme.palette.white}; - ${title4} + font-weight: ${({ theme }) => theme.text.weight.semiBold}; + font-size: ${({ theme, size }) => (size === 'small' ? theme.text.size.xSmall : theme.text.size.small)}; `, }; diff --git a/frontend/src/components/_common/Subway/SubwayStationItem.tsx b/frontend/src/components/_common/Subway/SubwayStationItem.tsx index 025bccb04..36d4c0bba 100644 --- a/frontend/src/components/_common/Subway/SubwayStationItem.tsx +++ b/frontend/src/components/_common/Subway/SubwayStationItem.tsx @@ -7,15 +7,16 @@ import { SubwayStation } from '@/types/subway'; interface Props { station: SubwayStation; + size?: 'medium' | 'small'; } -const SubwayStationItem = ({ station }: Props) => { +const SubwayStationItem = ({ station, size }: Props) => { const { stationName, stationLine, walkingTime } = station; return ( - {stationLine?.map(oneLine => )} + {stationLine?.map(oneLine => )} {`${stationName}까지 도보 ${walkingTime}분`} diff --git a/frontend/src/components/_common/Subway/SubwayStations.tsx b/frontend/src/components/_common/Subway/SubwayStations.tsx index 354c97f74..69cb276fc 100644 --- a/frontend/src/components/_common/Subway/SubwayStations.tsx +++ b/frontend/src/components/_common/Subway/SubwayStations.tsx @@ -8,13 +8,16 @@ import { SubwayStation } from '@/types/subway'; interface Props { checklist?: ChecklistInfo; stations: SubwayStation[]; + size?: 'small' | 'medium'; } -const SubwayStations = ({ stations }: Props) => { +const SubwayStations = ({ stations, size }: Props) => { return ( <> {stations?.length ? ( - {stations?.map(station => )} + + {stations?.map(station => )} + ) : ( {'보신 방과 가까운 지하철역을 찾아드릴게요.'} )} diff --git a/frontend/src/constants/routePath.ts b/frontend/src/constants/routePath.ts index ea49e5a2e..aad2bc391 100644 --- a/frontend/src/constants/routePath.ts +++ b/frontend/src/constants/routePath.ts @@ -12,6 +12,8 @@ export const ROUTE_PATH = { checklistQuestionSelect: `/checklist/question-select`, checklistId: '/checklist/:checklistId', checklistOne: (id: number) => `/checklist/${id}`, + /*compare*/ + roomCompare: '/room/compare', /* article */ articleList: '/article', articleId: '/article/:articleId', diff --git a/frontend/src/constants/system.ts b/frontend/src/constants/system.ts index c6845f2c5..74bf9dd79 100644 --- a/frontend/src/constants/system.ts +++ b/frontend/src/constants/system.ts @@ -16,3 +16,9 @@ export const DEFAULT_CHECKLIST_TAB_PAGE = -1; export const STALE_TIME = 5 * 60 * 1000; export const INTERSECTION_CONFIG = { threshold: 0.5, rootMargin: '5px' }; + +export const EMPTY_INDICATOR = ' - '; + +export const MIN_GOOD_SCORE = 70; + +export const MIN_SOSO_SCORE = 30; diff --git a/frontend/src/mocks/fixtures/roomCompare.ts b/frontend/src/mocks/fixtures/roomCompare.ts new file mode 100644 index 000000000..5b81e87a2 --- /dev/null +++ b/frontend/src/mocks/fixtures/roomCompare.ts @@ -0,0 +1,109 @@ +import { nearSubway } from '@/mocks/fixtures/subway'; +import { ChecklistCompare } from '@/types/checklistCompare'; + +export const roomsForCompare: ChecklistCompare[] = [ + { + checklistId: 1, + roomName: '건대입구역 10분거리 방', + address: '서울 송파구 올림픽로35다길 42', + buildingName: '한국루터회관', + deposit: 1000, + rent: 50, + maintenanceFee: 5, + contractTerm: 12, + floor: 5, + realEstate: undefined, + structure: '오픈형 원룸', + size: 25, + floorLevel: '지상', + occupancyMonth: 9, + occupancyPeriod: '중순', + includedMaintenances: [2], + createdAt: '2024-02-01T10:00:00Z', + options: [1, 2, 3], + nearSubwayStations: nearSubway, + categories: [ + { + categoryId: 1, + categoryName: '청결', + score: 70, + }, + { + categoryId: 2, + categoryName: '편의시설', + score: 60, + }, + { + categoryId: 3, + categoryName: '화장실', + score: 40, + }, + { + categoryId: 4, + categoryName: '보안', + score: 20, + }, + { + categoryId: 4, + categoryName: '보안', + score: null, + }, + ], + geolocation: { + latitude: 37.5061912, + longitude: 127.0508228, + }, + }, + { + checklistId: 1, + roomName: '건대입구역 10분거리 방', + address: '서울 송파구 올림픽로35다길 42', + buildingName: '한국루터회관', + deposit: undefined, + rent: 50, + maintenanceFee: 5, + contractTerm: 12, + floor: 5, + realEstate: undefined, + structure: '오픈형 원룸', + size: 25, + floorLevel: '지상', + occupancyMonth: 9, + occupancyPeriod: '중순', + includedMaintenances: [2], + createdAt: '2024-02-01T10:00:00Z', + options: [1, 2, 3], + nearSubwayStations: nearSubway, + categories: [ + { + categoryId: 1, + categoryName: '청결', + score: 20, + }, + { + categoryId: 4, + categoryName: '보안', + score: null, + }, + { + categoryId: 2, + categoryName: '편의시설', + score: 50, + }, + { + categoryId: 3, + categoryName: '화장실', + score: 90, + }, + { + categoryId: 4, + categoryName: '보안', + score: 95, + }, + ], + geolocation: { + latitude: 37.5061912, + longitude: 127.2508228, + }, + }, +]; diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index e02c40b10..83c716d79 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -1,7 +1,15 @@ import { ArticleHandlers } from '@/mocks/handlers/article'; import { checklistHandlers } from '@/mocks/handlers/checklist'; import { likeHandlers } from '@/mocks/handlers/like'; +import { roomCompareHandlers } from '@/mocks/handlers/roomCompare'; import { SubwayHandlers } from '@/mocks/handlers/subway'; import { userHandlers } from '@/mocks/handlers/user'; -export const handlers = [...checklistHandlers, ...ArticleHandlers, ...userHandlers, ...SubwayHandlers, ...likeHandlers]; +export const handlers = [ + ...checklistHandlers, + ...roomCompareHandlers, + ...ArticleHandlers, + ...userHandlers, + ...SubwayHandlers, + ...likeHandlers, +]; diff --git a/frontend/src/mocks/handlers/roomCompare.ts b/frontend/src/mocks/handlers/roomCompare.ts new file mode 100644 index 000000000..22c04876c --- /dev/null +++ b/frontend/src/mocks/handlers/roomCompare.ts @@ -0,0 +1,10 @@ +import { http, HttpResponse } from 'msw'; + +import { BASE_URL, ENDPOINT } from '@/apis/url'; +import { roomsForCompare } from '@/mocks/fixtures/roomCompare'; + +export const roomCompareHandlers = [ + http.get(BASE_URL + ENDPOINT.CHECKLIST_COMPARE(1, 2), () => { + return HttpResponse.json(roomsForCompare, { status: 200 }); + }), +]; diff --git a/frontend/src/pages/RoomComparePage.tsx b/frontend/src/pages/RoomComparePage.tsx new file mode 100644 index 000000000..4273508b5 --- /dev/null +++ b/frontend/src/pages/RoomComparePage.tsx @@ -0,0 +1,137 @@ +import styled from '@emotion/styled'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import Header from '@/components/_common/Header/Header'; +import Layout from '@/components/_common/layout/Layout'; +import RoomCompareMap from '@/components/_common/Map/RoomCompareMap'; +import CategoryDetailModal from '@/components/RoomCompare/CategoryDetailModal'; +import CompareCard from '@/components/RoomCompare/CompareCard'; +import OptionDetailModal from '@/components/RoomCompare/OptionDetailModal'; +import RoomMarker from '@/components/RoomCompare/RoomMarker'; +import { ROUTE_PATH } from '@/constants/routePath'; +import useModal from '@/hooks/useModal'; +import { roomsForCompare } from '@/mocks/fixtures/roomCompare'; +import { flexCenter, flexRow } from '@/styles/common'; +import theme from '@/styles/theme'; +import { Position } from '@/types/address'; +import { ChecklistCompare } from '@/types/checklistCompare'; + +const RoomComparePage = () => { + const navigate = useNavigate(); + // const roomsId = { ...location.state }; + const { isModalOpen: isOptionModalOpen, openModal: openOptionModal, closeModal: closeOptionModal } = useModal(); + const { isModalOpen: isCategoryModalOpen, openModal: openCategoryModal, closeModal: closeCategoryModal } = useModal(); + + const [roomList, setRoomList] = useState([]); + + //TODO: 나중에 비교 데이터 요청해서 받아오는 로직으로 수정 + useEffect(() => { + setRoomList(roomsForCompare); + }); + + const handleOpenCategoryDetailModal = (roomId: number, categoryId: number) => { + openCategoryModal(); + navigate(ROUTE_PATH.roomCompare + `?roomId=${roomId}&categoryId=${categoryId}`); + }; + + const handleCloseategoryDetailModal = () => { + closeCategoryModal(); + navigate(ROUTE_PATH.roomCompare); + }; + + const handleClickBackward = () => { + navigate(ROUTE_PATH.checklistList); + }; + const positions: Position[] = [ + { latitude: 37.5061912, longitude: 127.0019228 }, + { latitude: 37.5061912, longitude: 127.1266228 }, + ]; + + const optionMock = [ + { optionName: '세탁기', hasRoom1: true, hasRoom2: false }, + { optionName: '세탁기', hasRoom1: true, hasRoom2: false }, + { optionName: '세탁기', hasRoom1: true, hasRoom2: false }, + { optionName: '세탁기', hasRoom1: true, hasRoom2: false }, + { optionName: '세탁기', hasRoom1: true, hasRoom2: false }, + { optionName: '세탁기', hasRoom1: true, hasRoom2: false }, + ]; + + if (!roomList.length) return
loading
; + + return ( + <> +
} + center={방 비교하기} + /> + + + + + {roomList[0].roomName} + + + + {roomList[1].roomName} + + + + + + + {roomList?.map((room, index) => ( + + ))} + + {/*방 옵션 비교 모달*/} + {isOptionModalOpen && ( + + )} + {/*방 카테고리 디테일 모달*/} + {isCategoryModalOpen && ( + + )} + + + ); +}; + +export default RoomComparePage; + +const S = { + RoomGrid: styled.div` + ${flexRow} + `, + TitleFlex: styled.div` + display: flex; + width: 100vw; + `, + RoomTitle: styled.div` + width: 50%; + margin-bottom: 0.5rem; + ${flexCenter} + gap:0.8rem; + `, + Title: styled.span` + display: inline; + padding: 0.8rem 0; + + font-weight: ${({ theme }) => theme.text.weight.bold}; + font-size: 1.8rem; + text-align: center; + border-radius: 0.8rem; + `, +}; diff --git a/frontend/src/routers/router.tsx b/frontend/src/routers/router.tsx index 87d35d703..e627cd1e9 100644 --- a/frontend/src/routers/router.tsx +++ b/frontend/src/routers/router.tsx @@ -4,6 +4,7 @@ import { createBrowserRouter, Outlet } from 'react-router-dom'; import FooterLayout from '@/components/_common/layout/FooterLayout'; import { ROUTE_PATH } from '@/constants/routePath'; import ResetPasswordPage from '@/pages/ResetPasswordPage'; +import RoomComparePage from '@/pages/RoomComparePage'; import SignInPage from '@/pages/SignInPage'; import SignUpPage from '@/pages/SignUpPage'; @@ -87,6 +88,10 @@ const router = createBrowserRouter([ element: , path: ROUTE_PATH.resetPassword, }, + { + element: , + path: ROUTE_PATH.roomCompare, + }, { element: , path: '*', diff --git a/frontend/src/types/checklistCompare.ts b/frontend/src/types/checklistCompare.ts new file mode 100644 index 000000000..1ff504334 --- /dev/null +++ b/frontend/src/types/checklistCompare.ts @@ -0,0 +1,17 @@ +import { Position } from '@/types/address'; +import { RoomInfo } from '@/types/room'; +import { SubwayStation } from '@/types/subway'; + +export interface CategoryScore { + categoryId: number; + categoryName: string; + score: number | null; +} + +export interface ChecklistCompare extends RoomInfo { + checklistId: number; + options: number[]; + categories: CategoryScore[]; + nearSubwayStations: SubwayStation[]; + geolocation: Position; +} diff --git a/frontend/src/utils/createKakaoMapElements.ts b/frontend/src/utils/createKakaoMapElements.ts index 068af9982..759dcfa73 100644 --- a/frontend/src/utils/createKakaoMapElements.ts +++ b/frontend/src/utils/createKakaoMapElements.ts @@ -1,32 +1,21 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { DEFAULT_POSITION } from '@/constants/map'; - const createKakaoMapElements = () => { - const createMap = (kakao: any) => { - const container = document.getElementById('map'); - if (!container) return; - - const center = new kakao.maps.LatLng(DEFAULT_POSITION.latitude, DEFAULT_POSITION.longitude); - - const options = { - center, - level: 3, + const createMarker = (kakao: any, map: any, position: any, color: 'primary' | 'secondary', title?: string) => { + const imageSrc = { + primary: 'https://github.com/user-attachments/assets/cd52185e-f22f-4d8c-9528-cf9f0593bfaf', + secondary: 'https://github.com/user-attachments/assets/2f19b10b-790c-4d0a-88c4-36eb9b118e8d', }; - return new kakao.maps.Map(container, options); - }; - - const createMarker = (kakao: any, map: any, position: any) => { - const imageSrc = 'https://github.com/user-attachments/assets/cdd2825b-407f-485a-8cc9-5d261acf815d'; const imageSize = new kakao.maps.Size(32, 40); const imageOption = { offset: new kakao.maps.Point(15, 45) }; - const markerImage = new kakao.maps.MarkerImage(imageSrc, imageSize, imageOption); + const markerImage = new kakao.maps.MarkerImage(imageSrc[color], imageSize, imageOption); return new kakao.maps.Marker({ map: map, position: position, image: markerImage, + title: title, }); }; @@ -39,7 +28,7 @@ const createKakaoMapElements = () => { return infoWindow; }; - return { createMap, createMarker, createInfoWindow }; + return { createMarker, createInfoWindow }; }; export default createKakaoMapElements; diff --git a/frontend/src/utils/mapHelper.ts b/frontend/src/utils/mapHelper.ts new file mode 100644 index 000000000..0ba256f95 --- /dev/null +++ b/frontend/src/utils/mapHelper.ts @@ -0,0 +1,27 @@ +/*두 개의 지점의 거리를 재는 로직*/ +/* RoomCompareMap에서 사용 */ +export const getDistanceFromLatLonInKm = (lat1: number, lng1: number, lat2: number, lng2: number) => { + const deg2rad = (deg: number) => { + return deg * (Math.PI / 180); + }; + const R = 6371; + const dLat = deg2rad(lat2 - lat1); + const dLon = deg2rad(lng2 - lng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const d = R * c; + return d; +}; + +/* 거리에 따른 적절한 지도 level 설정 */ +export const getMapLevel = (distance: number) => { + if (distance < 0.5) return 3; + if (distance < 0.7) return 4; + if (distance < 1) return 5; + if (distance < 3) return 6; + if (distance < 5) return 7; + if (distance < 12) return 9; + return 10; +};