all repos — caroster @ 41eb7416d94d282b03f56d6a18017671ed515a57

[Octree] Group carpool to your event https://caroster.io

✨ Open popup upon clicking the travel card
#441
Simon Mulquin simon@octree.ch
Wed, 20 Dec 2023 10:23:05 +0000
commit

41eb7416d94d282b03f56d6a18017671ed515a57

parent

b665d19a2b5ab4e6c3f62c5344056a27ed78f486

A frontend/containers/EventMarker/EventPopup.tsx

@@ -0,0 +1,76 @@

+import moment from 'moment'; +import Card from '@mui/material/Card'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; +import Box from '@mui/material/Box'; +import {useTranslation} from 'react-i18next'; +import {Popup} from 'react-leaflet'; +import getMapsLink from '../../lib/getMapsLink'; +import {Event} from '../../generated/graphql'; + +interface Props { + event: Event & {id: string}; +} + +const EventPopup = ({event}: Props) => { + const {t} = useTranslation(); + + return ( + <Popup> + <Card sx={{p: 2, width: '350px', maxWidth: '100%'}}> + <Box> + <Typography + variant="h3" + color="primary" + sx={{cursor: 'pointer', display: 'inline-block'}} + > + <Link + color="inherit" + href={`/e/${event.uuid}/details`} + sx={{textDecoration: 'none'}} + > + {event.name} + </Link> + </Typography> + </Box> + {!!event.date && ( + <Box sx={{marginTop: 2}}> + <Typography variant="overline" color="GrayText"> + {t('event.fields.date')} + </Typography> + <Typography variant="body1"> + {moment(event.date).format('LLLL')} + </Typography> + </Box> + )} + {!!event.address && ( + <Box sx={{marginTop: 2}}> + <Typography variant="overline" color="GrayText"> + {t('event.fields.address')} + </Typography> + <Typography variant="body1" color="primary"> + <Link + component="a" + target="_blank" + rel="noopener noreferrer" + href={getMapsLink(event.address)} + sx={{color: 'primary.main'}} + > + {event.address} + </Link> + </Typography> + </Box> + )} + {!!event.description && ( + <Box sx={{marginTop: 2}}> + <Typography variant="overline" color="GrayText"> + {t('event.fields.description')} + </Typography> + <Typography variant="body1">{event.description}</Typography> + </Box> + )} + </Card> + </Popup> + ); +}; +export default EventPopup;
A frontend/containers/EventMarker/index.tsx

@@ -0,0 +1,38 @@

+import {CircleMarker} from 'react-leaflet'; +import {useTheme} from '@mui/material'; +import EventPopup from './EventPopup'; +import {Event} from '../../generated/graphql'; + +interface Props { + event: Event & {id: string}; +} + +const EventMarker = ({event}: Props) => { + const {latitude, longitude} = event; + const theme = useTheme(); + + const markerStyle = { + radius: 12, + fillColor: theme.palette.primary.main, + color: theme.palette.background.paper, + fillOpacity: 1, + opacity: 1, + weight: 3, + }; + + return ( + <CircleMarker {...markerStyle} radius={9} center={[latitude, longitude]}> + <CircleMarker + {...{ + ...markerStyle, + fillColor: markerStyle.color, + color: markerStyle.fillColor, + }} + center={[latitude, longitude]} + ></CircleMarker> + <EventPopup event={event} /> + </CircleMarker> + ); +}; + +export default EventMarker;
D frontend/containers/EventPopup/index.tsx

@@ -1,68 +0,0 @@

-import moment from 'moment'; -import Card from '@mui/material/Card'; -import Typography from '@mui/material/Typography'; -import Link from '@mui/material/Link'; -import getMapsLink from '../../lib/getMapsLink'; -import {Event} from '../../generated/graphql'; -import Box from '@mui/material/Box'; -import {useTranslation} from 'react-i18next'; - -interface Props { - event: Event & {id: string}; -} - -const EventPopup = ({event}: Props) => { - const {t} = useTranslation(); - return ( - <Card sx={{p: 2, width: '350px', maxWidth: '100%'}}> - <Box> - <Typography - variant="h3" - color="primary" - sx={{cursor: 'pointer', display: 'inline-block'}} - > - <Link color="inherit" href={`/e/${event.uuid}/details`} sx={{textDecoration: 'none'}}> - {event.name} - </Link> - </Typography> - </Box> - {!!event.date && ( - <Box sx={{marginTop: 2}}> - <Typography variant="overline" color="GrayText"> - {t('event.fields.date')} - </Typography> - <Typography variant="body1"> - {moment(event.date).format('LLLL')} - </Typography> - </Box> - )} - {!!event.address && ( - <Box sx={{marginTop: 2}}> - <Typography variant="overline" color="GrayText"> - {t('event.fields.address')} - </Typography> - <Typography variant="body1" color="primary"> - <Link - component="a" - target="_blank" - rel="noopener noreferrer" - href={getMapsLink(event.address)} - sx={{color: 'primary.main'}} - > - {event.address} - </Link> - </Typography> - </Box> - )} - {!!event.description && ( - <Box sx={{marginTop: 2}}> - <Typography variant="overline" color="GrayText"> - {t('event.fields.description')} - </Typography> - <Typography variant="body1">{event.description}</Typography> - </Box> - )} - </Card> - ); -}; -export default EventPopup;
M frontend/containers/Map/Bounds.tsxfrontend/containers/Map/Bounds.tsx

@@ -5,15 +5,17 @@ import useMapStore from '../../stores/useMapStore';

const Bounds = () => { const map = useMap(); - const {markers} = useMapStore(); - const debouncedMarkers = useDebounce(markers, 750); + const {bounds: storedBounds} = useMapStore(); + const debouncedBounds = useDebounce(storedBounds, 750); const bounds = useMemo( - () => debouncedMarkers.map(marker => marker.center), - [debouncedMarkers] + () => debouncedBounds.map(bound => bound), + [debouncedBounds] ); useEffect(() => { - map.fitBounds(bounds, {padding: [30, 30]}); + if (bounds.length >= 1) { + map.fitBounds(bounds, {padding: [30, 30]}); + } }, [bounds]); return null;
M frontend/containers/Map/Map.tsxfrontend/containers/Map/Map.tsx

@@ -1,11 +1,4 @@

-import { - MapContainer, - CircleMarker, - TileLayer, - ZoomControl, - Popup, -} from 'react-leaflet'; -import {useTheme} from '@mui/material'; +import {MapContainer, TileLayer} from 'react-leaflet'; import MapController from './MapController'; import MapWrapper from './MapWrapper'; import useMapStore from '../../stores/useMapStore';

@@ -16,46 +9,18 @@ process.env.DEV_TILES_URL ||

'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; const Map = () => { - const theme = useTheme(); - const {center, markers} = useMapStore(); - const defaultMarkerStyle = { - radius: 12, - fillColor: theme.palette.primary.main, - color: theme.palette.background.paper, - fillOpacity: 1, - opacity: 1, - weight: 3, - }; + const {markers} = useMapStore(); return ( <MapWrapper> <MapContainer - center={center} - zoom={15} style={{height: '100%', width: '100%'}} zoomControl={false} > <Bounds /> <TileLayer key="tiles" url={DEV_TILES_URL} /> <MapController /> - {markers.map(({popup, ...circleMarkerProps}, index) => ( - <CircleMarker - key={index} - {...defaultMarkerStyle} - radius={circleMarkerProps.double ? 9 : defaultMarkerStyle.radius} - center={circleMarkerProps.center} - > - <CircleMarker - {...{ - ...defaultMarkerStyle, - fillColor: defaultMarkerStyle.color, - color: defaultMarkerStyle.fillColor, - }} - center={circleMarkerProps.center} - ></CircleMarker> - {popup && <Popup>{popup}</Popup>} - </CircleMarker> - ))} + {markers} </MapContainer> </MapWrapper> );
D frontend/containers/TravelColumns/TravelPopup.tsx

@@ -1,65 +0,0 @@

-import moment from 'moment'; -import Card from '@mui/material/Card'; -import Typography from '@mui/material/Typography'; -import Link from '@mui/material/Link'; -import useMapStore from '../../stores/useMapStore'; -import getMapsLink from '../../lib/getMapsLink'; -import {Travel} from '../../generated/graphql'; -import Box from '@mui/material/Box'; -import {useTranslation} from 'react-i18next'; - -interface Props { - travel: Travel & {id: string}; -} - -const TravelPopup = ({travel}: Props) => { - const {t} = useTranslation(); - const {setFocusOnTravel} = useMapStore(); - return ( - <Card - sx={{p: 2, width: '350px', maxWidth: '100%', cursor: 'pointer'}} - onClick={() => { - setFocusOnTravel(travel); - const travelCard = document?.getElementById(travel.id); - travelCard?.scrollIntoView({behavior: 'smooth'}); - }} - > - {!!travel.departure && ( - <Typography variant="overline" color="Graytext" id="TravelDeparture"> - {moment(travel.departure).format('LLLL')} - </Typography> - )} - <Box> - <Typography variant="h5">{travel.vehicleName}</Typography> - </Box> - {!!travel.phone_number && ( - <Box sx={{marginTop: 2}}> - <Typography variant="overline" color="GrayText"> - {t('travel.fields.phone')} - </Typography> - <Typography variant="body1">{travel.phone_number}</Typography> - </Box> - )} - {!!travel.meeting && ( - <Box sx={{marginTop: 2}}> - <Typography variant="overline" color="GrayText"> - {t('travel.fields.meeting_point')} - </Typography> - <Typography variant="body1" color="primary"> - <Link - component="a" - target="_blank" - rel="noopener noreferrer" - href={getMapsLink(travel.meeting)} - onClick={e => e.stopPropagation()} - sx={{color: 'primary.main'}} - > - {travel.meeting} - </Link> - </Typography> - </Box> - )} - </Card> - ); -}; -export default TravelPopup;
M frontend/containers/TravelColumns/index.tsxfrontend/containers/TravelColumns/index.tsx

@@ -1,4 +1,5 @@

import {useState} from 'react'; +import dynamic from 'next/dynamic'; import Container from '@mui/material/Container'; import Masonry from '@mui/lab/Masonry'; import Box from '@mui/material/Box';

@@ -13,11 +14,12 @@ import usePassengersActions from '../../hooks/usePassengersActions';

import Map from '../Map'; import Travel from '../Travel'; import NoCar from './NoCar'; -import TravelPopup from './TravelPopup'; -import EventPopup from '../EventPopup'; import {Travel as TravelData, TravelEntity} from '../../generated/graphql'; import {AddPassengerToTravel} from '../NewPassengerDialog'; +const EventMarker = dynamic(() => import('../EventMarker'), {ssr: false}); +const TravelMarker = dynamic(() => import('../TravelMarker'), {ssr: false}); + type TravelType = TravelData & {id: string}; interface Props {

@@ -26,8 +28,13 @@ }

const TravelColumns = (props: Props) => { const theme = useTheme(); - const {preventUpdateKey, setPreventUpdateKey, setCenter, setMarkers} = - useMapStore(); + const { + focusedTravel, + preventUpdateKey, + setPreventUpdateKey, + setMarkers, + setBounds, + } = useMapStore(); const event = useEventStore(s => s.event); const travels = event?.travels?.data || []; const {t} = useTranslation();

@@ -76,35 +83,41 @@ ({attributes: {meeting_latitude, meeting_longitude}}) =>

meeting_latitude && meeting_longitude ); let coordsString = `${latitude}${longitude}`; - const markers = travels.reduce((markers, travel) => { - const { - attributes: {meeting_latitude, meeting_longitude}, - } = travel; - if (meeting_latitude && meeting_longitude) { - const travelObject = {id: travel.id, ...travel.attributes}; - coordsString = - coordsString + String(meeting_latitude) + String(meeting_longitude); - return [ - ...markers, - { - center: [meeting_latitude, meeting_longitude], - popup: <TravelPopup travel={travelObject} />, - }, - ]; - } - return markers; - }, []); + + const {markers, bounds} = travels.reduce( + ({markers, bounds}, travel) => { + const { + attributes: {meeting_latitude, meeting_longitude}, + } = travel; + if (meeting_latitude && meeting_longitude) { + const travelObject = {id: travel.id, ...travel.attributes}; + coordsString = + coordsString + String(meeting_latitude) + String(meeting_longitude); + return { + markers: [ + ...markers, + <TravelMarker + travel={travelObject} + focused={focusedTravel === travel.id} + />, + ], + bounds: [...bounds, [meeting_latitude, meeting_longitude]], + }; + } + return {markers, bounds}; + }, + {markers: [], bounds: []} + ); - const mapUpdateKey = `${event.uuid}.travels+${coordsString}`; + const mapUpdateKey = `${event.uuid}.travels+${coordsString}.${latitude}.${longitude}.${focusedTravel}`; if (preventUpdateKey !== mapUpdateKey) { setPreventUpdateKey(mapUpdateKey); if (latitude && longitude) { - setCenter([latitude, longitude]); - markers.push({ - double: true, - center: [latitude, longitude], - popup: <EventPopup event={event} />, - }); + bounds.push([latitude, longitude]); + markers.push(<EventMarker event={event} />); + } + if (!focusedTravel) { + setBounds(bounds); } setMarkers(markers); }
A frontend/containers/TravelMarker/TravelPopup.tsx

@@ -0,0 +1,68 @@

+import moment from 'moment'; +import Card from '@mui/material/Card'; +import Typography from '@mui/material/Typography'; +import Link from '@mui/material/Link'; +import Box from '@mui/material/Box'; +import {Travel} from '../../generated/graphql'; +import {Popup} from 'react-leaflet'; +import {useTranslation} from 'react-i18next'; +import useMapStore from '../../stores/useMapStore'; +import getMapsLink from '../../lib/getMapsLink'; + +interface Props { + travel: Travel & {id: string}; +} + +const TravelPopup = ({travel}: Props) => { + const {t} = useTranslation(); + const {setFocusOnTravel} = useMapStore(); + return ( + <Popup> + <Card + sx={{p: 2, width: '350px', maxWidth: '100%', cursor: 'pointer'}} + onClick={() => { + setFocusOnTravel(travel); + const travelCard = document?.getElementById(travel.id); + travelCard?.scrollIntoView({behavior: 'smooth'}); + }} + > + {!!travel.departure && ( + <Typography variant="overline" color="Graytext" id="TravelDeparture"> + {moment(travel.departure).format('LLLL')} + </Typography> + )} + <Box> + <Typography variant="h5">{travel.vehicleName}</Typography> + </Box> + {!!travel.phone_number && ( + <Box sx={{marginTop: 2}}> + <Typography variant="overline" color="GrayText"> + {t('travel.fields.phone')} + </Typography> + <Typography variant="body1">{travel.phone_number}</Typography> + </Box> + )} + {!!travel.meeting && ( + <Box sx={{marginTop: 2}}> + <Typography variant="overline" color="GrayText"> + {t('travel.fields.meeting_point')} + </Typography> + <Typography variant="body1" color="primary"> + <Link + component="a" + target="_blank" + rel="noopener noreferrer" + href={getMapsLink(travel.meeting)} + onClick={e => e.stopPropagation()} + sx={{color: 'primary.main'}} + > + {travel.meeting} + </Link> + </Typography> + </Box> + )} + </Card> + </Popup> + ); +}; +export default TravelPopup;
A frontend/containers/TravelMarker/index.tsx

@@ -0,0 +1,42 @@

+import {CircleMarker} from 'react-leaflet'; +import {useTheme} from '@mui/material'; +import TravelPopup from './TravelPopup'; +import {Travel} from '../../generated/graphql'; +import {useRef, useState} from 'react'; + +interface Props { + travel: Travel & {id: string}; + focused: boolean; +} + +const TravelMarker = ({travel, focused}: Props) => { + const {meeting_longitude, meeting_latitude} = travel; + const markerRef = useRef(null); + const theme = useTheme(); + const marker = markerRef.current; + + if (marker && focused) { + marker.openPopup(); + } + + const markerStyle = { + radius: 12, + fillColor: theme.palette.primary.main, + color: theme.palette.background.paper, + fillOpacity: 1, + opacity: 1, + weight: 3, + }; + + return ( + <CircleMarker + ref={markerRef} + {...markerStyle} + center={[meeting_latitude, meeting_longitude]} + > + <TravelPopup travel={travel} /> + </CircleMarker> + ); +}; + +export default TravelMarker;
M frontend/stores/useMapStore.tsfrontend/stores/useMapStore.ts

@@ -1,31 +1,29 @@

import {ReactNode} from 'react'; -import {type LatLngExpression} from 'leaflet'; -import {CircleMarkerProps} from 'react-leaflet'; +import {type LatLngExpression, LatLngBoundsExpression} from 'leaflet'; import {create} from 'zustand'; import {Travel} from '../generated/graphql'; type State = { map?: any; preventUpdateKey: string; - center: LatLngExpression; - markers: Array<CircleMarkerProps & {popup: ReactNode, double: boolean}>; + markers: Array<ReactNode>; focusedTravel?: string; + bounds: Array<LatLngExpression>; setMap: (map: any) => void; setPreventUpdateKey: (preventUpdateKey: string) => void; - setCenter: (center: LatLngExpression) => void; - setMarkers: (markers: Array<CircleMarkerProps & {popup: ReactNode}>) => void; + setMarkers: (markers: Array<ReactNode>) => void; setFocusOnTravel: (travel?: Travel & {id: string}) => void; + setBounds: (bounds: Array<LatLngExpression>) => void; }; const useMapStore = create<State>((set, get) => ({ map: undefined, preventUpdateKey: '', - center: [0, 0], markers: [], focusedTravel: undefined, + bounds: [], setMap: map => set({map}), setPreventUpdateKey: preventUpdateKey => set({preventUpdateKey}), - setCenter: center => set({center}), setMarkers: markers => set({markers}), setFocusOnTravel: travel => { if (!travel) {

@@ -36,6 +34,9 @@ const lat = travel.meeting_latitude;

const long = travel.meeting_longitude; if (lat && long) get().map?.panTo([lat, long]); } + }, + setBounds: bounds => { + set({bounds}); }, }));