Skip to main content

Map Components

The Map Components provide comprehensive geolocation and mapping functionality for the WorkPayCore Frontend application. These components handle location selection, map visualization, address search, and location-based features with Google Maps integration.

Overview​

This document covers map-related components that enable location picking, address searching, map visualization, and geolocation features throughout the application using Google Maps API.

Components Overview​

Core Map Components​

  • Map - Main map component with location markers and interactions
  • SearchLocation - Location search component with address autocomplete
  • MapLocater - Simplified map component with location display

Map Integration Patterns​

  • Project Location Setup - Project creation with location selection
  • Geolocation Attendance - Attendance tracking with location validation
  • Location-based Services - Various location-based feature implementations

Map​

Main interactive map component that displays Google Maps with location markers, click interactions, and location data management.

Component Location​

import Map from 'components/Map';

Props​

PropTypeRequiredDefaultDescription
defaultZoomnumber-15Default map zoom level
heightstring-'50vh'Map container height
latnumber--1.2921Initial latitude
lngnumber-36.8219Initial longitude
zoomnumber-15Current zoom level
getMapDatafunction✓-Callback for location data

Features​

  • Interactive Map: Google Maps integration with full interaction support
  • Location Markers: Pin markers for selected locations
  • Address Geocoding: Converts coordinates to readable addresses
  • Click Selection: Click on map to select locations
  • Search Integration: Built-in Places Autocomplete
  • Location Display: Shows current location details

Usage Examples​

Basic Map Implementation​

import Map from 'components/Map';

function LocationPicker() {
const [selectedLocation, setSelectedLocation] = useState(null);

const handleLocationSelect = locationData => {
setSelectedLocation(locationData);
console.log('Selected location:', locationData);
// locationData contains: { lat, lng, address }
};

return (
<Map
height='400px'
lat={-1.2921}
lng={36.8219}
zoom={15}
getMapData={handleLocationSelect}
/>
);
}

Project Location Setup​

function ProjectLocationSetup() {
const [projectLocation, setProjectLocation] = useState(null);
const [mapCenter, setMapCenter] = useState({
lat: -1.2921,
lng: 36.8219,
});

const handleLocationSelect = locationData => {
setProjectLocation(locationData);
setMapCenter({ lat: locationData.lat, lng: locationData.lng });

// Update form with location data
setValue('latitude', locationData.lat);
setValue('longitude', locationData.lng);
setValue('address', locationData.address);
};

return (
<Stack spacing={4}>
<Text fontSize='sm' color='gray.600'>
Click on the map to select your project location
</Text>

<Map
height='300px'
lat={mapCenter.lat}
lng={mapCenter.lng}
zoom={15}
getMapData={handleLocationSelect}
/>

{projectLocation && (
<Box
borderWidth='1px'
borderColor='blue.300'
borderRadius='md'
bg='blue.50'
p={4}
>
<Text fontSize='sm' fontWeight='bold'>
Selected Location:
</Text>
<Text fontSize='sm'>{projectLocation.address}</Text>
<Text fontSize='xs' color='gray.600'>
Lat: {projectLocation.lat}, Lng: {projectLocation.lng}
</Text>
</Box>
)}
</Stack>
);
}

Checkpoint Creation with Radius​

function CheckpointCreation() {
const [checkpointData, setCheckpointData] = useState(null);
const [radius, setRadius] = useState(10);

const handleMapLocationSelect = locationData => {
setCheckpointData({
...locationData,
radius: radius,
});
};

return (
<VStack spacing={4}>
&lt;FormControl&gt;
&lt;FormLabel&gt;Checkpoint Radius</FormLabel>
&lt;HStack&gt;
<Slider
value={radius}
onChange={setRadius}
min={1}
max={100}
step={1}
flex={1}
>
&lt;SliderTrack&gt;
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
<Text minW='60px'>{radius}m</Text>
</HStack>
</FormControl>

<Map
height='300px'
lat={-1.2921}
lng={36.8219}
getMapData={handleMapLocationSelect}
/>

{checkpointData && (
<Alert status='info'>
<AlertIcon />
&lt;AlertDescription&gt;
Checkpoint will be created at {checkpointData.address} with a{' '}
{radius}m radius
</AlertDescription>
</Alert>
)}
</VStack>
);
}

SearchLocation​

Location search component with Google Places Autocomplete integration for address selection.

Component Location​

import { SearchLocation } from 'components/Map/MapLocater';

Props​

PropTypeRequiredDefaultDescription
onLocationSelectfunction✓-Location selection callback
heightstring--Input field height
labelstring--Input label
helperTextstring--Helper text below input
isDisabledboolean-falseDisable input interaction
valuestring-''Initial search value
isInvalidboolean-falseShow error state
errorMessagestring-''Error message text
isRequiredboolean-falseShow required indicator

Features​

  • Address Autocomplete: Google Places API integration
  • Geocoding: Converts addresses to coordinates
  • Form Integration: Works with form libraries
  • Error Handling: Validation and error display
  • Loading States: Shows loading during search

Usage Examples​

import { SearchLocation } from 'components/Map/MapLocater';

function LocationSearchForm() {
const [selectedLocation, setSelectedLocation] = useState(null);

const handleLocationSelect = locationData => {
setSelectedLocation(locationData);
console.log('Location selected:', locationData);
// locationData: { lat, lng, address }
};

return (
<SearchLocation
onLocationSelect={handleLocationSelect}
label='Search Location'
helperText='Start typing to search for addresses'
isRequired
/>
);
}

Form Integration​

function LocationForm() {
const [formData, setFormData] = useState({
name: '',
location: null,
});

const [locationError, setLocationError] = useState('');

const handleLocationSelect = locationData => {
setFormData(prev => ({
...prev,
location: locationData,
}));
setLocationError('');
};

const handleSubmit = e => {
e.preventDefault();

if (!formData.location) {
setLocationError('Please select a location');
return;
}

// Process form submission
console.log('Form data:', formData);
};

return (
<form onSubmit={handleSubmit}>
<VStack spacing={4}>
&lt;FormControl&gt;
&lt;FormLabel&gt;Name</FormLabel>
<Input
value={formData.name}
onChange={e =>
setFormData(prev => ({
...prev,
name: e.target.value,
}))
}
/>
</FormControl>

<SearchLocation
onLocationSelect={handleLocationSelect}
label='Location'
helperText='Search for the location address'
isRequired
isInvalid={!!locationError}
errorMessage={locationError}
/>

<Button type='submit' colorScheme='blue'>
Submit
</Button>
</VStack>
</form>
);
}
function ControlledLocationSearch() {
const [searchValue, setSearchValue] = useState('');
const [selectedLocation, setSelectedLocation] = useState(null);

const handleLocationSelect = locationData => {
setSelectedLocation(locationData);
setSearchValue(locationData.address);
};

return (
<VStack spacing={4}>
<SearchLocation
onLocationSelect={handleLocationSelect}
value={searchValue}
label='Project Location'
helperText="Enter the project's physical location"
isRequired
/>

{selectedLocation && (
<Box p={4} bg='green.50' borderRadius='md' w='full'>
<Text fontWeight='bold'>Selected Location:</Text>
<Text fontSize='sm'>{selectedLocation.address}</Text>
<Text fontSize='xs' color='gray.600'>
Coordinates: {selectedLocation.lat.toFixed(6)},{' '}
{selectedLocation.lng.toFixed(6)}
</Text>
</Box>
)}
</VStack>
);
}

MapLocater​

Simplified map component with location display functionality from the MapLocater module.

Component Location​

import { Map } from 'components/Map/MapLocater';

Props​

PropTypeRequiredDefaultDescription
centerobject✓-Map center coordinates
zoomnumber-15Map zoom level
heightstring-'50vh'Map container height
onMapChangefunction--Map change callback
mapTitlestring--Map title display

TypeScript Interface​

interface MapCenter {
lat: number;
lng: number;
}

interface MapLocaterProps {
center: MapCenter;
zoom?: number;
height?: string;
onMapChange?: (data: { center: MapCenter; zoom: number }) => void;
mapTitle?: string;
}

Usage Examples​

Display-Only Map​

import { Map } from 'components/Map/MapLocater';

function LocationDisplay({ location }) {
return (
<Map
center={{ lat: location.latitude, lng: location.longitude }}
zoom={15}
height='300px'
mapTitle='Project Location'
/>
);
}

Interactive Map with Updates​

function InteractiveLocationMap() {
const [mapCenter, setMapCenter] = useState({
lat: -1.2921,
lng: 36.8219,
});
const [mapZoom, setMapZoom] = useState(15);

const handleMapChange = ({ center, zoom }) => {
setMapCenter(center);
setMapZoom(zoom);

// Optionally update parent component
onLocationChange?.(center);
};

return (
<VStack spacing={4}>
<Map
center={mapCenter}
zoom={mapZoom}
height='400px'
onMapChange={handleMapChange}
mapTitle='Select Location'
/>

<Text fontSize='sm' color='gray.600'>
Current: {mapCenter.lat.toFixed(6)}, {mapCenter.lng.toFixed(6)}
</Text>
</VStack>
);
}

Map Integration Patterns​

Project Location Setup Pattern​

function ProjectLocationSetup() {
const [location, setLocation] = useState({
lat: -1.2921,
lng: 36.8219,
});
const [address, setAddress] = useState('');
const [zoom, setZoom] = useState(15);

const handleLocationSelect = locationData => {
setLocation({ lat: locationData.lat, lng: locationData.lng });
setAddress(locationData.address);

// Update form values
setValue('latitude', locationData.lat);
setValue('longitude', locationData.lng);
setValue('location_name', locationData.address);
};

const handleMapChange = ({ center, zoom }) => {
setLocation(center);
setZoom(zoom);
};

return (
<HStack spacing={8} align='flex-start'>
<VStack flex={1} spacing={4}>
<SearchLocation
onLocationSelect={handleLocationSelect}
label='Search Location'
helperText='Search for the project location'
isRequired
/>

&lt;FormControl&gt;
&lt;FormLabel&gt;Project Name</FormLabel>
<Input {...register('project_name')} />
</FormControl>

&lt;FormControl&gt;
&lt;FormLabel&gt;Description</FormLabel>
<Textarea {...register('description')} />
</FormControl>
</VStack>

<Stack flex={1}>
<Map
height='300px'
center={location}
zoom={zoom}
onMapChange={handleMapChange}
/>

{address && (
<Box
borderWidth='1px'
borderColor='blue.300'
borderRadius='md'
bg='blue.50'
p={4}
>
<Text fontSize='sm' fontWeight='bold'>
Location Details:
</Text>
<Text fontSize='sm'>{address}</Text>
<Text fontSize='xs' color='gray.600'>
Lat: {location.lat}, Lng: {location.lng}
</Text>
</Box>
)}
</Stack>
</HStack>
);
}

Geolocation Attendance Pattern​

function GeolocationAttendance() {
const [userLocation, setUserLocation] = useState(null);
const [workLocation, setWorkLocation] = useState({
lat: -1.2921,
lng: 36.8219,
radius: 100, // meters
});
const [isWithinRadius, setIsWithinRadius] = useState(false);

useEffect(() => {
// Get user's current location
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
position => {
const userPos = {
lat: position.coords.latitude,
lng: position.coords.longitude,
};
setUserLocation(userPos);

// Check if user is within work location radius
const distance = calculateDistance(userPos, workLocation);
setIsWithinRadius(distance <= workLocation.radius);
},
error => {
console.error('Error getting location:', error);
},
);
}
}, [workLocation]);

const calculateDistance = (pos1, pos2) => {
// Haversine formula for calculating distance between two points
const R = 6371e3; // Earth's radius in meters
const φ1 = (pos1.lat * Math.PI) / 180;
const φ2 = (pos2.lat * Math.PI) / 180;
const Δφ = ((pos2.lat - pos1.lat) * Math.PI) / 180;
const Δλ = ((pos2.lng - pos1.lng) * Math.PI) / 180;

const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

return R * c;
};

const handleClockIn = () => {
if (!isWithinRadius) {
alert('You must be within the work location radius to clock in');
return;
}

// Process clock in with location data
const clockInData = {
timestamp: new Date().toISOString(),
location: userLocation,
withinRadius: isWithinRadius,
};

submitClockIn(clockInData);
};

return (
<VStack spacing={4}>
<Alert status={isWithinRadius ? 'success' : 'warning'}>
<AlertIcon />
&lt;AlertDescription&gt;
{isWithinRadius
? 'You are within the work location radius'
: 'You are outside the work location radius'}
</AlertDescription>
</Alert>

{userLocation && (
<Map
center={userLocation}
zoom={16}
height='300px'
mapTitle='Your Current Location'
/>
)}

<Button
onClick={handleClockIn}
colorScheme={isWithinRadius ? 'green' : 'gray'}
isDisabled={!isWithinRadius}
>
Clock In
</Button>
</VStack>
);
}

Location History Display Pattern​

