diff --git a/frontend/src/@types/api.types.ts b/frontend/src/@types/api.types.ts index 92975dd42..e773b993a 100644 --- a/frontend/src/@types/api.types.ts +++ b/frontend/src/@types/api.types.ts @@ -1,3 +1,12 @@ +export interface RestaurantListData { + content: RestaurantData[]; + currentElementsCount: number; + currentPage: number; + pageSize: number; + totalElementsCount: number; + totalPage: number; +} + export interface RestaurantData { id: number; name: string; diff --git a/frontend/src/components/@common/LoadingDots/LoadingDots.tsx b/frontend/src/components/@common/LoadingDots/LoadingDots.tsx index 24f0d5b9a..387c68396 100644 --- a/frontend/src/components/@common/LoadingDots/LoadingDots.tsx +++ b/frontend/src/components/@common/LoadingDots/LoadingDots.tsx @@ -14,7 +14,7 @@ export default LoadingDots; const StyledLoadingDots = styled.div` display: flex; - gap: 0 0.6rem; + gap: 0 1.4rem; & > div:nth-child(2) { animation-delay: 0.14s; @@ -30,13 +30,13 @@ const pulseAnimation = keyframes` transform: scale(0); } 90%, 100% { - transform: scale(1); + transform: scale(10); } `; const StyledLoadingDot = styled.div` - width: 12px; - height: 12px; + width: 1.2px; + height: 1.2px; border-radius: 50%; background-color: var(--black); diff --git a/frontend/src/components/@common/Map/Map.tsx b/frontend/src/components/@common/Map/Map.tsx index d6c30e4f2..82137b652 100644 --- a/frontend/src/components/@common/Map/Map.tsx +++ b/frontend/src/components/@common/Map/Map.tsx @@ -1,7 +1,6 @@ import { useState } from 'react'; import { Wrapper, Status } from '@googlemaps/react-wrapper'; import { styled } from 'styled-components'; -import OverlayMarker from './OverlayMarker'; import MapContent from './MapContent'; import OverlayMyLocation from './OverlayMyLocation'; import LoadingDots from '../LoadingDots'; @@ -12,6 +11,7 @@ import RightBracket from '~/assets/icons/right-bracket.svg'; import Minus from '~/assets/icons/minus.svg'; import Plus from '~/assets/icons/plus.svg'; import getQuadrant from '~/utils/getQuadrant'; +import OverlayMarker from './OverlayMarker'; import type { Coordinate, CoordinateBoundary } from '~/@types/map.types'; import type { RestaurantData } from '~/@types/api.types'; @@ -21,18 +21,33 @@ interface MapProps { hoveredId: number | null; setBoundary: React.Dispatch>; toggleMapExpand: () => void; + loadingData: boolean; } const render = (status: Status) => { if (status === Status.FAILURE) return
지도를 불러올 수 없습니다. 페이지를 새로고침 하거나 네트워크 연결을 다시 한 번 확인해주세요.
; - return ; + return ( + + + + ); }; +const StyledMapLoadingContainer = styled.section` + display: flex; + justify-content: center; + align-items: center; + + height: 100%; + + background-color: var(--gray-2); +`; + const JamsilCampus = { lat: 37.515271, lng: 127.1029949 }; -function Map({ data, setBoundary, toggleMapExpand, hoveredId }: MapProps) { - const [center, setCenter] = useState(JamsilCampus); +function Map({ data, setBoundary, toggleMapExpand, loadingData, hoveredId }: MapProps) { + const [center, setCenter] = useState({ lat: 37.5057482, lng: 127.050727 }); const [clicks, setClicks] = useState([]); const [zoom, setZoom] = useState(16); const [myPosition, setMyPosition] = useState(null); @@ -86,7 +101,7 @@ function Map({ data, setBoundary, toggleMapExpand, hoveredId }: MapProps) { zoom={zoom} center={center} > - {data.map(({ celebs, ...restaurant }) => { + {data?.map(({ celebs, ...restaurant }) => { const { lat, lng } = restaurant; return ( } - {loading && ( + {(loadingData || loading) && ( @@ -132,8 +147,7 @@ const LoadingUI = styled.div` right: calc(50% - 41px); width: 82px; - - padding: 1.6rem 2.4rem; + height: 40px; `; const StyledMyPositionButtonUI = styled.button` diff --git a/frontend/src/components/@common/ProfileImage/ProfileImageSkeleton.tsx b/frontend/src/components/@common/ProfileImage/ProfileImageSkeleton.tsx new file mode 100644 index 000000000..9ca92eab1 --- /dev/null +++ b/frontend/src/components/@common/ProfileImage/ProfileImageSkeleton.tsx @@ -0,0 +1,21 @@ +import { styled } from 'styled-components'; +import { paintSkeleton } from '~/styles/common'; + +interface ProfileImageSkeletonProps { + size: number; +} + +function ProfileImageSkeleton({ size }: ProfileImageSkeletonProps) { + return ; +} + +export default ProfileImageSkeleton; + +const StyledProfileImageSkeleton = styled.div<{ size: number }>` + ${paintSkeleton} + width: ${({ size }) => (size ? `${size}px` : '100%')}; + height: ${({ size }) => (size ? `${size}px` : 'auto')}; + + border-radius: 50%; + background: none; +`; diff --git a/frontend/src/components/RestaurantCard/RestaurantCard.tsx b/frontend/src/components/RestaurantCard/RestaurantCard.tsx index 536ea0c5f..cf5e62610 100644 --- a/frontend/src/components/RestaurantCard/RestaurantCard.tsx +++ b/frontend/src/components/RestaurantCard/RestaurantCard.tsx @@ -1,5 +1,5 @@ import { styled } from 'styled-components'; -import { BORDER_RADIUS, FONT_SIZE, truncateText } from '~/styles/common'; +import { BORDER_RADIUS, FONT_SIZE, paintSkeleton, truncateText } from '~/styles/common'; import ProfileImage from '../@common/ProfileImage'; import { BASE_URL } from '~/App'; @@ -35,7 +35,7 @@ function RestaurantCard({ return ( - +
{category} @@ -71,11 +71,12 @@ const StyledContainer = styled.div` `; const StyledImage = styled.img<{ type: 'list' | 'map' }>` + ${paintSkeleton} width: 100%; aspect-ratio: 1.05 / 1; border-radius: ${({ type }) => - type === 'list' ? `${BORDER_RADIUS.md}` : `${BORDER_RADIUS.md} ${BORDER_RADIUS.md} 0 0`}; + type === 'list' ? `${BORDER_RADIUS.md}` : `${BORDER_RADIUS.md} ${BORDER_RADIUS.md} 0 0 `}; object-fit: cover; `; diff --git a/frontend/src/components/RestaurantCard/RestaurantCardSkeleton.tsx b/frontend/src/components/RestaurantCard/RestaurantCardSkeleton.tsx new file mode 100644 index 000000000..92ff4b945 --- /dev/null +++ b/frontend/src/components/RestaurantCard/RestaurantCardSkeleton.tsx @@ -0,0 +1,92 @@ +import { styled } from 'styled-components'; +import ProfileImageSkeleton from '../@common/ProfileImage/ProfileImageSkeleton'; +import { BORDER_RADIUS, paintSkeleton } from '~/styles/common'; + +function RestaurantCardSkeleton() { + return ( + + +
+ + + + + + + + + +
+
+ ); +} + +export default RestaurantCardSkeleton; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + gap: 0.8rem; + + width: 100%; + height: 100%; + + & > section { + display: flex; + justify-content: space-between; + } + + cursor: pointer; +`; + +const StyledImage = styled.div` + ${paintSkeleton} + width: 100%; + aspect-ratio: 1.05 / 1; + + object-fit: cover; + + border-radius: ${BORDER_RADIUS.md}; +`; + +const StyledInfo = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: 0.4rem; + + position: relative; + + width: 100%; + + padding: 0.4rem; +`; + +const StyledName = styled.h5` + ${paintSkeleton} + width: 100%; + height: 20px; + + border-radius: ${BORDER_RADIUS.xs}; +`; + +const StyledAddress = styled.span` + ${paintSkeleton} + width: 50%; + height: 12px; + + border-radius: ${BORDER_RADIUS.xs}; +`; + +const StyledCategory = styled.span` + ${paintSkeleton} + width: 40%; + height: 12px; + + border-radius: ${BORDER_RADIUS.xs}; +`; + +const StyledProfileImageSection = styled.div` + align-self: flex-end; +`; diff --git a/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx b/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx new file mode 100644 index 000000000..8bf925577 --- /dev/null +++ b/frontend/src/components/RestaurantCardList/RestaurantCardList.tsx @@ -0,0 +1,56 @@ +import { styled } from 'styled-components'; +import { useEffect, useState } from 'react'; +import RestaurantCard from '../RestaurantCard/RestaurantCard'; +import { FONT_SIZE } from '~/styles/common'; +import RestaurantCardListSkeleton from './RestaurantCardListSkeleton'; + +import type { RestaurantData, RestaurantListData } from '~/@types/api.types'; + +interface RestaurantCardListProps { + restaurantDataList: RestaurantListData | null; + loading: boolean; + setHoveredId: React.Dispatch>; +} + +function RestaurantCardList({ restaurantDataList, loading, setHoveredId }: RestaurantCardListProps) { + const [prevCardNumber, setPrevCardNumber] = useState(18); + + useEffect(() => { + if (restaurantDataList) setPrevCardNumber(restaurantDataList.currentElementsCount); + }, [restaurantDataList?.currentElementsCount]); + + if (!restaurantDataList || loading) return ; + + return ( +
+ 음식점 수 {restaurantDataList.totalElementsCount} 개 + + {restaurantDataList.content?.map(({ celebs, ...restaurant }: RestaurantData) => ( + + ))} + +
+ ); +} + +export default RestaurantCardList; + +const StyledCardListHeader = styled.p` + margin: 3.2rem 2.4rem; + + font-size: ${FONT_SIZE.md}; +`; + +const StyledRestaurantCardList = styled.div` + display: grid; + gap: 4rem 2.4rem; + + height: 100%; + + margin: 0 2.4rem; + grid-template-columns: 1fr 1fr 1fr; + + @media screen and (width <= 1240px) { + grid-template-columns: 1fr 1fr; + } +`; diff --git a/frontend/src/components/RestaurantCardList/RestaurantCardListSkeleton.tsx b/frontend/src/components/RestaurantCardList/RestaurantCardListSkeleton.tsx new file mode 100644 index 000000000..93e07ed21 --- /dev/null +++ b/frontend/src/components/RestaurantCardList/RestaurantCardListSkeleton.tsx @@ -0,0 +1,46 @@ +import { styled } from 'styled-components'; +import RestaurantCardSkeleton from '../RestaurantCard/RestaurantCardSkeleton'; +import { BORDER_RADIUS, paintSkeleton } from '~/styles/common'; + +interface RestaurantCardListSkeletonProps { + cardNumber: number; +} + +function RestaurantCardListSkeleton({ cardNumber }: RestaurantCardListSkeletonProps) { + return ( +
+ + + {Array.from({ length: cardNumber }, () => ( + + ))} + +
+ ); +} + +export default RestaurantCardListSkeleton; + +const StyledCardListHeader = styled.p` + ${paintSkeleton} + width: 35%; + height: 16px; + + margin: 3.2rem 2.4rem; + + border-radius: ${BORDER_RADIUS.xs}; +`; + +const StyledRestaurantCardList = styled.div` + display: grid; + gap: 4rem 2.4rem; + + height: 100%; + + margin: 0 2.4rem; + grid-template-columns: 1fr 1fr 1fr; + + @media screen and (width <= 1240px) { + grid-template-columns: 1fr 1fr; + } +`; diff --git a/frontend/src/components/RestaurantCardList/index.tsx b/frontend/src/components/RestaurantCardList/index.tsx new file mode 100644 index 000000000..0f31b4b56 --- /dev/null +++ b/frontend/src/components/RestaurantCardList/index.tsx @@ -0,0 +1,3 @@ +import RestaurantCardList from './RestaurantCardList'; + +export default RestaurantCardList; diff --git a/frontend/src/hooks/useOnClickOuside.ts b/frontend/src/hooks/useOnClickOuside.ts new file mode 100644 index 000000000..f95470cc2 --- /dev/null +++ b/frontend/src/hooks/useOnClickOuside.ts @@ -0,0 +1,19 @@ +import { useEffect, RefObject } from 'react'; + +export default function useOnClickOutside( + ref: RefObject, + handler: (event?: Event | MouseEvent) => void, +) { + useEffect(() => { + function onClickHandler(event: Event | MouseEvent) { + if (!ref?.current || ref?.current.contains(event?.target as Node)) { + return; + } + handler(event); + } + window.addEventListener('click', onClickHandler); + return () => { + window.removeEventListener('click', onClickHandler); + }; + }, [ref, handler]); +} diff --git a/frontend/src/pages/MainPage.tsx b/frontend/src/pages/MainPage.tsx index fd5c90e4e..8d3a0823e 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -5,21 +5,21 @@ import Header from '~/components/@common/Header'; import Map from '~/components/@common/Map'; import CategoryNavbar from '~/components/CategoryNavbar'; import CelebDropDown from '~/components/CelebDropDown/CelebDropDown'; -import RestaurantCard from '~/components/RestaurantCard'; import RESTAURANT_CATEGORY from '~/constants/restaurantCategory'; import { CELEBS_OPTIONS } from '~/constants/celebs'; import useFetch from '~/hooks/useFetch'; import getQueryString from '~/utils/getQueryString'; -import { FONT_SIZE } from '~/styles/common'; +import RestaurantCardList from '~/components/RestaurantCardList'; import type { Celeb } from '~/@types/celeb.types'; -import type { RestaurantData } from '~/@types/api.types'; +import type { RestaurantListData } from '~/@types/api.types'; import type { CoordinateBoundary } from '~/@types/map.types'; import type { RestaurantCategory } from '~/@types/restaurant.types'; function MainPage() { const [isMapExpanded, setIsMapExpanded] = useState(false); - const [data, setData] = useState([]); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); const [boundary, setBoundary] = useState(); const [celebId, setCelebId] = useState(-1); const [restaurantCategory, setRestaurantCategory] = useState('전체'); @@ -28,11 +28,12 @@ function MainPage() { const fetchRestaurants = useCallback( async (queryObject: { boundary: CoordinateBoundary; celebId: number; category: RestaurantCategory }) => { + setLoading(true); const queryString = getQueryString(queryObject); const response = await handleFetch({ queryString }); - const { content }: { content: RestaurantData[] } = response; - setData(content); + setData(response); + setLoading(false); }, [boundary, celebId, restaurantCategory], ); @@ -69,15 +70,16 @@ function MainPage() { - 음식점 수 {data.length} 개 - - {data?.map(({ celebs, ...restaurant }: RestaurantData) => ( - - ))} - + - +