diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbcac05..399e8e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "realt-properties-map-frontend", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "realt-properties-map-frontend", - "version": "1.0.1", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@apollo/client": "^3.9.9", @@ -38,6 +38,7 @@ "react-leaflet": "^4.2.1", "react-leaflet-cluster": "^2.1.0", "react-redux": "^9.1.0", + "react-router-dom": "^6.22.3", "react-scripts": "^5.0.1", "typescript": "^4.9.5", "web-vitals": "^2.1.4" @@ -4901,6 +4902,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", + "integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@repeaterjs/repeater": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.5.tgz", @@ -17171,6 +17180,36 @@ } } }, + "node_modules/react-router": { + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", + "integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==", + "dependencies": { + "@remix-run/router": "1.15.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz", + "integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==", + "dependencies": { + "@remix-run/router": "1.15.3", + "react-router": "6.22.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a4ba736..2d2ae56 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,7 @@ "react-leaflet": "^4.2.1", "react-leaflet-cluster": "^2.1.0", "react-redux": "^9.1.0", + "react-router-dom": "^6.22.3", "react-scripts": "^5.0.1", "typescript": "^4.9.5", "web-vitals": "^2.1.4" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2ef866b..76bea25 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,17 +6,20 @@ import { MantineProviders } from './providers/MantineProvider'; import { HotkeysProvider } from './providers/HotkeysProvider'; import InitStoreProvider from './providers/InitStoreProvider'; import './i18next/i18next'; +import { QueryProvider } from './providers/QueryProvider'; function App() { return ( - - - - - - + + + + + + + + ); diff --git a/frontend/src/components/Map/MapMarkers.ts b/frontend/src/components/Map/MapMarkers.ts index f5c0b6f..e0c937a 100644 --- a/frontend/src/components/Map/MapMarkers.ts +++ b/frontend/src/components/Map/MapMarkers.ts @@ -1,13 +1,15 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { TFunction } from 'i18next'; -import { DivIcon, MarkerClusterGroup, marker, Marker, markerClusterGroup, LeafletMouseEvent } from 'leaflet'; +import { DivIcon, MarkerClusterGroup, marker, Marker, markerClusterGroup, LeafletMouseEvent, LeafletEvent } from 'leaflet'; import { useLeafletContext } from '@react-leaflet/core'; import 'leaflet.markercluster'; -import { Wallet } from '../../types/wallet'; import { Property } from '../../types/property'; import { useAppDispatch, useAppSelector } from '../../hooks/useInitStore'; import { setSelected } from '../../store/marker/markerReducer'; +import { setLatLng, setSelectedProperty, setZoom } from '../../store/urlQuery/urlQuery.reducer'; +import { selectedProperty } from '../../store/urlQuery/urlQuery.selector'; +import { Maybe } from '../../types/global'; export const OWNED_SELECTOR = '[data-marker-owned]'; export const CSSCLASSES = { @@ -15,17 +17,16 @@ export const CSSCLASSES = { notOwned: 'opacity-80', } - function pinSvg(cssClasses: string) { return ``; } -// TODO - Map options for opacity export function generateIcon( property: Property, differentiateOwned: boolean, markerOpacity: number, + selected: boolean, ) { const owned = property.ownedAmount > 0; const ownedClass = differentiateOwned && owned ? CSSCLASSES.owned : CSSCLASSES.notOwned; @@ -37,7 +38,7 @@ export function generateIcon( ${owned ? 'data-marker-owned' : ''} ${property.source ? `data-marker-${property.source}` : ''} ${property.ownerWallets.length ? `data-marker-wallet="${property.ownerWallets.join(' ')}"` : ''}> - ${pinSvg(`${property.iconColorClass}-icon ${ownedClass}`)} + ${pinSvg(`${property.iconColorClass}-icon ${ownedClass + (selected ? ' selected' : '')}`)} ${property.icon} `, iconSize: [50, 50], @@ -50,9 +51,13 @@ function filterProperties( displayAll: boolean, displayGnosis: boolean, displayRmm: boolean, + selected: string | null, ) { return properties .filter((property) => { + if (selected && property.address === selected) { + return true; + } let toInclude = !property.isOld && property.productType !== 'equity_token'; if (!displayAll && property.ownedAmount <= 0) { toInclude = false; @@ -71,10 +76,11 @@ function createMarker( property: Property, markerOpacity: number, differentiateOwned: boolean, + selected: Maybe, t: TFunction<"common", undefined>, ): Marker { return marker([property.coordinate.lat, property.coordinate.lng], { - icon: generateIcon(property, differentiateOwned, markerOpacity), + icon: generateIcon(property, differentiateOwned, markerOpacity, selected === property.address), alt: property.propertyTypeName, title: t('propertyType.' + property.propertyTypeName), }); @@ -107,10 +113,8 @@ let markerCluster: MarkerClusterGroup; let markers: Array = []; export function MapMarkers({ properties, - wallets, }: { properties: Property[]; - wallets: Wallet[]; }) { const { t } = useTranslation('common'); const dispatch = useAppDispatch(); @@ -122,6 +126,7 @@ export function MapMarkers({ differentiateOwned, markerOpacity, } = useAppSelector((state) => state.mapOptions); + const selectedUrlParam = useAppSelector(selectedProperty); function onMarkerClicked(event: LeafletMouseEvent, property: Property) { const currentZoom = map.getZoom(); @@ -142,6 +147,7 @@ export function MapMarkers({ lng: event.latlng.lng, }, })); + dispatch(setSelectedProperty(property.address)); } function clearMap() { @@ -156,6 +162,8 @@ export function MapMarkers({ markerCluster.clearAllEventListeners(); markerCluster.clearLayers(); map.removeLayer(markerCluster); + map.removeEventListener('zoom'); + map.removeEventListener('moveend'); } function getCleanMarkerCluster() { @@ -173,15 +181,37 @@ export function MapMarkers({ clearMap(); markerCluster = getCleanMarkerCluster(); - filterProperties(properties, displayAll, displayGnosis, displayRmm) + filterProperties(properties, displayAll, displayGnosis, displayRmm, selectedUrlParam) .forEach((property) => { - const marker = createMarker(property, markerOpacity, differentiateOwned, t) + const marker = createMarker(property, markerOpacity, differentiateOwned, selectedUrlParam, t) .addEventListener('click', (event) => onMarkerClicked(event, property)); markers.push(marker); markerCluster.addLayer(marker); }); map.addLayer(markerCluster); + map.addEventListener('zoom', (event: LeafletEvent) => { + dispatch(setZoom(event.target.getZoom())); + }); + map.addEventListener('moveend', (event: LeafletEvent) => { + dispatch(setLatLng([event.target.getCenter().lat, event.target.getCenter().lng])); + }); + if (selectedUrlParam) { + const selectedProperty = properties.find((property) => property.address === selectedUrlParam); + if (selectedProperty) { + dispatch(setSelected({ + property: selectedProperty, + latlng: { + lat: selectedProperty.coordinate.lat, + lng: selectedProperty.coordinate.lng, + }, + })); + } + } + + return () => { + clearMap(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [properties, displayAll, displayGnosis, displayRmm, differentiateOwned, markerOpacity]); diff --git a/frontend/src/components/Map/MapWrapper.tsx b/frontend/src/components/Map/MapWrapper.tsx index 962b797..0390f59 100644 --- a/frontend/src/components/Map/MapWrapper.tsx +++ b/frontend/src/components/Map/MapWrapper.tsx @@ -10,10 +10,13 @@ import { Property } from '../../types/property'; import { PropertyPanel } from './PropertyPanel'; import { MapMarkers } from './MapMarkers'; import { MapEvents } from './MapEvents'; +import { selectedLatLng, selectedZoom } from '../../store/urlQuery/urlQuery.selector'; export function MapWrapper() { const realToken = useAppSelector(selectRealtokensList); const wallets = useAppSelector(selectWalletsList); + const center = useAppSelector(selectedLatLng); + const zoom = useAppSelector(selectedZoom); const [properties, setProperties] = useState([]); @@ -25,12 +28,11 @@ export function MapWrapper() { <> + center={center} + zoom={zoom}> - + diff --git a/frontend/src/components/Map/PropertyPanel.tsx b/frontend/src/components/Map/PropertyPanel.tsx index f6c8e06..14ad7db 100644 --- a/frontend/src/components/Map/PropertyPanel.tsx +++ b/frontend/src/components/Map/PropertyPanel.tsx @@ -12,6 +12,7 @@ import date from "../../utils/date"; import { selectedLanguage } from "../../store/settings/settingsSelector"; import { selectDifferentiateOwned } from "../../store/mapOptions/mapOptionsSelector"; import { useElementSize } from "@mantine/hooks"; +import { setSelectedProperty } from "../../store/urlQuery/urlQuery.reducer"; function toFixedStr(value: number, precision: number = 2) { return value.toFixed(precision).toLowerCase(); @@ -31,6 +32,7 @@ export function PropertyPanel() { }, [property]); function onClose() { + dispatch(setSelectedProperty(null)); dispatch(clearSelected()); } diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 717d92c..67131d3 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -4,13 +4,16 @@ import './index.css'; import '@mantine/core/styles.css'; import '@mantine/carousel/styles.css'; import App from './App'; +import { BrowserRouter } from 'react-router-dom'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( - + + + ); diff --git a/frontend/src/providers/QueryProvider.tsx b/frontend/src/providers/QueryProvider.tsx new file mode 100644 index 0000000..db8ce06 --- /dev/null +++ b/frontend/src/providers/QueryProvider.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useAppSelector } from "../hooks/useInitStore"; +import { selectedLatLng, selectedProperty, selectedZoom } from "../store/urlQuery/urlQuery.selector"; +import { Maybe } from "../types/global"; + +function handleSearchParam( + searchParams: URLSearchParams, + key: string, + value: Maybe, +) { + if (value) { + searchParams.set(key, value); + } else { + searchParams.delete(key); + } + return searchParams; +} + +export function QueryProvider({ + children, +}: { + children: React.ReactNode +}) { + let [_, setSearchParams] = useSearchParams(); + const center = useAppSelector(selectedLatLng); + const zoom = useAppSelector(selectedZoom); + const selected = useAppSelector(selectedProperty); + + useEffect(() => { + const newSearchParams = new URLSearchParams(); + handleSearchParam(newSearchParams, "latlng", JSON.stringify(center)); + handleSearchParam(newSearchParams, "zoom", JSON.stringify(zoom)); + handleSearchParam(newSearchParams, "selected", selected); + setSearchParams(newSearchParams); + }, [center, zoom, selected, setSearchParams]); + + return ( + <>{children} + ) +} \ No newline at end of file diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index 2d03800..431c670 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -5,6 +5,7 @@ import realTokensReducer from './realtokens/realtokensReducer'; import settingsReducer from './settings/settingsReducer'; import markerReducer from './marker/markerReducer'; import currenciesReducer from './currencies/currenciesReducer'; +import urlQueryReducer from './urlQuery/urlQuery.reducer'; const rootReducer = combineReducers({ settings: settingsReducer, @@ -13,6 +14,7 @@ const rootReducer = combineReducers({ realtokens: realTokensReducer, marker: markerReducer, currencies: currenciesReducer, + urlQuery: urlQueryReducer, }); const store = configureStore({ diff --git a/frontend/src/store/urlQuery/urlQuery.reducer.ts b/frontend/src/store/urlQuery/urlQuery.reducer.ts new file mode 100644 index 0000000..548955b --- /dev/null +++ b/frontend/src/store/urlQuery/urlQuery.reducer.ts @@ -0,0 +1,39 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { Maybe } from "../../types/global"; + +interface UrlQueryState { + selected: Maybe; + latlng?: [number, number]; + zoom?: number; +} + +const urlParams = new URLSearchParams(window.location.search); +const selected = urlParams.get('selected'); +const latlng = urlParams.get('latlng'); +const zoom = urlParams.get('zoom'); + +const initialState: UrlQueryState = { + selected, + latlng: latlng ? JSON.parse(latlng) : [32, -83], + zoom: zoom ? parseInt(zoom) : 4, +}; + +export const urlQuerySlice = createSlice({ + name: 'urlQuery', + initialState, + reducers: { + setSelectedProperty: (state, action) => { + state.selected = action.payload; + }, + setLatLng: (state, action) => { + state.latlng = action.payload; + }, + setZoom: (state, action) => { + state.zoom = action.payload; + }, + }, +}); + +export const { setSelectedProperty, setLatLng, setZoom } = urlQuerySlice.actions; + +export default urlQuerySlice.reducer; \ No newline at end of file diff --git a/frontend/src/store/urlQuery/urlQuery.selector.ts b/frontend/src/store/urlQuery/urlQuery.selector.ts new file mode 100644 index 0000000..5bff9c0 --- /dev/null +++ b/frontend/src/store/urlQuery/urlQuery.selector.ts @@ -0,0 +1,7 @@ +import { RootState } from "../store"; + +export const selectedProperty = (state: RootState) => state.urlQuery.selected; + +export const selectedLatLng = (state: RootState) => state.urlQuery.latlng; + +export const selectedZoom = (state: RootState) => state.urlQuery.zoom; \ No newline at end of file