function LocationHistoryDisplay({ clockInAttempts }) {
const [selectedAttempt, setSelectedAttempt] = useState(null);

const handleAttemptSelect = attempt => {
setSelectedAttempt(attempt);
};

return (
<VStack spacing={4}>
<Text fontSize='lg' fontWeight='bold'>
Clock-In Attempts
</Text>

<SimpleGrid columns={2} spacing={4} w='full'>
{clockInAttempts.map(attempt => (
<Box
key={attempt.id}
p={4}
borderWidth='1px'
borderRadius='md'
cursor='pointer'
onClick={() => handleAttemptSelect(attempt)}
bg={selectedAttempt?.id === attempt.id ? 'blue.50' : 'white'}
>
<Text fontWeight='bold'>{attempt.date}</Text>
<Text fontSize='sm'>{attempt.time}</Text>
<Text fontSize='sm' color='gray.600'>
{attempt.location}
</Text>
</Box>
))}
</SimpleGrid>

{selectedAttempt && (
<Box w='full'>
<Text fontSize='md' fontWeight='bold' mb={2}>
Location Details
</Text>
<Map
center={{
lat: parseFloat(selectedAttempt.latitude),
lng: parseFloat(selectedAttempt.longitude),
}}
zoom={15}
height='300px'
mapTitle={`Clock-in attempt at ${selectedAttempt.time}`}
/>
</Box>
)}
</VStack>
);
}

Best Practices​

Location Data Management​

  1. Coordinate Precision

    • Store coordinates with sufficient precision (6 decimal places)
    • Validate coordinate ranges (lat: -90 to 90, lng: -180 to 180)
    • Handle edge cases and invalid coordinates
  2. Address Handling

    • Store both coordinates and formatted addresses
    • Implement fallback for geocoding failures
    • Handle partial address matches
  3. Performance Optimization

    • Debounce map interactions to reduce API calls
    • Cache geocoding results where appropriate
    • Implement proper loading states

User Experience​

  1. Error Handling

    • Provide clear error messages for location failures
    • Handle geolocation permission denials gracefully
    • Show loading states during API calls
  2. Accessibility

    • Provide keyboard navigation alternatives
    • Include proper ARIA labels for screen readers
    • Ensure sufficient color contrast for map elements
  3. Mobile Optimization

    • Optimize map interactions for touch devices
    • Handle different screen sizes appropriately
    • Consider GPS accuracy variations

Security Considerations​

  1. API Key Management

    • Use environment variables for API keys
    • Implement proper API key restrictions
    • Monitor API usage and costs
  2. Location Privacy

    • Request location permissions appropriately
    • Provide clear privacy information
    • Allow users to opt out of location tracking

Configuration​

Google Maps API Setup​

// Environment variables
const MAPS_API_KEY = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;

// API configuration
const GOOGLE_MAPS_CONFIG = {
key: MAPS_API_KEY,
libraries: ['places', 'geometry'],
version: 'weekly',
};

Map Default Settings​

const MAP_DEFAULTS = {
center: {
lat: -1.2921, // Nairobi, Kenya
lng: 36.8219,
},
zoom: 15,
height: '50vh',
options: {
disableDefaultUI: false,
zoomControl: true,
mapTypeControl: false,
scaleControl: true,
streetViewControl: false,
rotateControl: false,
fullscreenControl: true,
},
};

Testing​

Unit Tests​

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Map } from 'components/Map/MapLocater';

// Mock Google Maps API
const mockGoogleMapsApi = {
maps: {
Map: jest.fn(),
Marker: jest.fn(),
places: {
Autocomplete: jest.fn(),
},
},
};

global.google = mockGoogleMapsApi;

describe('Map Component', () => {
const mockOnMapChange = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
});

it('renders map with correct center and zoom', () => {
const center = { lat: -1.2921, lng: 36.8219 };

render(<Map center={center} zoom={15} onMapChange={mockOnMapChange} />);

expect(screen.getByText('Select Location')).toBeInTheDocument();
});

it('calls onMapChange when map is interacted with', async () => {
const center = { lat: -1.2921, lng: 36.8219 };

render(<Map center={center} zoom={15} onMapChange={mockOnMapChange} />);

// Simulate map interaction
const mapContainer = screen.getByTestId('map-container');
fireEvent.click(mapContainer);

await waitFor(() => {
expect(mockOnMapChange).toHaveBeenCalled();
});
});
});

Integration Tests​

