diff --git a/client/package.json b/client/package.json index 7e2ce7a9..79f95e9c 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "cypress:run": "cypress run --e2e --browser chrome --headless" }, "dependencies": { + "@googlemaps/react-wrapper": "^1.1.35", "@tanstack/react-query": "^4.29.19", "@tanstack/react-query-devtools": "^4.32.1", "axios": "^1.4.0", @@ -40,6 +41,7 @@ "@storybook/react": "^7.0.25", "@storybook/react-webpack5": "7.0.25", "@storybook/testing-library": "0.0.14-next.2", + "@types/google.maps": "^3.54.0", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", "@typescript-eslint/eslint-plugin": "^5.61.0", diff --git a/client/public/assets/coffee-icon.png b/client/public/assets/coffee-icon.png new file mode 100644 index 00000000..72d43e77 Binary files /dev/null and b/client/public/assets/coffee-icon.png differ diff --git a/client/public/assets/current-position-icon.png b/client/public/assets/current-position-icon.png new file mode 100644 index 00000000..88d4c637 Binary files /dev/null and b/client/public/assets/current-position-icon.png differ diff --git a/client/src/App.tsx b/client/src/App.tsx index ea931b6d..15334d5b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,6 +3,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { RouterProvider } from 'react-router-dom'; import { ThemeProvider } from 'styled-components'; import { AuthProvider } from './context/AuthContext'; +import { ToastProvider } from './context/ToastContext'; import router from './router'; import GlobalStyle from './styles/GlobalStyle'; import ResetStyle from './styles/ResetStyle'; @@ -23,9 +24,11 @@ const App = () => { - - - + + + + + ); diff --git a/client/src/client.ts b/client/src/client.ts index f0c6983b..a3cef0b1 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -1,4 +1,4 @@ -import type { AuthProvider, AuthUrl, Cafe, CafeMenu, LikedCafe, Rank, User } from './types'; +import type { AuthProvider, AuthUrl, Cafe, CafeMapLocation, CafeMenu, LikedCafe, MapBounds, Rank, User } from './types'; export class ClientNetworkError extends Error { constructor() { @@ -104,6 +104,17 @@ class Client { return this.fetchJson(`/cafes/${cafeId}/menus`); } + getCafesNearLocation( + longitude: MapBounds['longitude'], + latitude: MapBounds['latitude'], + longitudeDelta: MapBounds['longitudeDelta'], + latitudeDelta: MapBounds['latitudeDelta'], + ) { + return this.fetchJson( + `/cafes/location?longitude=${longitude}&latitude=${latitude}&longitudeDelta=${longitudeDelta}&latitudeDelta=${latitudeDelta}`, + ); + } + /** * 인증 수행 시, OAuth 제공자(provider)와 인증 코드(Authorization Code) 값을 * 백엔드에 전송하면 백엔드에서 발급한 accessToken을 응답으로 받을 수 있다. diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx index e142a04d..9850b9b3 100644 --- a/client/src/components/Button.tsx +++ b/client/src/components/Button.tsx @@ -38,13 +38,9 @@ const ButtonVariants = { }; const Container = styled.button` - cursor: pointer; - padding: ${({ theme }) => theme.space['1.5']} 0; - font-size: 16px; font-weight: 500; - border-radius: 40px; ${(props) => ButtonVariants[props.$variant || 'default']} ${(props) => props.$fullWidth && 'width: 100%;'} diff --git a/client/src/components/CafeActionBar.tsx b/client/src/components/CafeActionBar.tsx index b1301711..9055aad8 100644 --- a/client/src/components/CafeActionBar.tsx +++ b/client/src/components/CafeActionBar.tsx @@ -94,8 +94,6 @@ const ActionButton = (props: ActionButtonProps) => { }; const ActionButtonContainer = styled.button` - cursor: pointer; - display: flex; flex-direction: column; align-items: center; diff --git a/client/src/components/CafeDetailBottomSheet.tsx b/client/src/components/CafeDetailBottomSheet.tsx index d3a60894..28e6db5c 100644 --- a/client/src/components/CafeDetailBottomSheet.tsx +++ b/client/src/components/CafeDetailBottomSheet.tsx @@ -145,7 +145,6 @@ const CloseButton = styled.button` `; const CloseIcon = styled(BsX)` - cursor: pointer; font-size: ${({ theme }) => theme.fontSize['2xl']}; `; diff --git a/client/src/components/CafeMap.tsx b/client/src/components/CafeMap.tsx new file mode 100644 index 00000000..f97d12fe --- /dev/null +++ b/client/src/components/CafeMap.tsx @@ -0,0 +1,120 @@ +import React, { Suspense, useEffect, useRef, useState } from 'react'; +import { styled } from 'styled-components'; +import { SEONGSU_BOUNDS_LOCATION, SEONGSU_CAFE_STREET_LOCATION, SEONGSU_MAP_INITIAL_ZOOM_SIZE } from '../constants'; +import useCafesNearLocation from '../hooks/useCafesNearLocation'; +import useCurrentPosition from '../hooks/useCurrentPosition'; +import CafeMarkersContainer from './CafeMarkersContainer'; +import UserMarker from './UserMarker'; + +const CafeMap = () => { + const ref = useRef(null); + const [googleMap, setGoogleMap] = useState(null); + + useEffect(() => { + if (!ref.current) return; + + const googleMap = new window.google.maps.Map(ref.current, { + center: SEONGSU_CAFE_STREET_LOCATION, + zoom: SEONGSU_MAP_INITIAL_ZOOM_SIZE, + disableDefaultUI: true, + clickableIcons: false, + mapId: '32c9cce63f7772a8', + maxZoom: 20, + minZoom: 14, + restriction: { + latLngBounds: SEONGSU_BOUNDS_LOCATION, + }, + }); + + setGoogleMap(googleMap); + }, []); + + return ( + <> +
+ {googleMap && } + + ); +}; + +type CafeMapContentProps = { + map: google.maps.Map; +}; + +const CafeMapContent = (props: CafeMapContentProps) => { + const { map } = props; + + const currentPosition = useCurrentPosition(); + const { refetch } = useCafesNearLocation(map); + + const setPosition = (position: google.maps.LatLngLiteral) => { + map.setCenter(position); + }; + + const moveToCurrentUserLocation = () => { + if (!currentPosition) return; + + const bounds = new google.maps.LatLngBounds(SEONGSU_BOUNDS_LOCATION); + + // 원하는 위치가 범위 내에 있는지 확인합니다. + if (bounds.contains(currentPosition)) { + map.panTo(currentPosition); + setPosition(currentPosition); + } else { + // 경고 메시지를 표시합니다. + alert('서비스는 성수 지역에서만 이용 가능합니다.'); + map.panTo(SEONGSU_CAFE_STREET_LOCATION); + setPosition(SEONGSU_CAFE_STREET_LOCATION); + } + }; + + const moveToSungsuCafeRoadLocation = () => { + map.panTo(SEONGSU_CAFE_STREET_LOCATION); + setPosition(SEONGSU_CAFE_STREET_LOCATION); + }; + + useEffect(() => { + const listener = map.addListener('idle', () => { + refetch(); + }); + + return () => google.maps.event.removeListener(listener); + }, [map]); + + return ( + <> + + 내 위치로 이동 + 성수동 카페거리로 이동 + + + + {currentPosition && } + + ); +}; + +export default React.memo(CafeMap); + +const MapLocationButtonContainer = styled.div` + position: absolute; + z-index: 999; + top: 65px; + + display: flex; + gap: 10px; + + width: 100%; + padding: 10px; +`; + +const MapLocationButton = styled.button` + padding: 10px; + + color: ${({ theme }) => theme.color.black}; + + background-color: ${({ theme }) => theme.color.white}; + border: none; + border-radius: 10px; + box-shadow: ${({ theme }) => theme.shadow.map}; +`; diff --git a/client/src/components/CafeMarkers.tsx b/client/src/components/CafeMarkers.tsx new file mode 100644 index 00000000..a671c5eb --- /dev/null +++ b/client/src/components/CafeMarkers.tsx @@ -0,0 +1,174 @@ +import type { MouseEventHandler } from 'react'; +import { useCallback, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { TfiClose } from 'react-icons/tfi'; +import { styled } from 'styled-components'; +import useObservable from '../hooks/useObservable'; +import type { CafeMapLocation } from '../types'; +import Observable from '../utils/Observable'; + +const openedCafeIdObserverable = new Observable(null); + +type CafeMarkerProps = { + cafeLocation: CafeMapLocation; +}; + +const CafeMarker = (props: CafeMarkerProps) => { + const { cafeLocation } = props; + const [openedCafeId, setOpenedCafeId] = useObservable(openedCafeIdObserverable); + + const handleCloseModal = useCallback(() => { + setOpenedCafeId(null); + }, []); + + const handleOpenModal: MouseEventHandler = useCallback(() => { + setOpenedCafeId(cafeLocation.id); + }, []); + + return ( +
event.stopPropagation()}> + {openedCafeId === cafeLocation.id && ( + + + + + + + {cafeLocation.name} + {cafeLocation.address} + + + + 상세보기 + + + + )} + + + +
+ ); +}; + +type CafeMarkersProps = { + map: google.maps.Map; + cafeLocation: CafeMapLocation; +}; + +const CafeMarkers = (props: CafeMarkersProps) => { + const { map, cafeLocation } = props; + const { latitude, longitude, name } = cafeLocation; + + useEffect(() => { + const container = document.createElement('div'); + const markerRoot = createRoot(container); + + const newMarker = new google.maps.marker.AdvancedMarkerElement({ + position: { lat: latitude, lng: longitude }, + map, + title: name, + content: container, + }); + + const handleOpenedCafeIdChange = () => { + const isOpened = openedCafeIdObserverable.getState() === cafeLocation.id; + newMarker.zIndex = isOpened ? 1 : 0; + }; + + openedCafeIdObserverable.subscribe(handleOpenedCafeIdChange); + + markerRoot.render(); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + const noop = () => {}; + + newMarker.addListener('click', noop); + + return () => { + openedCafeIdObserverable.unsubscribe(handleOpenedCafeIdChange); + }; + }, []); + + return <>; +}; + +export default CafeMarkers; + +const Marker = styled.button` + position: absolute; + transform: translate(-50%, -50%); +`; + +const MarkerIcon = styled.img.attrs({ src: '/assets/coffee-icon.png', alt: '카페마커' })``; + +const ModalContainer = styled.div` + position: absolute; + transform: translate(-50%, -100%) translateY(-30px) translateZ(100px); + + width: max-content; + min-width: 160px; + padding: 16px; + + background: white; + border: 1px solid rgba(0, 0, 0, 0.05); + border-radius: 10px; + box-shadow: rgba(0, 0, 0, 0.12) 0px 2px 4px 0px; + + &::before { + content: ''; + + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + + border-color: white transparent transparent transparent; + border-style: solid; + border-width: 10px; + } +`; + +const ModalTitle = styled.h2` + margin-bottom: 8px; + font-size: 20px; +`; + +const ModalSubtitle = styled.h3` + font-size: 12px; +`; + +const ModalContent = styled.div` + margin-bottom: 16px; +`; + +const ModalButtonContainer = styled.div` + display: flex; + justify-content: space-between; + width: 100%; +`; + +const ModalCloseButtonContainer = styled.div` + display: flex; + flex-direction: row-reverse; +`; + +const StyledLink = styled.a` + width: 100%; +`; + +const ModalCloseButton = styled.button``; + +const ModalDetailButton = styled.button` + width: 100%; + padding: 8px 16px; + + color: white; + + background-color: #f08080; + border: none; + border-radius: 10px; + &:hover { + background-color: #f4a9a8; + } +`; diff --git a/client/src/components/CafeMarkersContainer.tsx b/client/src/components/CafeMarkersContainer.tsx new file mode 100644 index 00000000..52685660 --- /dev/null +++ b/client/src/components/CafeMarkersContainer.tsx @@ -0,0 +1,24 @@ +import useCafesNearLocation from '../hooks/useCafesNearLocation'; +import CafeMarkers from './CafeMarkers'; + +type CafeMarkersContainerProps = { + map: google.maps.Map; +}; + +const CafeMarkersContainer = (props: CafeMarkersContainerProps) => { + const { map } = props; + const { ...queryInto } = useCafesNearLocation(map); + const cafes = queryInto.data; + + if (!cafes || !queryInto.isSuccess) return <>; + + return ( + <> + {cafes.map((cafe) => ( + + ))} + + ); +}; + +export default CafeMarkersContainer; diff --git a/client/src/components/CafeMenuBottomSheet.tsx b/client/src/components/CafeMenuBottomSheet.tsx index 1b80211f..4ca2c758 100644 --- a/client/src/components/CafeMenuBottomSheet.tsx +++ b/client/src/components/CafeMenuBottomSheet.tsx @@ -123,7 +123,6 @@ const CloseButton = styled.button` `; const CloseIcon = styled(BsX)` - cursor: pointer; font-size: ${({ theme }) => theme.fontSize['2xl']}; `; @@ -142,8 +141,6 @@ const Placeholder = styled.div` `; const ShowMenuBoardButton = styled.button<{ $imageUrl: string }>` - cursor: pointer; - padding: ${({ theme }) => theme.space[5]} ${({ theme }) => theme.space[10]}; font-size: ${({ theme }) => theme.fontSize.lg}; diff --git a/client/src/components/ImageModal.tsx b/client/src/components/ImageModal.tsx index 7987f618..433269ab 100644 --- a/client/src/components/ImageModal.tsx +++ b/client/src/components/ImageModal.tsx @@ -78,7 +78,6 @@ const ImageListItem = styled.li` `; const ImageListItemButton = styled.button` - cursor: pointer; width: 100%; height: 100%; `; diff --git a/client/src/components/LoadingBar.tsx b/client/src/components/LoadingBar.tsx new file mode 100644 index 00000000..e416ff4b --- /dev/null +++ b/client/src/components/LoadingBar.tsx @@ -0,0 +1,44 @@ +import { css, keyframes, styled } from 'styled-components'; + +const LoadingBar = () => { + return ( + + {[1, 2, 3, 4, 5].map((index) => ( + + ))} + + ); +}; + +export default LoadingBar; + +const LoadingBarContainer = styled.div` + position: absolute; + display: flex; + padding: 12px; +`; + +const Slide = keyframes` + 0% { + transform: scale(1); + } + 50% { + opacity: 0.3; + transform: scale(2); + } + 100% { + transform: scale(1); + } +`; + +const Dot = styled.div<{ delay: number }>` + width: 24px; + height: 24px; + background: ${({ theme }) => theme.color.primary}; + border-radius: 100%; + + animation: ${({ delay }) => + css` + ${Slide} 1s infinite ${delay}s + `}; +`; diff --git a/client/src/components/LoginButton.tsx b/client/src/components/LoginButton.tsx index 97897fd7..9378d30f 100644 --- a/client/src/components/LoginButton.tsx +++ b/client/src/components/LoginButton.tsx @@ -20,8 +20,6 @@ const LoginButton = (props: ButtonProps) => { export default LoginButton; const Container = styled.button` - cursor: pointer; - width: 44px; height: 44px; diff --git a/client/src/components/LoginModal.tsx b/client/src/components/LoginModal.tsx index b4f2915b..9ee2487f 100644 --- a/client/src/components/LoginModal.tsx +++ b/client/src/components/LoginModal.tsx @@ -91,9 +91,7 @@ const CloseButtonContainer = styled.button` color: ${({ theme }) => theme.color.gray}; `; -const CloseIcon = styled(CgClose)` - cursor: pointer; -`; +const CloseIcon = styled(CgClose)``; const LoginTitle = styled.div` display: flex; diff --git a/client/src/components/ScrollSnapContainer.tsx b/client/src/components/ScrollSnapContainer.tsx index 5162b773..8723ae81 100644 --- a/client/src/components/ScrollSnapContainer.tsx +++ b/client/src/components/ScrollSnapContainer.tsx @@ -2,6 +2,7 @@ import type React from 'react'; import type { HTMLAttributes, MouseEventHandler, PropsWithChildren, TouchEventHandler, WheelEventHandler } from 'react'; import { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; +import useEffectEvent from '../shims/useEffectEvent'; type TimingFn = (x: number) => number; @@ -407,6 +408,25 @@ const ScrollSnapContainer = (props: ScrollSnapContainerProps) => { onSnapStart(); }; + const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowUp': + setActiveIndex(activeIndex - 1); + onSnapStart(); + break; + case 'ArrowDown': + setActiveIndex(activeIndex + 1); + onSnapStart(); + break; + } + }); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + return ( { + const { map, position } = props; + const markerRef = useRef(null); + + useEffect(() => { + const marker = new google.maps.Marker({ + position, + map, + icon: '/assets/current-position-icon.png', + }); + markerRef.current = marker; + + return () => { + marker.setMap(null); + markerRef.current = null; + }; + }, [map]); + + useEffect(() => { + markerRef.current?.setPosition(position); + }, [markerRef.current, position.lat, position.lng]); + + return <>; +}; + +export default UserMarker; diff --git a/client/src/constants/index.ts b/client/src/constants/index.ts new file mode 100644 index 00000000..349e7eef --- /dev/null +++ b/client/src/constants/index.ts @@ -0,0 +1,13 @@ +export const SEONGSU_CAFE_STREET_LOCATION: google.maps.LatLngLiteral = { + lat: 37.543327, + lng: 127.056738, +}; + +export const SEONGSU_BOUNDS_LOCATION: google.maps.LatLngBoundsLiteral = { + north: 37.5543, + south: 37.5353, + east: 127.0637, + west: 127.0299, +}; + +export const SEONGSU_MAP_INITIAL_ZOOM_SIZE = 17; diff --git a/client/src/context/ToastContext.tsx b/client/src/context/ToastContext.tsx new file mode 100644 index 00000000..e86d873a --- /dev/null +++ b/client/src/context/ToastContext.tsx @@ -0,0 +1,111 @@ +import type { PropsWithChildren } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; +import styled, { css, keyframes } from 'styled-components'; + +type ToastVariant = 'default' | 'success' | 'warning' | 'error'; + +type ToastContextType = { + showToast: (variant: ToastVariant, message: string) => void; +}; + +const ToastContext = createContext(undefined); + +type ToastProviderPros = PropsWithChildren; + +export const ToastProvider = (props: ToastProviderPros) => { + const { children } = props; + const [toastState, setToastState] = useState<{ variant: ToastVariant; message: string | null }>({ + variant: 'default', + message: null, + }); + + useEffect(() => { + if (toastState.variant && toastState.message !== null) { + const timer = setTimeout(() => { + setToastState({ variant: 'default', message: null }); + }, 5000); + + return () => clearTimeout(timer); + } + }, [toastState]); + + const showToast = (variant: ToastVariant, message: string) => { + setToastState({ variant, message }); + }; + + const contextValue = { + showToast, + }; + + return ( + + {children} + {toastState.message !== null && ( + + {toastState.message} + + )} + + ); +}; + +export const useToast = () => { + const context = useContext(ToastContext); + + if (!context) { + throw new Error('부모 트리에서 ToastProvider를 사용해주세요'); + } + + return context.showToast; +}; + +const Container = styled.div` + position: absolute; + bottom: 50px; + left: 50%; + transform: translate(-50%); + + display: flex; + flex-direction: column; + + white-space: nowrap; +`; + +const ToastAnimation = keyframes` + from { + transform: translateY(100px); + opacity: 0; + } + + to { + transform: translateY(0px); + opacity: 1; + } +`; + +const ToastColorVariants = { + default: css` + background-color: ${(props) => props.theme.color.primary}; + `, + success: css` + background-color: ${(props) => props.theme.color.success}; + `, + warning: css` + background-color: ${(props) => props.theme.color.warning}; + `, + error: css` + background-color: ${(props) => props.theme.color.error}; + `, +}; + +const ToastMessage = styled.div<{ $variant: ToastVariant }>` + padding: ${({ theme }) => theme.space['3.5']}; + + color: ${({ theme }) => theme.color.white}; + + border-radius: 20px; + box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px; + + animation: 0.3s ${ToastAnimation}, 0.3s 3s reverse forwards ${ToastAnimation}; + ${({ $variant }) => ToastColorVariants[$variant]} +`; diff --git a/client/src/data/mockData.ts b/client/src/data/mockData.ts index 700646c3..342f802a 100644 --- a/client/src/data/mockData.ts +++ b/client/src/data/mockData.ts @@ -1,4 +1,28 @@ -import type { Cafe, CafeMenu, Rank } from '../types'; +import type { Cafe, CafeMapLocation, CafeMenu, Rank } from '../types'; + +export const cafeMarker: CafeMapLocation[] = [ + { + id: 1, + name: '텅 성수 스페이스', + address: '서울특별시 성동구 성수이로 82 2층', + latitude: 37.514933, + longitude: 127.102604, + }, + { + id: 2, + name: '텅 성수 스페이스', + address: '서울특별시 성동구 성수이로 82 2층', + latitude: 37.5144, + longitude: 127.1028, + }, + { + id: 3, + name: '텅 성수 스페이스', + address: '서울특별시 성동구 성수이로 82 2층', + latitude: 37.5077, + longitude: 127.1067, + }, +]; export const RankCafes: Rank[] = [ { id: 1, rank: 1, name: '참치카페', address: '서울 성수', image: 'cafe-image-1.png', likeCount: 50 }, diff --git a/client/src/hooks/useCafesNearLocation.ts b/client/src/hooks/useCafesNearLocation.ts new file mode 100644 index 00000000..0194544e --- /dev/null +++ b/client/src/hooks/useCafesNearLocation.ts @@ -0,0 +1,14 @@ +import client from '../client'; +import { getMapBounds } from '../utils/mapUtils'; +import useSuspenseQuery from './useSuspenseQuery'; + +const useCafesNearLocation = (map: google.maps.Map) => { + const { longitude, latitude, longitudeDelta, latitudeDelta } = getMapBounds(map); + + return useSuspenseQuery({ + queryKey: ['cafesNearLocation'], + queryFn: () => client.getCafesNearLocation(longitude, latitude, longitudeDelta, latitudeDelta), + }); +}; + +export default useCafesNearLocation; diff --git a/client/src/hooks/useCurrentPosition.ts b/client/src/hooks/useCurrentPosition.ts new file mode 100644 index 00000000..1a90393a --- /dev/null +++ b/client/src/hooks/useCurrentPosition.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { SEONGSU_CAFE_STREET_LOCATION } from '../constants'; + +const useCurrentPosition = () => { + const [position, setPosition] = useState(null); + + useEffect(() => { + navigator.geolocation.getCurrentPosition( + (position) => { + setPosition({ + lat: position.coords.latitude, + lng: position.coords.longitude, + }); + }, + () => { + setPosition(SEONGSU_CAFE_STREET_LOCATION); + }, + { + enableHighAccuracy: true, + }, + ); + }, []); + + return position; +}; + +export default useCurrentPosition; diff --git a/client/src/hooks/useObservable.ts b/client/src/hooks/useObservable.ts new file mode 100644 index 00000000..e77ea6bf --- /dev/null +++ b/client/src/hooks/useObservable.ts @@ -0,0 +1,17 @@ +import { useSyncExternalStore } from 'react'; +import type Observable from '../utils/Observable'; + +const useObservable = (Observable: Observable): [T, (state: T) => void] => { + const state = useSyncExternalStore( + (callback) => { + Observable.subscribe(callback); + + return () => Observable.unsubscribe(callback); + }, + () => Observable.getState(), + ); + const dispatch = (state: T) => Observable.setState(state); + return [state, dispatch]; +}; + +export default useObservable; diff --git a/client/src/mocks/handlers.ts b/client/src/mocks/handlers.ts index c12e5d8a..04032842 100644 --- a/client/src/mocks/handlers.ts +++ b/client/src/mocks/handlers.ts @@ -1,10 +1,51 @@ import { rest } from 'msw'; -import { RankCafes, cafeMenus, cafes } from '../data/mockData'; -import type { Identity, User } from '../types'; +import { RankCafes, cafeMarker, cafeMenus, cafes } from '../data/mockData'; +import type { CafeMapLocation, Identity, User } from '../types'; let pageState = 1; export const handlers = [ + // 지도에 핀 꽂을 카페 정보 + rest.get('/api/cafes/location', async (req, res, ctx) => { + const { searchParams } = new URL(req.url.toString()); + const myLocationLongitude = searchParams.get('longitude'); + const myLocationLatitude = searchParams.get('latitude'); + const longitudeDelta = searchParams.get('longitudeDelta'); + const latitudeDelta = searchParams.get('latitudeDelta'); + + if (!myLocationLongitude || !myLocationLatitude || !longitudeDelta || !latitudeDelta) { + return res( + ctx.status(400), + ctx.json({ + message: '인자가 유효하지 않습니다. 다시 확인해주세요.', + }), + ); + } + + const northEastBoundary = { + latitude: parseFloat(myLocationLatitude) + parseFloat(latitudeDelta), + longitude: parseFloat(myLocationLongitude) + parseFloat(longitudeDelta), + }; + + const southWestBoundary = { + latitude: parseFloat(myLocationLatitude) - parseFloat(latitudeDelta), + longitude: parseFloat(myLocationLongitude) - parseFloat(longitudeDelta), + }; + + const isCafeLatitudeWithinBounds = (cafe: CafeMapLocation) => { + return cafe.latitude > southWestBoundary.latitude && cafe.latitude < northEastBoundary.latitude; + }; + + const isCafeLongitudeWithinBounds = (cafe: CafeMapLocation) => { + return cafe.longitude > southWestBoundary.longitude && cafe.longitude < northEastBoundary.longitude; + }; + + const foundCafes: CafeMapLocation[] = cafeMarker.filter( + (cafe) => isCafeLatitudeWithinBounds(cafe) && isCafeLongitudeWithinBounds(cafe), + ); + + return res(ctx.status(200), ctx.json(foundCafes)); + }), // 카페 조회 rest.get('/api/cafes', (req, res, ctx) => { const PAGINATE_UNIT = 5; diff --git a/client/src/pages/CafeMapPage.tsx b/client/src/pages/CafeMapPage.tsx new file mode 100644 index 00000000..417fe891 --- /dev/null +++ b/client/src/pages/CafeMapPage.tsx @@ -0,0 +1,18 @@ +import { Status, Wrapper } from '@googlemaps/react-wrapper'; +import { type ReactElement } from 'react'; +import CafeMap from '../components/CafeMap'; + +const render = (status: Status): ReactElement => { + if (status === Status.FAILURE) return
에러입니다.
; + return
로딩중...
; +}; + +const CafeMapPage = () => { + return ( + + + + ); +}; + +export default CafeMapPage; diff --git a/client/src/pages/ErrorPage.tsx b/client/src/pages/ErrorPage.tsx new file mode 100644 index 00000000..77df7ee6 --- /dev/null +++ b/client/src/pages/ErrorPage.tsx @@ -0,0 +1,74 @@ +import { MdOutlineErrorOutline } from 'react-icons/md'; +import { Link } from 'react-router-dom'; +import { keyframes, styled } from 'styled-components'; +import Button from '../components/Button'; + +const ErrorPage = () => { + return ( + + + 다시 확인해주세요 + 요청하신 내용을 찾을 수 없어요 + + + + + + + ); +}; + +export default ErrorPage; + +const Contaienr = styled.section` + display: flex; + flex-direction: column; + align-items: center; + + height: 100vh; + padding-top: 150px; +`; + +const SentenceContainer = styled.article` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space[5]}; + align-items: center; +`; + +const ButtonContainer = styled(Link)` + width: 133px; +`; + +const MainSentence = styled.h1` + font-size: ${({ theme }) => theme.fontSize['4xl']}; + font-weight: bold; + color: ${({ theme }) => theme.color.text.secondary}; +`; + +const Sentence = styled.span` + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme }) => theme.color.text.secondary}; +`; + +const bounce = keyframes` + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-5px); + } + 60% { + transform: translateY(-3px); + } +`; + +const Icon = styled(MdOutlineErrorOutline)` + width: 100px; + height: 100px; + margin-top: 80px; + + fill: ${({ theme }) => theme.color.primary}; + + animation: ${bounce} 1s ease infinite; +`; diff --git a/client/src/router.tsx b/client/src/router.tsx index 9141d096..31890208 100644 --- a/client/src/router.tsx +++ b/client/src/router.tsx @@ -3,6 +3,7 @@ import { createBrowserRouter } from 'react-router-dom'; import Root from './pages/Root'; const AuthPage = React.lazy(() => import('./pages/AuthPage')); const CafePage = React.lazy(() => import('./pages/CafePage')); +const CafeMapPage = React.lazy(() => import('./pages/CafeMapPage')); const HomePage = React.lazy(() => import('./pages/HomePage')); const LikedCafeDetailPage = React.lazy(() => import('./pages/LikedCafeDetailPage')); const LoadingPage = React.lazy(() => import('./pages/LoadingPage')); @@ -22,6 +23,7 @@ const router = createBrowserRouter([ { path: '/cafes/:cafeId', element: }, { path: 'rank', element: }, { path: 'my-profile/cafes/:cafeId', element: }, + { path: 'map', element: }, ], }, { diff --git a/client/src/shims/useEffectEvent.ts b/client/src/shims/useEffectEvent.ts new file mode 100644 index 00000000..3ab9f198 --- /dev/null +++ b/client/src/shims/useEffectEvent.ts @@ -0,0 +1,15 @@ +import { useCallback, useInsertionEffect, useRef } from 'react'; + +const useEffectEvent = (callback: (...args: Args) => Return) => { + const ref = useRef((..._args: Args): Return => { + throw new Error('Cannot call an event handler while rendering.'); + }); + + useInsertionEffect(() => { + ref.current = callback; + }); + + return useCallback((...args: Args) => ref.current(...args), []); +}; + +export default useEffectEvent; diff --git a/client/src/styles/GlobalStyle.ts b/client/src/styles/GlobalStyle.ts index c60a4751..098a672f 100644 --- a/client/src/styles/GlobalStyle.ts +++ b/client/src/styles/GlobalStyle.ts @@ -27,6 +27,10 @@ const GlobalStyle = createGlobalStyle` height: 100svh; } + button { + cursor: pointer; + } + #root { position: relative; diff --git a/client/src/styles/theme.ts b/client/src/styles/theme.ts index 37bc9936..65b7dfb5 100644 --- a/client/src/styles/theme.ts +++ b/client/src/styles/theme.ts @@ -15,6 +15,7 @@ const theme = { text: { primary: '#212121', + secondary: '#454f5d', }, line: { @@ -70,6 +71,7 @@ const theme = { '4': '0 0 6px 6px rgba(0, 0, 0, 0.2)', '5': '0 0 8px 8px rgba(0, 0, 0, 0.2)', '6': '0 0 10px 10px rgba(0, 0, 0, 0.2)', + map: 'rgba(60, 64, 67, 0.3) 0px 1px 2px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px', }, } as const; diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 0ea661ed..6742cd64 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -84,3 +84,18 @@ export type Rank = { image: string; likeCount: number; }; + +export type CafeMapLocation = { + id: number; + name: string; + address: string; + latitude: number; + longitude: number; +}; + +export type MapBounds = { + longitude: number; + latitude: number; + longitudeDelta: number; + latitudeDelta: number; +}; diff --git a/client/src/utils/Observable.ts b/client/src/utils/Observable.ts new file mode 100644 index 00000000..500ffd93 --- /dev/null +++ b/client/src/utils/Observable.ts @@ -0,0 +1,36 @@ +type Subscriber = () => void; + +class Observable { + protected subscribers: Subscriber[] = []; + + protected state: T; + + constructor(initialState: T) { + this.state = initialState; + } + + subscribe(subscriber: Subscriber) { + this.subscribers.push(subscriber); + } + + unsubscribe(subscriber: Subscriber) { + const foundIndex = this.subscribers.indexOf(subscriber); + if (foundIndex === -1) return; + this.subscribers.splice(foundIndex, 1); + } + + emit() { + this.subscribers.forEach((subscriber) => subscriber()); + } + + getState() { + return this.state; + } + + setState(state: T) { + this.state = state; + this.emit(); + } +} + +export default Observable; diff --git a/client/src/utils/mapUtils.ts b/client/src/utils/mapUtils.ts new file mode 100644 index 00000000..8e5e5b6e --- /dev/null +++ b/client/src/utils/mapUtils.ts @@ -0,0 +1,17 @@ +import type { MapBounds } from '../types'; + +export const getMapBounds = (map: google.maps.Map) => { + const center = map.getCenter() as google.maps.LatLng; + const bounds = map.getBounds() as google.maps.LatLngBounds; + const longitudeDelta = bounds ? (bounds.getNorthEast().lng() - bounds.getSouthWest().lng()) / 2 : 0; + const latitudeDelta = bounds ? (bounds.getNorthEast().lat() - bounds.getSouthWest().lat()) / 2 : 0; + const longitude = center.lng(); + const latitude = center.lat(); + + return { + longitude, + latitude, + longitudeDelta, + latitudeDelta, + } as MapBounds; +}; diff --git a/client/yarn.lock b/client/yarn.lock index c3205c98..4b208d1c 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1762,6 +1762,20 @@ resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.1.tgz" integrity sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw== +"@googlemaps/js-api-loader@^1.13.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@googlemaps/js-api-loader/-/js-api-loader-1.16.2.tgz#3fe748e21243f8e8322c677a5525c569ae9cdbe9" + integrity sha512-psGw5u0QM6humao48Hn4lrChOM2/rA43ZCm3tKK9qQsEj1/VzqkCqnvGfEOshDbBQflydfaRovbKwZMF4AyqbA== + dependencies: + fast-deep-equal "^3.1.3" + +"@googlemaps/react-wrapper@^1.1.35": + version "1.1.35" + resolved "https://registry.yarnpkg.com/@googlemaps/react-wrapper/-/react-wrapper-1.1.35.tgz#fde9146b1ae02805dcad9c0fab56c4bc137b32ee" + integrity sha512-vK+BDQMHN0Oqr66cW3ZPWVK43BUmJJBu6P8T74tc6/fKpUJUlFEaZsupgIIRRRDW9ejB8uGagUmwOnA2gdcvbw== + dependencies: + "@googlemaps/js-api-loader" "^1.13.2" + "@hapi/hoek@^9.0.0": version "9.3.0" resolved "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz" @@ -3612,6 +3626,11 @@ "@types/minimatch" "^5.1.2" "@types/node" "*" +"@types/google.maps@^3.54.0": + version "3.54.0" + resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.54.0.tgz#e1cd94b04edd47030644897105186e1b47a25024" + integrity sha512-b1MBy2eGrZoEFLnzq1RrlHbfzuWHz+Nitgqbb5N+MFA0kAUv0kYPmAXtczpb4dHlFZyu58EYzcKXtWNqSInyXg== + "@types/graceful-fs@^4.1.3": version "4.1.6" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz"