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>
);
}