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
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| defaultZoom | number | - | 15 | Default map zoom level |
| height | string | - | '50vh' | Map container height |
| lat | number | - | -1.2921 | Initial latitude |
| lng | number | - | 36.8219 | Initial longitude |
| zoom | number | - | 15 | Current zoom level |
| getMapData | function | ✓ | - | 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}>
<FormControl>
<FormLabel>Checkpoint Radius</FormLabel>
<HStack>
<Slider
value={radius}
onChange={setRadius}
min={1}
max={100}
step={1}
flex={1}
>
<SliderTrack>
<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 />
<AlertDescription>
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
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| onLocationSelect | function | ✓ | - | Location selection callback |
| height | string | - | - | Input field height |
| label | string | - | - | Input label |
| helperText | string | - | - | Helper text below input |
| isDisabled | boolean | - | false | Disable input interaction |
| value | string | - | '' | Initial search value |
| isInvalid | boolean | - | false | Show error state |
| errorMessage | string | - | '' | Error message text |
| isRequired | boolean | - | false | Show 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
Basic Location Search
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}>
<FormControl>
<FormLabel>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>
);
}
Controlled Location Search
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
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| center | object | ✓ | - | Map center coordinates |
| zoom | number | - | 15 | Map zoom level |
| height | string | - | '50vh' | Map container height |
| onMapChange | function | - | - | Map change callback |
| mapTitle | string | - | - | 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
/>
<FormControl>
<FormLabel>Project Name</FormLabel>
<Input {...register('project_name')} />
</FormControl>
<FormControl>
<FormLabel>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 />
<AlertDescription>
{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
-
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
-
Address Handling
- Store both coordinates and formatted addresses
- Implement fallback for geocoding failures
- Handle partial address matches
-
Performance Optimization
- Debounce map interactions to reduce API calls
- Cache geocoding results where appropriate
- Implement proper loading states
User Experience
-
Error Handling
- Provide clear error messages for location failures
- Handle geolocation permission denials gracefully
- Show loading states during API calls
-
Accessibility
- Provide keyboard navigation alternatives
- Include proper ARIA labels for screen readers
- Ensure sufficient color contrast for map elements
-
Mobile Optimization
- Optimize map interactions for touch devices
- Handle different screen sizes appropriately
- Consider GPS accuracy variations
Security Considerations
-
API Key Management
- Use environment variables for API keys
- Implement proper API key restrictions
- Monitor API usage and costs
-
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
-
Google Maps API Not Loading
// Check if Google Maps API is loaded
if (!window.google) {
return <Text>Loading Google Maps API...</Text>;
} -
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');
}
}; -
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
-
Update Import Statements
// Old
import MapComponent from 'components/OldMap';
// New
import Map from 'components/Map';
import { SearchLocation } from 'components/Map/MapLocater'; -
Update Props Structure
// Old
<MapComponent
latitude={lat}
longitude={lng}
onLocationChange={handleChange}
/>
// New
<Map
lat={lat}
lng={lng}
getMapData={handleChange}
/> -
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>
);
}