Skip to content

Commit

Permalink
Merge pull request #532 from woowacourse-teams/feat/444-map-feature
Browse files Browse the repository at this point in the history
카페의 위치를 보여줄 수 있는 지도를 구현
  • Loading branch information
jeongwusi authored Oct 5, 2023
2 parents c766693 + dcd1fd0 commit 2a9f640
Show file tree
Hide file tree
Showing 35 changed files with 888 additions and 23 deletions.
2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Binary file added client/public/assets/coffee-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added client/public/assets/current-position-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 6 additions & 3 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,9 +24,11 @@ const App = () => {
<ThemeProvider theme={theme}>
<ResetStyle />
<GlobalStyle />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
<ToastProvider>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</ToastProvider>
</ThemeProvider>
</QueryClientProvider>
);
Expand Down
13 changes: 12 additions & 1 deletion client/src/client.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -104,6 +104,17 @@ class Client {
return this.fetchJson<CafeMenu>(`/cafes/${cafeId}/menus`);
}

getCafesNearLocation(
longitude: MapBounds['longitude'],
latitude: MapBounds['latitude'],
longitudeDelta: MapBounds['longitudeDelta'],
latitudeDelta: MapBounds['latitudeDelta'],
) {
return this.fetchJson<CafeMapLocation[]>(
`/cafes/location?longitude=${longitude}&latitude=${latitude}&longitudeDelta=${longitudeDelta}&latitudeDelta=${latitudeDelta}`,
);
}

/**
* 인증 수행 시, OAuth 제공자(provider)와 인증 코드(Authorization Code) 값을
* 백엔드에 전송하면 백엔드에서 발급한 accessToken을 응답으로 받을 수 있다.
Expand Down
4 changes: 0 additions & 4 deletions client/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,9 @@ const ButtonVariants = {
};

const Container = styled.button<ButtonProps>`
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%;'}
Expand Down
2 changes: 0 additions & 2 deletions client/src/components/CafeActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,6 @@ const ActionButton = (props: ActionButtonProps) => {
};

const ActionButtonContainer = styled.button`
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
Expand Down
1 change: 0 additions & 1 deletion client/src/components/CafeDetailBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@ const CloseButton = styled.button`
`;

const CloseIcon = styled(BsX)`
cursor: pointer;
font-size: ${({ theme }) => theme.fontSize['2xl']};
`;

Expand Down
120 changes: 120 additions & 0 deletions client/src/components/CafeMap.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const [googleMap, setGoogleMap] = useState<google.maps.Map | null>(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 (
<>
<div ref={ref} id="map" style={{ minHeight: '100vh' }} />
<Suspense>{googleMap && <CafeMapContent map={googleMap} />}</Suspense>
</>
);
};

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 (
<>
<MapLocationButtonContainer>
<MapLocationButton onClick={moveToCurrentUserLocation}>내 위치로 이동</MapLocationButton>
<MapLocationButton onClick={moveToSungsuCafeRoadLocation}>성수동 카페거리로 이동</MapLocationButton>
</MapLocationButtonContainer>

<CafeMarkersContainer map={map} />
{currentPosition && <UserMarker map={map} position={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};
`;
174 changes: 174 additions & 0 deletions client/src/components/CafeMarkers.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 (
<div onClick={(event) => event.stopPropagation()}>
{openedCafeId === cafeLocation.id && (
<ModalContainer>
<ModalCloseButtonContainer>
<ModalCloseButton onClick={handleCloseModal}>
<TfiClose />
</ModalCloseButton>
</ModalCloseButtonContainer>
<ModalTitle>{cafeLocation.name}</ModalTitle>
<ModalSubtitle>{cafeLocation.address}</ModalSubtitle>
<ModalContent></ModalContent>
<ModalButtonContainer>
<StyledLink href={`https://yozm.cafe/cafes/${cafeLocation.id}`} target="_blank" rel="noreferrer">
<ModalDetailButton onClick={handleCloseModal}>상세보기</ModalDetailButton>
</StyledLink>
</ModalButtonContainer>
</ModalContainer>
)}
<Marker onClick={handleOpenModal}>
<MarkerIcon />
</Marker>
</div>
);
};

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(<CafeMarker cafeLocation={cafeLocation} />);

// 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;
}
`;
Loading

0 comments on commit 2a9f640

Please sign in to comment.