(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"