diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index ab8fc7934..0f385ba75 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -9,6 +9,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "stylelint.enable": true, + "stylelint.config": null, "stylelint.validate": ["css", "scss", "typescript", "typescriptreact"] } diff --git a/frontend/package.json b/frontend/package.json index 913d3ac0f..9e6784510 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "main": "index.tsx", "license": "MIT", "scripts": { - "start": "webpack serve --open --mode development", + "start": "webpack serve --open --mode development --port 3000", "start:prod": "webpack serve --open --mode production", "build": "webpack --mode production", "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", @@ -32,6 +32,7 @@ "msw": "^1.2.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.14.2", "storybook": "^7.0.25", "styled-components": "^6.0.2", "ts-loader": "^9.4.4", diff --git a/frontend/public/index.html b/frontend/public/index.html index b3789f56e..615b5e221 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -11,5 +11,6 @@

경고!

현재 사용 중인 브라우저는 스크립트를 지원하지 않거나, 해당 기능이 활성화 되어 있지 않습니다.
+ 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/@types/image.type.ts b/frontend/src/@types/image.type.ts new file mode 100644 index 000000000..aadbc4cb2 --- /dev/null +++ b/frontend/src/@types/image.type.ts @@ -0,0 +1,5 @@ +import type { RestaurantData } from './api.types'; + +type RestaurantImages = RestaurantData['images']; + +export type RestaurantImage = RestaurantImages[number]; diff --git a/frontend/src/@types/oauth.types.ts b/frontend/src/@types/oauth.types.ts new file mode 100644 index 000000000..3dbe5eb87 --- /dev/null +++ b/frontend/src/@types/oauth.types.ts @@ -0,0 +1 @@ +export type Oauth = 'google' | 'kakao' | 'naver'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b45495c4f..2d0311bdd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,18 @@ -import MainPage from './pages/MainPage'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import OauthRedirectPage from '~/pages/OauthRedirectPage'; +import MainPage from '~/pages/MainPage'; function App() { - return ; + return ( + + + } /> + } /> + } /> + } /> + + + ); } export default App; diff --git a/frontend/src/assets/all.png b/frontend/src/assets/all.png new file mode 100644 index 000000000..b608aef60 Binary files /dev/null and b/frontend/src/assets/all.png differ diff --git a/frontend/src/assets/icons/etc/menu.svg b/frontend/src/assets/icons/etc/menu.svg new file mode 100644 index 000000000..f2063d991 --- /dev/null +++ b/frontend/src/assets/icons/etc/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/etc/user.svg b/frontend/src/assets/icons/etc/user.svg new file mode 100644 index 000000000..23456087a --- /dev/null +++ b/frontend/src/assets/icons/etc/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/love.svg b/frontend/src/assets/icons/love.svg new file mode 100644 index 000000000..75a0db3be --- /dev/null +++ b/frontend/src/assets/icons/love.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/oauth/google.svg b/frontend/src/assets/icons/oauth/google.svg new file mode 100644 index 000000000..138004410 --- /dev/null +++ b/frontend/src/assets/icons/oauth/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/oauth/kakao.svg b/frontend/src/assets/icons/oauth/kakao.svg new file mode 100644 index 000000000..1915a9b39 --- /dev/null +++ b/frontend/src/assets/icons/oauth/kakao.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/icons/oauth/naver.svg b/frontend/src/assets/icons/oauth/naver.svg new file mode 100644 index 000000000..a9c147926 --- /dev/null +++ b/frontend/src/assets/icons/oauth/naver.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/frontend/src/assets/icons/restaurantCategory/all.svg b/frontend/src/assets/icons/restaurantCategory/all.svg new file mode 100644 index 000000000..f066ec43f --- /dev/null +++ b/frontend/src/assets/icons/restaurantCategory/all.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/@common/Header/Header.tsx b/frontend/src/components/@common/Header/Header.tsx index 4d43da815..f8e6541e6 100644 --- a/frontend/src/components/@common/Header/Header.tsx +++ b/frontend/src/components/@common/Header/Header.tsx @@ -1,11 +1,37 @@ +import React from 'react'; import { styled } from 'styled-components'; import Logo from '~/assets/logo.png'; +import { Modal, ModalContent } from '~/components/@common/Modal'; +import InfoDropDown from '~/components/InfoDropDown'; +import LoginModalContent from '~/components/LoginModalContent'; +import useBooleanState from '~/hooks/useBooleanState'; + +const options = [ + { id: 1, value: '로그인' }, + { id: 2, value: '회원가입' }, +]; function Header() { + const { value: isModalOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false); + + const handleInfoDropDown = (event: React.MouseEvent) => { + const currentOption = event.currentTarget.dataset.name; + + if (currentOption === '로그인') openModal(); + }; + return ( - - - + <> + + + + + + + + + + ); } @@ -13,11 +39,12 @@ export default Header; const StyledHeader = styled.header` display: flex; + justify-content: space-between; align-items: center; position: sticky; top: 0; - z-index: 10; + z-index: 20; width: 100%; height: 80px; diff --git a/frontend/src/components/@common/ImageCarousel/ImageCarousel.stories.tsx b/frontend/src/components/@common/ImageCarousel/ImageCarousel.stories.tsx new file mode 100644 index 000000000..0c905dd92 --- /dev/null +++ b/frontend/src/components/@common/ImageCarousel/ImageCarousel.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ImageCarousel from './ImageCarousel'; + +const meta: Meta = { + title: 'ImageCarousel', + component: ImageCarousel, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + images: [ + { id: 1, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }, + { id: 2, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }, + { id: 3, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }, + { id: 4, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }, + ], + }, +}; + +export const OneImage: Story = { + args: { + images: [{ id: 1, name: 'https://picsum.photos/315/300', author: '@d0dam', sns: 'youtube' }], + }, +}; diff --git a/frontend/src/components/@common/ImageCarousel/ImageCarousel.tsx b/frontend/src/components/@common/ImageCarousel/ImageCarousel.tsx new file mode 100644 index 000000000..18354d279 --- /dev/null +++ b/frontend/src/components/@common/ImageCarousel/ImageCarousel.tsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; +import styled, { css } from 'styled-components'; +import { RestaurantImage } from '~/@types/image.type'; +import LeftBracket from '~/assets/icons/left-bracket.svg'; +import RightBracket from '~/assets/icons/right-bracket.svg'; +import { BORDER_RADIUS } from '~/styles/common'; +import WaterMarkImage from '../WaterMarkImage'; + +interface ImageCarouselProps { + images: RestaurantImage[]; + type: 'list' | 'map'; +} + +function ImageCarousel({ images, type }: ImageCarouselProps) { + const [currentIndex, setCurrentIndex] = useState(0); + + const goToPrevious = () => { + setCurrentIndex(prevIndex => prevIndex - 1); + }; + + const goToNext = () => { + setCurrentIndex(prevIndex => prevIndex + 1); + }; + + return ( + + + {images.map(({ id, name, author }) => ( + + ))} + + {currentIndex !== 0 && ( + + + + )} + {currentIndex !== images.length - 1 && ( + + + + )} + {images.length > 1 && ( + + {Array.from({ length: images.length }, () => ( + + ))} + + )} + + ); +} + +export default ImageCarousel; + +const StyledCarouselContainer = styled.div<{ type: 'list' | 'map' }>` + position: relative; + + width: 100%; + overflow: hidden; + + border-radius: ${({ type }) => + type === 'list' ? `${BORDER_RADIUS.md}` : `${BORDER_RADIUS.md} ${BORDER_RADIUS.md} 0 0`}; + + button { + visibility: hidden; + + display: flex; + justify-content: center; + align-items: center; + + position: absolute; + top: 50%; + + width: 32px; + height: 32px; + + border: none; + border-radius: 50%; + background-color: var(--white); + + cursor: pointer; + opacity: 0; + transition: transform 0.15s ease-in-out, opacity 0.2s ease-in-out; + transform: translateY(-50%); + box-shadow: var(--shadow); + outline: none; + + &:hover { + transform: translateY(-50%) scale(1.04); + } + } + + &:hover { + button { + visibility: visible; + + opacity: 0.85; + + &:hover { + opacity: 1; + } + } + } +`; + +const StyledLeftButton = styled.button` + left: 12px; +`; + +const StyledRightButton = styled.button` + right: 12px; +`; + +const StyledCarouselSlide = styled.div<{ currentIndex: number }>` + display: flex; + + width: 100%; + + transition: transform 0.3s ease-in-out; + transform: ${({ currentIndex }) => `translateX(-${currentIndex * 100}%)`}; + flex-wrap: nowrap; + + aspect-ratio: 1.05 / 1; +`; + +const StyledDots = styled.div<{ currentIndex: number }>` + display: flex; + justify-content: center; + align-items: center; + gap: 0 0.5rem; + + position: absolute; + bottom: 12px; + + width: 100%; + + ${({ currentIndex }) => css` + & > span:nth-child(${currentIndex + 1}) { + opacity: 1; + transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out; + transform: scale(1.1); + } + `} +`; + +const StyledDot = styled.span` + width: 6px; + height: 6px; + + border-radius: 50%; + background-color: var(--white); + + opacity: 0.2; + transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out; +`; diff --git a/frontend/src/components/@common/ImageCarousel/index.tsx b/frontend/src/components/@common/ImageCarousel/index.tsx new file mode 100644 index 000000000..d557e0a41 --- /dev/null +++ b/frontend/src/components/@common/ImageCarousel/index.tsx @@ -0,0 +1,3 @@ +import ImageCarousel from './ImageCarousel'; + +export default ImageCarousel; diff --git a/frontend/src/components/@common/InfoButton/InfoButton.stories.tsx b/frontend/src/components/@common/InfoButton/InfoButton.stories.tsx new file mode 100644 index 000000000..4be75506c --- /dev/null +++ b/frontend/src/components/@common/InfoButton/InfoButton.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import InfoButton from './InfoButton'; + +const meta: Meta = { + title: 'InfoButton', + component: InfoButton, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/frontend/src/components/@common/InfoButton/InfoButton.tsx b/frontend/src/components/@common/InfoButton/InfoButton.tsx new file mode 100644 index 000000000..10f5f0ad2 --- /dev/null +++ b/frontend/src/components/@common/InfoButton/InfoButton.tsx @@ -0,0 +1,47 @@ +import styled, { css } from 'styled-components'; + +import Menu from '~/assets/icons/etc/menu.svg'; +import User from '~/assets/icons/etc/user.svg'; + +interface InfoButtonProps { + isShow?: boolean; +} + +function InfoButton({ isShow = false }: InfoButtonProps) { + return ( + + + + + ); +} + +export default InfoButton; + +const StyledInfoButton = styled.button` + display: flex; + justify-content: space-between; + align-items: center; + + width: 77px; + + padding: 0.5rem 0.5rem 0.5rem 1.2rem; + + border: 1px solid #ddd; + border-radius: 21px; + background: transparent; + + cursor: pointer; + + ${({ isShow }) => + isShow && + css` + box-shadow: var(--shadow); + `} + + &:hover { + box-shadow: var(--shadow); + + transition: box-shadow 0.2s ease-in-out; + } +`; diff --git a/frontend/src/components/@common/InfoButton/index.tsx b/frontend/src/components/@common/InfoButton/index.tsx new file mode 100644 index 000000000..dac566eef --- /dev/null +++ b/frontend/src/components/@common/InfoButton/index.tsx @@ -0,0 +1,3 @@ +import InfoButton from '~/components/@common/InfoButton/InfoButton'; + +export default InfoButton; 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/LoginButton/LoginButton.stories.tsx b/frontend/src/components/@common/LoginButton/LoginButton.stories.tsx new file mode 100644 index 000000000..52cd8c2e0 --- /dev/null +++ b/frontend/src/components/@common/LoginButton/LoginButton.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import LoginButton from './LoginButton'; + +const meta: Meta = { + title: 'Oauth/LoginButton', + component: LoginButton, + decorators: [ + Story => ( + + + } /> + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Google: Story = { + args: { type: 'google' }, +}; + +export const KaKao: Story = { + args: { type: 'kakao' }, +}; + +export const Naver: Story = { + args: { type: 'naver' }, +}; diff --git a/frontend/src/components/@common/LoginButton/LoginButton.tsx b/frontend/src/components/@common/LoginButton/LoginButton.tsx new file mode 100644 index 000000000..4fd1b0e19 --- /dev/null +++ b/frontend/src/components/@common/LoginButton/LoginButton.tsx @@ -0,0 +1,74 @@ +import { Link } from 'react-router-dom'; +import styled, { css } from 'styled-components'; +import React from 'react'; +import { OAUTH_BUTTON_MESSAGE, OAUTH_LINK } from '~/constants/api'; + +import KaKao from '~/assets/icons/oauth/kakao.svg'; +import Naver from '~/assets/icons/oauth/naver.svg'; +import Google from '~/assets/icons/oauth/google.svg'; +import { Oauth } from '~/@types/oauth.types'; + +interface LoginButtonProps { + type: Oauth; +} + +const LoginIcon: Record = { + naver: , + kakao: , + google: , +}; + +function LoginButton({ type }: LoginButtonProps) { + return ( + +
{LoginIcon[type]}
+ {OAUTH_BUTTON_MESSAGE[type]} +
+ ); +} + +export default LoginButton; + +const StyledLoginButtonWrapper = styled(Link)` + display: flex; + + width: 100%; + height: fit-content; + + padding: 2.3rem 1.3rem; + + border-radius: 12px; + + font-size: 1.4rem; + font-weight: 600; + text-decoration: none; + + ${({ type }) => + type === 'naver' && + css` + background: #03c759; + + color: #fff; + `} + + ${({ type }) => + type === 'kakao' && + css` + background: #fee500; + `} + + ${({ type }) => + type === 'google' && + css` + border: 1px solid var(--gray-3); + `} + + cursor: pointer; + transition: box-shadow 0.2s cubic-bezier(0.2, 0, 0, 1), transform 0.1s cubic-bezier(0.2, 0, 0, 1); +`; + +const StyledLoginButtonText = styled.span` + margin: 0 auto; + + color: inherit; +`; diff --git a/frontend/src/components/@common/LoginButton/index.tsx b/frontend/src/components/@common/LoginButton/index.tsx new file mode 100644 index 000000000..9ee6be126 --- /dev/null +++ b/frontend/src/components/@common/LoginButton/index.tsx @@ -0,0 +1,3 @@ +import LoginButton from '~/components/@common/LoginButton/LoginButton'; + +export default LoginButton; diff --git a/frontend/src/components/@common/Map/Map.tsx b/frontend/src/components/@common/Map/Map.tsx index ba1537440..82137b652 100644 --- a/frontend/src/components/@common/Map/Map.tsx +++ b/frontend/src/components/@common/Map/Map.tsx @@ -1,9 +1,6 @@ import { useState } from 'react'; import { Wrapper, Status } from '@googlemaps/react-wrapper'; import { styled } from 'styled-components'; -import OverlayMarker from './OverlayMarker'; -import type { Coordinate, CoordinateBoundary } from '~/@types/map.types'; -import type { Celeb } from '~/@types/celeb.types'; import MapContent from './MapContent'; import OverlayMyLocation from './OverlayMyLocation'; import LoadingDots from '../LoadingDots'; @@ -13,27 +10,50 @@ import LeftBracket from '~/assets/icons/left-bracket.svg'; 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'; interface MapProps { - clickMarker: ({ lat, lng }: Coordinate) => void; - markers: { position: Coordinate; celebs: Celeb[] }[]; + data: RestaurantData[]; + hoveredId: number | null; setBoundary: React.Dispatch>; toggleMapExpand: () => void; + loadingData: boolean; } const render = (status: Status) => { if (status === Status.FAILURE) return
지도를 불러올 수 없습니다. 페이지를 새로고침 하거나 네트워크 연결을 다시 한 번 확인해주세요.
; - return ; + return ( + + + + ); }; -function Map({ clickMarker, markers, setBoundary, toggleMapExpand }: MapProps) { +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, 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); const [isMapExpanded, setIsMapExpanded] = useState(false); const [loading, setLoading] = useState(false); + const [currentCenter, setCurrentCenter] = useState(JamsilCampus); const onClick = (e: google.maps.MapMouseEvent) => { setClicks([...clicks, e.latLng!]); @@ -41,6 +61,7 @@ function Map({ clickMarker, markers, setBoundary, toggleMapExpand }: MapProps) { const onIdle = (m: google.maps.Map) => { setZoom(m.getZoom()!); + setCurrentCenter({ lat: m.getCenter().lat(), lng: m.getCenter().lng() }); const lowLatitude = String(m.getBounds().getSouthWest().lat()); const highLatitude = String(m.getBounds().getNorthEast().lat()); @@ -60,11 +81,6 @@ function Map({ clickMarker, markers, setBoundary, toggleMapExpand }: MapProps) { }); }; - const clickOverlayMarker = (position: Coordinate) => { - clickMarker(position); - setCenter(position); - }; - const clickZoom = (number: number): React.MouseEventHandler => () => { @@ -85,11 +101,19 @@ function Map({ clickMarker, markers, setBoundary, toggleMapExpand }: MapProps) { zoom={zoom} center={center} > - {markers.map(({ position, celebs }) => ( - - ))} + {data?.map(({ celebs, ...restaurant }) => { + const { lat, lng } = restaurant; + return ( + + ); + })} {myPosition && } - {loading && ( + {(loadingData || loading) && ( @@ -123,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/Map/Overlay/Overlay.tsx b/frontend/src/components/@common/Map/Overlay/Overlay.tsx index bfd0581ce..5b2bbd3ac 100644 --- a/frontend/src/components/@common/Map/Overlay/Overlay.tsx +++ b/frontend/src/components/@common/Map/Overlay/Overlay.tsx @@ -14,6 +14,7 @@ function Overlay({ position, pane = 'floatPane', map, zIndex, children }: Overla const container = useMemo(() => { const div = document.createElement('div'); div.style.position = 'absolute'; + return div; }, []); diff --git a/frontend/src/components/@common/Map/OverlayMarker.tsx b/frontend/src/components/@common/Map/OverlayMarker.tsx index a3fa57362..414ffdd7c 100644 --- a/frontend/src/components/@common/Map/OverlayMarker.tsx +++ b/frontend/src/components/@common/Map/OverlayMarker.tsx @@ -1,37 +1,102 @@ -import styled from 'styled-components'; +import styled, { css, keyframes } from 'styled-components'; +import { useRef, useState } from 'react'; import ProfileImage from '../ProfileImage'; import Overlay from './Overlay/Overlay'; +import RestaurantCard from '~/components/RestaurantCard'; +import useOnClickOutside from '~/hooks/useOnClickOutside'; + +import type { Quadrant } from '~/utils/getQuadrant'; +import type { Restaurant } from '~/@types/restaurant.types'; import type { Celeb } from '~/@types/celeb.types'; -import type { Coordinate } from '~/@types/map.types'; interface OverlayMarkerProps { celeb: Celeb; - position: Coordinate; - onClick: ({ lat, lng }: Coordinate) => void; map?: google.maps.Map; + restaurant: Restaurant; + quadrant: Quadrant; + isRestaurantHovered: boolean; } -function OverlayMarker({ celeb, position, map, onClick }: OverlayMarkerProps) { +function OverlayMarker({ celeb, restaurant, map, quadrant, isRestaurantHovered }: OverlayMarkerProps) { + const { lat, lng } = restaurant; + const [isClicked, setIsClicked] = useState(false); + const ref = useRef(); + useOnClickOutside(ref, () => setIsClicked(false)); + + const clickMarker = () => setIsClicked(true); + return ( map && ( - - onClick(position)}> - + + + + {isClicked && ( + + + + )} ) ); } -const StyledMarker = styled.button` - border: none; - background-color: transparent; +const scaleUp = keyframes` + 0% { + transform: scale(1); + } + 100% { + transform: scale(1.5); + } +`; + +const StyledMarker = styled.div<{ isClicked: boolean; isRestaurantHovered: boolean }>` + display: flex; + justify-content: center; + align-items: center; + + width: 36px; + height: 36px; - transition: all 0.2s ease-in-out; + border: ${({ isClicked, isRestaurantHovered }) => + isClicked || isRestaurantHovered ? '3px solid var(--orange-2)' : '3px solid transparent'}; + border-radius: 50%; + + transition: transform 0.2s ease-in-out; + transform: ${({ isClicked }) => (isClicked ? 'scale(1.5)' : 'scale(1)')}; &:hover { - transform: scale(1.1); + transform: scale(1.5); } + + ${({ isRestaurantHovered }) => + isRestaurantHovered && + css` + animation: ${scaleUp} 0.2s ease-in-out forwards; + `} +`; + +const fadeInAnimation = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } +`; + +const StyledModal = styled.div<{ quadrant: Quadrant }>` + position: absolute; + top: ${({ quadrant }) => (quadrant === 1 || quadrant === 2 ? '40px' : '-280px')}; + right: ${({ quadrant }) => (quadrant === 1 || quadrant === 4 ? '45px' : '-210px')}; + + width: 200px; + + border-radius: 12px; + background-color: #fff; + + animation: ${fadeInAnimation} 100ms ease-in; + box-shadow: 0 4px 6px rgb(0 0 0 / 20%); `; export default OverlayMarker; diff --git a/frontend/src/components/@common/Modal/Modal.tsx b/frontend/src/components/@common/Modal/Modal.tsx new file mode 100644 index 000000000..b5eb84500 --- /dev/null +++ b/frontend/src/components/@common/Modal/Modal.tsx @@ -0,0 +1,11 @@ +import { createPortal } from 'react-dom'; + +interface ModalProps { + children: React.ReactNode; +} + +function Modal({ children }: ModalProps) { + return createPortal(children, document.querySelector('#modal')); +} + +export default Modal; diff --git a/frontend/src/components/@common/Modal/ModalContent.stories.tsx b/frontend/src/components/@common/Modal/ModalContent.stories.tsx new file mode 100644 index 000000000..fc7ce1800 --- /dev/null +++ b/frontend/src/components/@common/Modal/ModalContent.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ModalContent from './ModalContent'; + +const meta: Meta = { + title: 'ModalContent', + component: ModalContent, +}; + +export default meta; + +type Story = StoryObj; + +export const LoginModal: Story = { + args: { + isShow: true, + title: '로그인 또는 회원가입', + closeModal: () => {}, + children: '모달 내용', + }, +}; diff --git a/frontend/src/components/@common/Modal/ModalContent.tsx b/frontend/src/components/@common/Modal/ModalContent.tsx new file mode 100644 index 000000000..fbab17a16 --- /dev/null +++ b/frontend/src/components/@common/Modal/ModalContent.tsx @@ -0,0 +1,106 @@ +import styled, { css } from 'styled-components'; +import Exit from '~/assets/icons/exit.svg'; + +interface ModalContentProps { + isShow?: boolean; + title: string; + closeModal: () => void; + children: React.ReactNode; +} + +function ModalContent({ isShow = false, title, closeModal, children }: ModalContentProps) { + return ( + + + + + + {title} + + {children} + + + ); +} + +export default ModalContent; + +const StyledModalContentWrapper = styled.div<{ isShow: boolean }>` + display: flex; + justify-content: center; + align-items: center; + + position: fixed; + top: 0; + left: 0; + z-index: 999; + + width: 100%; + height: 100%; + + opacity: 0; + visibility: hidden; + + ${({ isShow }) => + isShow && + css` + visibility: visible; + + opacity: 1; + transition: opacity ease 0.25s; + `} +`; + +const StyledModalOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + z-index: 1; + + width: 100%; + height: 100%; + + background: rgb(0 0 0 / 50%); +`; + +const StyledModalContent = styled.div<{ isShow: boolean }>` + display: flex; + flex-direction: column; + + position: relative; + z-index: 10; + + width: 33%; + min-width: 500px; + max-width: 600px; + min-height: 100px; + + padding: 2rem; + + border-radius: 5px; + background: #fff; + + transition: transform ease 0.3s 0.1s; + transform: translateY(80px); + + overflow-y: auto; + + ${({ isShow }) => + isShow && + css` + transform: translateY(0); + `} +`; + +const StyledModalHeader = styled.h5` + display: flex; + align-items: center; +`; + +const StyledModalTitleText = styled.span` + margin: 0 auto; +`; + +const StyledModalBody = styled.div` + margin-top: 2.4rem; +`; diff --git a/frontend/src/components/@common/Modal/index.tsx b/frontend/src/components/@common/Modal/index.tsx new file mode 100644 index 000000000..4caa2d0b2 --- /dev/null +++ b/frontend/src/components/@common/Modal/index.tsx @@ -0,0 +1,4 @@ +import Modal from '~/components/@common/Modal/Modal'; +import ModalContent from '~/components/@common/Modal/ModalContent'; + +export { Modal, ModalContent }; diff --git a/frontend/src/components/@common/ProfileImage/ProfileImage.stories.tsx b/frontend/src/components/@common/ProfileImage/ProfileImage.stories.tsx index e5dbbe3a9..58f63f8a1 100644 --- a/frontend/src/components/@common/ProfileImage/ProfileImage.stories.tsx +++ b/frontend/src/components/@common/ProfileImage/ProfileImage.stories.tsx @@ -14,6 +14,6 @@ export const Default: Story = { args: { name: '누군가', imageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', - size: 64, + size: '64px', }, }; diff --git a/frontend/src/components/@common/ProfileImage/ProfileImage.tsx b/frontend/src/components/@common/ProfileImage/ProfileImage.tsx index 92d95ef41..e2efe8345 100644 --- a/frontend/src/components/@common/ProfileImage/ProfileImage.tsx +++ b/frontend/src/components/@common/ProfileImage/ProfileImage.tsx @@ -4,7 +4,7 @@ interface ProfileImageProps extends React.HTMLAttributes { name: string; imageUrl: string; border?: boolean; - size: number; + size?: string; } function ProfileImage({ name = '셀럽', imageUrl, size, border = false, ...props }: ProfileImageProps) { @@ -13,11 +13,10 @@ function ProfileImage({ name = '셀럽', imageUrl, size, border = false, ...prop export default ProfileImage; -const StyledProfile = styled.img<{ size: number; border: boolean }>` - width: ${({ size }) => `${size}px`}; - height: ${({ size }) => `${size}px`}; +const StyledProfile = styled.img<{ size: string; border: boolean }>` + width: ${({ size }) => size || 'auto'}; + height: ${({ size }) => size || 'auto'}; - border: ${({ border }) => (border ? `2px solid var(--primary-1)` : `none`)}; border-radius: 50%; - background: var(--red-5); + background: none; `; 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/@common/ProfileImageList/ProfileImageList.stories.tsx b/frontend/src/components/@common/ProfileImageList/ProfileImageList.stories.tsx new file mode 100644 index 000000000..2d4992598 --- /dev/null +++ b/frontend/src/components/@common/ProfileImageList/ProfileImageList.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ProfileImageList from './ProfileImageList'; + +const meta: Meta = { + title: 'ProfileImageList', + component: ProfileImageList, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + celebs: [ + { + name: '누군가', + profileImageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + id: 1, + youtubeChannelName: '@d0dam', + }, + { + name: '누군가', + profileImageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + id: 2, + youtubeChannelName: '@d0dam', + }, + { + name: '누군가', + profileImageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + id: 3, + youtubeChannelName: '@d0dam', + }, + { + name: '누군가', + profileImageUrl: 'https://avatars.githubusercontent.com/u/51052049?v=4', + id: 4, + youtubeChannelName: '@d0dam', + }, + ], + size: '42px', + }, +}; diff --git a/frontend/src/components/@common/ProfileImageList/ProfileImageList.tsx b/frontend/src/components/@common/ProfileImageList/ProfileImageList.tsx new file mode 100644 index 000000000..1ee2c0ec1 --- /dev/null +++ b/frontend/src/components/@common/ProfileImageList/ProfileImageList.tsx @@ -0,0 +1,49 @@ +import { styled } from 'styled-components'; +import ProfileImage from '../ProfileImage/ProfileImage'; +import useBooleanState from '~/hooks/useBooleanState'; + +import type { Celeb } from '~/@types/celeb.types'; + +interface ProfileImageListProps { + celebs: Celeb[]; + size: string; +} + +function ProfileImageList({ celebs, size }: ProfileImageListProps) { + const { value: hover, setTrue, setFalse } = useBooleanState(false); + + return ( + + {celebs.map((celeb, index) => ( + + + + ))} + + ); +} + +export default ProfileImageList; + +const StyledProfileImageList = styled.div<{ size: string }>` + position: relative; + + width: ${({ size }) => `${size}`}; + height: ${({ size }) => `${size}`}; +`; + +const StyledProfileImageWrapper = styled.div<{ index: number; hover: boolean }>` + position: absolute; + z-index: ${({ index }) => 100 - index}; + + transition: 0.4s ease-in-out; + + ${({ hover, index }) => + hover + ? ` + transform: translateX(${index * -110}%); + ` + : ` + transform: translateX(${index * -20}%); + `}; +`; diff --git a/frontend/src/components/@common/ProfileImageList/index.tsx b/frontend/src/components/@common/ProfileImageList/index.tsx new file mode 100644 index 000000000..11aaa2980 --- /dev/null +++ b/frontend/src/components/@common/ProfileImageList/index.tsx @@ -0,0 +1,3 @@ +import ProfileImageList from './ProfileImageList'; + +export default ProfileImageList; diff --git a/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.stories.tsx b/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.stories.tsx new file mode 100644 index 000000000..3026f3220 --- /dev/null +++ b/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import WaterMarkImage from './WaterMarkImage'; + +const meta: Meta = { + title: 'WaterMarkImage', + component: WaterMarkImage, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + waterMark: '@d0dam', + imageUrl: 'https://picsum.photos/315/300', + }, +}; diff --git a/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.tsx b/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.tsx new file mode 100644 index 000000000..2a2fa9b89 --- /dev/null +++ b/frontend/src/components/@common/WaterMarkImage/WaterMarkImage.tsx @@ -0,0 +1,51 @@ +import styled from 'styled-components'; +import { BORDER_RADIUS, FONT_SIZE, paintSkeleton } from '~/styles/common'; + +interface WaterMarkImageProps { + waterMark: string; + imageUrl: string; +} + +function WaterMarkImage({ waterMark, imageUrl }: WaterMarkImageProps) { + return ( + + + {waterMark} + + ); +} + +export default WaterMarkImage; + +const StyledWaterMarkImage = styled.div` + position: relative; + + width: 100%; + aspect-ratio: 1.05 / 1; + + height: auto; +`; + +const StyledImage = styled.img` + ${paintSkeleton} + display: block; + + aspect-ratio: 1.05 / 1; + object-fit: cover; + + width: 100%; +`; + +const StyledWaterMark = styled.div` + position: absolute; + top: 12px; + left: 12px; + + padding: 0.4rem 0.8rem; + + border-radius: ${BORDER_RADIUS.xs}; + background-color: var(--white); + + color: var(--black); + font-size: ${FONT_SIZE.sm}; +`; diff --git a/frontend/src/components/@common/WaterMarkImage/index.tsx b/frontend/src/components/@common/WaterMarkImage/index.tsx new file mode 100644 index 000000000..b45cee9d3 --- /dev/null +++ b/frontend/src/components/@common/WaterMarkImage/index.tsx @@ -0,0 +1,3 @@ +import WaterMarkImage from './WaterMarkImage'; + +export default WaterMarkImage; diff --git a/frontend/src/components/CategoryNavbar/CategoryNavbar.tsx b/frontend/src/components/CategoryNavbar/CategoryNavbar.tsx index f326bcb71..53ecd90ed 100644 --- a/frontend/src/components/CategoryNavbar/CategoryNavbar.tsx +++ b/frontend/src/components/CategoryNavbar/CategoryNavbar.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; import styled from 'styled-components'; -import { RestaurantCategory } from '~/@types/restaurant.types'; import NavItem from '~/components/@common/NavButton/NavButton'; - import isEqual from '~/utils/compare'; +import type { RestaurantCategory } from '~/@types/restaurant.types'; + interface Category { label: RestaurantCategory; icon: React.ReactNode; diff --git a/frontend/src/components/CelebBanner/CelebBanner.tsx b/frontend/src/components/CelebBanner/CelebBanner.tsx index 077b14997..9a78383cf 100644 --- a/frontend/src/components/CelebBanner/CelebBanner.tsx +++ b/frontend/src/components/CelebBanner/CelebBanner.tsx @@ -27,7 +27,7 @@ function CelebBanner({ return ( - + {name} {youtubeChannelName} 구독자 {subscriberCount / 10_000}만명 ∙ 음식점 {restaurantCount}개 diff --git a/frontend/src/components/CelebDropDown/CelebDropDown.tsx b/frontend/src/components/CelebDropDown/CelebDropDown.tsx index a560a35d6..54201f29e 100644 --- a/frontend/src/components/CelebDropDown/CelebDropDown.tsx +++ b/frontend/src/components/CelebDropDown/CelebDropDown.tsx @@ -40,7 +40,7 @@ function CelebDropDown({ celebs, externalOnClick, isOpen = false }: DropDownProp {celebs.map(({ id, name, profileImageUrl }) => (
- + {name}
{isEqual(selected, name) && } diff --git a/frontend/src/components/InfoDropDown/InfoDropDown.stories.tsx b/frontend/src/components/InfoDropDown/InfoDropDown.stories.tsx new file mode 100644 index 000000000..1c62ce2f5 --- /dev/null +++ b/frontend/src/components/InfoDropDown/InfoDropDown.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import InfoDropDown from './InfoDropDown'; + +const meta: Meta = { + title: 'Selector/InfoDropDown', + component: InfoDropDown, +}; + +export default meta; + +const options = [ + { + id: 1, + value: '로그인', + }, + { + id: 2, + value: '회원가입', + }, + { + id: 3, + value: '기타', + }, + { + id: 4, + value: '등 등', + }, +]; + +type Story = StoryObj; + +export const Default: Story = { + args: { + options, + }, +}; diff --git a/frontend/src/components/InfoDropDown/InfoDropDown.tsx b/frontend/src/components/InfoDropDown/InfoDropDown.tsx new file mode 100644 index 000000000..d757dcc98 --- /dev/null +++ b/frontend/src/components/InfoDropDown/InfoDropDown.tsx @@ -0,0 +1,119 @@ +import styled from 'styled-components'; +import { MouseEvent } from 'react'; +import InfoButton from '~/components/@common/InfoButton'; +import useBooleanState from '~/hooks/useBooleanState'; + +interface Option { + id: number; + value: string; +} + +interface DropDownProps { + options: Option[]; + isOpen?: boolean; + externalOnClick?: (e?: React.MouseEvent) => void; +} + +function InfoDropDown({ options, externalOnClick, isOpen = false }: DropDownProps) { + const { value: isShow, toggle: onToggleDropDown, setFalse: onCloseDropDown } = useBooleanState(isOpen); + + const onSelection = () => (event?: MouseEvent) => { + if (externalOnClick) externalOnClick(event); + }; + + return ( + + + + + + {isShow && ( + + + {options.map(({ id, value }) => ( + + {value} + + ))} + + + )} + + ); +} + +export default InfoDropDown; + +const StyledInfoButtonWrapper = styled.button` + border: none; + background: transparent; + + cursor: pointer; + outline: none; +`; + +const StyledInfoDropDown = styled.div` + display: relative; + + z-index: 100000000; + + width: 77px; + height: 42px; +`; + +const StyledDropDownWrapper = styled.ul` + display: flex; + flex-direction: column; + align-content: center; + + position: absolute; + top: calc(100% - 8px); + right: 18px; + + width: 216px; + height: 176px; + + padding: 1.8rem 0; + + border-radius: 10px; + background: white; + + font-size: 1.4rem; + + box-shadow: var(--shadow); +`; + +const StyledSelectContainer = styled.div` + width: 100%; + height: 150px; + + background: transparent; + + overflow-y: auto; +`; + +const StyledDropDownOption = styled.li` + display: flex; + justify-content: space-between; + align-items: center; + + height: 44px; + + margin: 0 1.8rem; + + cursor: pointer; + + & + & { + border-bottom: 1px solid var(--gray-1); + } + + &:first-child { + border-bottom: 1px solid var(--gray-1); + } + + & > div { + display: flex; + align-items: center; + gap: 0.4rem; + } +`; diff --git a/frontend/src/components/InfoDropDown/index.tsx b/frontend/src/components/InfoDropDown/index.tsx new file mode 100644 index 000000000..e4ff6498b --- /dev/null +++ b/frontend/src/components/InfoDropDown/index.tsx @@ -0,0 +1,3 @@ +import InfoDropDown from '~/components/InfoDropDown/InfoDropDown'; + +export default InfoDropDown; diff --git a/frontend/src/components/LoginModalContent/LoginModalContent.stories.tsx b/frontend/src/components/LoginModalContent/LoginModalContent.stories.tsx new file mode 100644 index 000000000..48f398ca7 --- /dev/null +++ b/frontend/src/components/LoginModalContent/LoginModalContent.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import LoginModalContent from './LoginModalContent'; + +const meta: Meta = { + title: 'Modal/LoginModalContent', + component: LoginModalContent, + decorators: [ + Story => ( + + + } /> + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/frontend/src/components/LoginModalContent/LoginModalContent.tsx b/frontend/src/components/LoginModalContent/LoginModalContent.tsx new file mode 100644 index 000000000..a7c7eef30 --- /dev/null +++ b/frontend/src/components/LoginModalContent/LoginModalContent.tsx @@ -0,0 +1,20 @@ +import styled from 'styled-components'; +import LoginButton from '~/components/@common/LoginButton'; + +function LoginModalContent() { + return ( + + + + + + ); +} + +export default LoginModalContent; + +const StyledLoginModalContent = styled.div` + a + a { + margin-top: 1.6rem; + } +`; diff --git a/frontend/src/components/LoginModalContent/index.tsx b/frontend/src/components/LoginModalContent/index.tsx new file mode 100644 index 000000000..c632d277c --- /dev/null +++ b/frontend/src/components/LoginModalContent/index.tsx @@ -0,0 +1,3 @@ +import LoginModalContent from '~/components/LoginModalContent/LoginModalContent'; + +export default LoginModalContent; diff --git a/frontend/src/components/MapModalContent/MapModalContent.tsx b/frontend/src/components/MapModalContent/MapModalContent.tsx index b85084d36..9350e71f9 100644 --- a/frontend/src/components/MapModalContent/MapModalContent.tsx +++ b/frontend/src/components/MapModalContent/MapModalContent.tsx @@ -2,6 +2,7 @@ import { styled } from 'styled-components'; import { RestaurantModalInfo } from '~/@types/restaurant.types'; import { BORDER_RADIUS, FONT_SIZE } from '~/styles/common'; import TextButton from '../@common/Button'; +import { BASE_URL } from '~/App'; interface MapModalContentProps { content: RestaurantModalInfo; @@ -17,10 +18,7 @@ function MapModalContent({ content }: MapModalContentProps) {
{roadAddress}
{phoneNumber}
- + >; } -function RestaurantCard({ restaurant, celebs, size, onClick }: RestaurantCardProps) { - const { images, name, roadAddress, category } = restaurant; +function RestaurantCard({ + restaurant, + celebs, + size, + type = 'list', + onClick = () => {}, + setHoveredId = () => {}, +}: RestaurantCardProps) { + const { images, name, roadAddress, category, phoneNumber } = restaurant; + + const onMouseEnter = () => { + setHoveredId(restaurant.id); + }; + + const onMouseLeave = () => { + setHoveredId(null); + }; return ( - - + + + + +
{category} {name} {roadAddress} - 02-1234-5678 + {phoneNumber} - + {celebs && }
@@ -51,18 +74,18 @@ const StyledContainer = styled.div` cursor: pointer; `; -const StyledImage = styled.img` - width: 100%; - aspect-ratio: 1.05 / 1; - - border-radius: ${BORDER_RADIUS.md}; +const StyledImageViewer = styled.div` + position: relative; - object-fit: cover; + & > svg { + position: absolute; + top: 12px; + right: 12px; + } `; const StyledInfo = styled.div` display: flex; - flex: 1; flex-direction: column; gap: 0.4rem; 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..3aa388b47 --- /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'; +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/components/VideoPreview/VideoPreview.tsx b/frontend/src/components/VideoPreview/VideoPreview.tsx index 78b42ca14..4b3c261ad 100644 --- a/frontend/src/components/VideoPreview/VideoPreview.tsx +++ b/frontend/src/components/VideoPreview/VideoPreview.tsx @@ -42,7 +42,7 @@ function VideoPreview({ )} - + {title}
{celebName}
diff --git a/frontend/src/constants/api.ts b/frontend/src/constants/api.ts new file mode 100644 index 000000000..a4a3f9c22 --- /dev/null +++ b/frontend/src/constants/api.ts @@ -0,0 +1,13 @@ +export const BASE_URL = `${process.env.BASE_URL}`; + +export const OAUTH_LINK = { + google: `${BASE_URL}/api/oauth/google`, + kakao: `${BASE_URL}/api/oauth/kakao`, + naver: `${BASE_URL}/api/oauth/naver`, +}; + +export const OAUTH_BUTTON_MESSAGE = { + google: '구글로 로그인하기', + kakao: '카카오로 로그인하기', + naver: '네이버로 로그인하기', +}; diff --git a/frontend/src/constants/celebs.ts b/frontend/src/constants/celebs.ts index 10da6bf1e..65aba2e6a 100644 --- a/frontend/src/constants/celebs.ts +++ b/frontend/src/constants/celebs.ts @@ -1,3 +1,5 @@ +import All from '~/assets/all.png'; + export const CELEBS = [ { id: 1, @@ -48,8 +50,7 @@ export const CELEBS_OPTIONS = [ id: -1, name: '전체', youtubeChannelName: '@all', - profileImageUrl: - 'https://yt3.googleusercontent.com/ytc/AOPolaQnS9nKQFCtkBihMKt1Jhm-nzkUFY3Z6RQpwnbf=s176-c-k-c0x00ffffff-no-rj', + profileImageUrl: All, }, ...CELEBS, ]; diff --git a/frontend/src/constants/restaurantCategory.tsx b/frontend/src/constants/restaurantCategory.tsx index 504ee16a9..9868f508c 100644 --- a/frontend/src/constants/restaurantCategory.tsx +++ b/frontend/src/constants/restaurantCategory.tsx @@ -8,6 +8,7 @@ import Pub from '~/assets/icons/restaurantCategory/pub.svg'; import Sashimi from '~/assets/icons/restaurantCategory/sashimi.svg'; import Sushi from '~/assets/icons/restaurantCategory/sushi.svg'; import Wine from '~/assets/icons/restaurantCategory/wine.svg'; +import All from '~/assets/icons/restaurantCategory/all.svg'; import type { RestaurantCategory } from '~/@types/restaurant.types'; interface Category { @@ -18,7 +19,7 @@ interface Category { const RESTAURANT_CATEGORY: Category[] = [ { label: '전체', - icon: , + icon: , }, { label: '일식당', 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/hooks/useOnClickOutside.ts b/frontend/src/hooks/useOnClickOutside.ts new file mode 100644 index 000000000..f95470cc2 --- /dev/null +++ b/frontend/src/hooks/useOnClickOutside.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 ef251fcce..8d3a0823e 100644 --- a/frontend/src/pages/MainPage.tsx +++ b/frontend/src/pages/MainPage.tsx @@ -5,55 +5,39 @@ import Header from '~/components/@common/Header'; import Map from '~/components/@common/Map'; import CategoryNavbar from '~/components/CategoryNavbar'; import CelebDropDown from '~/components/CelebDropDown/CelebDropDown'; -import MapModal from '~/components/MapModal/MapModal'; -import RestaurantCard from '~/components/RestaurantCard'; import RESTAURANT_CATEGORY from '~/constants/restaurantCategory'; import { CELEBS_OPTIONS } from '~/constants/celebs'; import useFetch from '~/hooks/useFetch'; -import useMapModal from '~/hooks/useMapModal'; 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 { Coordinate, CoordinateBoundary } from '~/@types/map.types'; -import type { Restaurant, RestaurantCategory, RestaurantModalInfo } from '~/@types/restaurant.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 [currentRestaurant, setCurrentRestaurant] = useState(null); - const { modalOpen, isVisible, closeModal, openModal } = useMapModal(true); 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('전체'); + const [hoveredId, setHoveredId] = useState(null); const { handleFetch } = useFetch('restaurants'); const fetchRestaurants = useCallback( async (queryObject: { boundary: CoordinateBoundary; celebId: number; category: RestaurantCategory }) => { + setLoading(true); const queryString = getQueryString(queryObject); const response = await handleFetch({ queryString }); - - setData(response.content); + + setData(response); + setLoading(false); }, [boundary, celebId, restaurantCategory], ); - const clickCard = (restaurant: Restaurant) => { - const { lat, lng, ...restaurantModalInfo } = restaurant; - - openModal(); - setCurrentRestaurant(restaurantModalInfo); - }; - - const clickMarker = ({ lat, lng }: Coordinate) => { - const filteredRestaurant = data.find(restaurantData => lat === restaurantData.lat && lng === restaurantData.lng); - - const { id, name, category, roadAddress, phoneNumber, naverMapUrl, images }: RestaurantModalInfo = - filteredRestaurant; - - setCurrentRestaurant({ id, name, category, roadAddress, phoneNumber, naverMapUrl, images }); - }; - const clickRestaurantCategory = (e: React.MouseEvent) => { const currentCategory = e.currentTarget.dataset.label as RestaurantCategory; @@ -86,28 +70,16 @@ function MainPage() { - 음식점 수 {data.length} 개 - - {data?.map(({ celebs, ...restaurant }: RestaurantData) => ( - clickCard(restaurant)} /> - ))} - + ({ position: { lat, lng }, celebs }))} + data={data?.content} toggleMapExpand={toggleMapExpand} + hoveredId={hoveredId} + loadingData={loading} /> - {currentRestaurant && ( - - )}