describe('SearchLocation Integration', () => {
it('selects location and updates map', async () => {
const mockOnLocationSelect = jest.fn();
const { user } = render(
<SearchLocation onLocationSelect={mockOnLocationSelect} />,
);

// Type in search input
const searchInput = screen.getByPlaceholderText('Enter a location');
await user.type(searchInput, 'Nairobi');

// Wait for suggestions and select one
await waitFor(() => {
expect(screen.getByText('Nairobi, Kenya')).toBeInTheDocument();
});

await user.click(screen.getByText('Nairobi, Kenya'));

// Verify location selection callback
expect(mockOnLocationSelect).toHaveBeenCalledWith({
lat: expect.any(Number),
lng: expect.any(Number),
address: 'Nairobi, Kenya',
});
});
});

Troubleshooting​

Common Issues​

  1. Google Maps API Not Loading

    // Check if Google Maps API is loaded
    if (!window.google) {
    return &lt;Text&gt;Loading Google Maps API...</Text>;
    }
  2. Location Permission Denied

    const handleLocationError = error => {
    switch (error.code) {
    case error.PERMISSION_DENIED:
    setError('Location access denied by user');
    break;
    case error.POSITION_UNAVAILABLE:
    setError('Location information unavailable');
    break;
    case error.TIMEOUT:
    setError('Location request timed out');
    break;
    default:
    setError('Unknown location error');
    }
    };
  3. Geocoding API Quota Exceeded

    const handleGeocodingError = error => {
    if (error.code === 'OVER_QUERY_LIMIT') {
    setError('Too many requests. Please try again later.');
    } else {
    setError('Unable to find address for this location');
    }
    };

Performance Optimization​

// Debounce map interactions
const debouncedMapChange = useCallback(
debounce(data => {
onMapChange?.(data);
}, 500),
[onMapChange],
);

// Memoize map component
const MemoizedMap = memo(Map, (prevProps, nextProps) => {
return (
prevProps.center.lat === nextProps.center.lat &&
prevProps.center.lng === nextProps.center.lng &&
prevProps.zoom === nextProps.zoom
);
});

Migration Guide​

From Legacy Map Components​

  1. Update Import Statements

    // Old
    import MapComponent from 'components/OldMap';

    // New
    import Map from 'components/Map';
    import { SearchLocation } from 'components/Map/MapLocater';
  2. Update Props Structure

    // Old
    <MapComponent
    latitude={lat}
    longitude={lng}
    onLocationChange={handleChange}
    />

    // New
    <Map
    lat={lat}
    lng={lng}
    getMapData={handleChange}
    />
  3. Update Event Handlers

    // Old
    const handleLocationChange = (lat, lng) => {
    // Handle location change
    };

    // New
    const handleLocationChange = locationData => {
    const { lat, lng, address } = locationData;
    // Handle location change with address
    };

Advanced Usage​

Custom Map Styles​

const customMapStyles = [
{
elementType: 'geometry',
stylers: [{ color: '#f5f5f5' }],
},
{
elementType: 'labels.icon',
stylers: [{ visibility: 'off' }],
},
// More custom styles...
];

function StyledMap() {
return (
<GoogleMapReact
bootstrapURLKeys={{ key: MAPS_API_KEY }}
options={{
styles: customMapStyles,
disableDefaultUI: true,
zoomControl: true,
}}
center={center}
zoom={15}
>
<CustomMarker lat={center.lat} lng={center.lng} />
</GoogleMapReact>
);
}

Multiple Location Management​

function MultiLocationManager() {
const [locations, setLocations] = useState([]);
const [selectedLocation, setSelectedLocation] = useState(null);

const addLocation = locationData => {
setLocations(prev => [
...prev,
{
id: Date.now(),
...locationData,
},
]);
};

const removeLocation = id => {
setLocations(prev => prev.filter(loc => loc.id !== id));
};

return (
<VStack spacing={4}>
<SearchLocation onLocationSelect={addLocation} label='Add New Location' />

<SimpleGrid columns={2} spacing={4} w='full'>
{locations.map(location => (
<Box
key={location.id}
p={4}
borderWidth='1px'
borderRadius='md'
cursor='pointer'
onClick={() => setSelectedLocation(location)}
>
<Text fontWeight='bold'>{location.address}</Text>
<Text fontSize='sm' color='gray.600'>
{location.lat.toFixed(4)}, {location.lng.toFixed(4)}
</Text>
<IconButton
icon={<DeleteIcon />}
size='sm'
onClick={e => {
e.stopPropagation();
removeLocation(location.id);
}}
/>
</Box>
))}
</SimpleGrid>

{selectedLocation && (
<Map
center={selectedLocation}
zoom={15}
height='300px'
mapTitle={`Location: ${selectedLocation.address}`}
/>
)}
</VStack>
);
}