| 1 |
// src/services/locationService.js |
| 2 |
import axios from 'axios'; |
| 3 |
|
| 4 |
// API keys from environment variables (optional) |
| 5 |
const IPAPI_KEY = process.env.REACT_APP_IPAPI_KEY; |
| 6 |
const OPENCAGE_KEY = process.env.REACT_APP_OPENCAGE_KEY; |
| 7 |
|
| 8 |
/** |
| 9 |
* Detect user's location using IP geolocation |
| 10 |
* Uses ipapi.co free tier (no API key required for limited requests) |
| 11 |
*/ |
| 12 |
export const detectLocationByIP = async () => { |
| 13 |
try { |
| 14 |
// ipapi.co free tier - 1000 requests per day without API key |
| 15 |
const response = await axios.get('https://ipapi.co/json/'); |
| 16 |
|
| 17 |
return { |
| 18 |
city: response.data.city, |
| 19 |
state: response.data.region, |
| 20 |
country: response.data.country_name, |
| 21 |
countryCode: response.data.country_code, |
| 22 |
latitude: response.data.latitude, |
| 23 |
longitude: response.data.longitude, |
| 24 |
ip: response.data.ip |
| 25 |
}; |
| 26 |
} catch (error) { |
| 27 |
console.error('IP geolocation failed:', error); |
| 28 |
|
| 29 |
// Fallback to another free service |
| 30 |
try { |
| 31 |
const fallbackResponse = await axios.get('https://api.ipify.org?format=json'); |
| 32 |
const ip = fallbackResponse.data.ip; |
| 33 |
|
| 34 |
// Try ip-api.com as fallback (free, no key needed) |
| 35 |
const geoResponse = await axios.get(`http://ip-api.com/json/${ip}`); |
| 36 |
|
| 37 |
return { |
| 38 |
city: geoResponse.data.city, |
| 39 |
state: geoResponse.data.regionName, |
| 40 |
country: geoResponse.data.country, |
| 41 |
countryCode: geoResponse.data.countryCode, |
| 42 |
latitude: geoResponse.data.lat, |
| 43 |
longitude: geoResponse.data.lon, |
| 44 |
ip: ip |
| 45 |
}; |
| 46 |
} catch (fallbackError) { |
| 47 |
console.error('Fallback IP geolocation also failed:', fallbackError); |
| 48 |
throw new Error('Unable to detect location by IP'); |
| 49 |
} |
| 50 |
} |
| 51 |
}; |
| 52 |
|
| 53 |
/** |
| 54 |
* Reverse geocode coordinates to get location details |
| 55 |
* @param {number} latitude |
| 56 |
* @param {number} longitude |
| 57 |
*/ |
| 58 |
export const reverseGeocode = async (latitude, longitude) => { |
| 59 |
try { |
| 60 |
// If we have an OpenCage API key, use it for better accuracy |
| 61 |
if (OPENCAGE_KEY) { |
| 62 |
const response = await axios.get( |
| 63 |
`https://api.opencagedata.com/geocode/v1/json?q=${latitude}+${longitude}&key=${OPENCAGE_KEY}` |
| 64 |
); |
| 65 |
|
| 66 |
if (response.data.results && response.data.results.length > 0) { |
| 67 |
const result = response.data.results[0]; |
| 68 |
const components = result.components; |
| 69 |
|
| 70 |
return { |
| 71 |
city: components.city || components.town || components.village || components.municipality, |
| 72 |
state: components.state || components.county || components.state_district, |
| 73 |
country: components.country, |
| 74 |
countryCode: components.country_code?.toUpperCase(), |
| 75 |
latitude, |
| 76 |
longitude, |
| 77 |
formatted: result.formatted |
| 78 |
}; |
| 79 |
} |
| 80 |
} |
| 81 |
|
| 82 |
// Fallback: Use Nominatim (OpenStreetMap) - free, no key required |
| 83 |
const response = await axios.get( |
| 84 |
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}`, |
| 85 |
{ |
| 86 |
headers: { |
| 87 |
'User-Agent': 'LocalRoast/1.0' // Required by Nominatim |
| 88 |
} |
| 89 |
} |
| 90 |
); |
| 91 |
|
| 92 |
const address = response.data.address; |
| 93 |
|
| 94 |
return { |
| 95 |
city: address.city || address.town || address.village || address.municipality, |
| 96 |
state: address.state || address.county || address.state_district, |
| 97 |
country: address.country, |
| 98 |
countryCode: address.country_code?.toUpperCase(), |
| 99 |
latitude, |
| 100 |
longitude, |
| 101 |
formatted: response.data.display_name |
| 102 |
}; |
| 103 |
|
| 104 |
} catch (error) { |
| 105 |
console.error('Reverse geocoding failed:', error); |
| 106 |
// If reverse geocoding fails, fall back to IP location |
| 107 |
return detectLocationByIP(); |
| 108 |
} |
| 109 |
}; |
| 110 |
|
| 111 |
/** |
| 112 |
* Main function to detect user location |
| 113 |
* Tries browser geolocation first, then falls back to IP |
| 114 |
*/ |
| 115 |
export const detectLocation = async () => { |
| 116 |
return new Promise((resolve, reject) => { |
| 117 |
// Check if geolocation is available |
| 118 |
if ('geolocation' in navigator) { |
| 119 |
navigator.geolocation.getCurrentPosition( |
| 120 |
async (position) => { |
| 121 |
try { |
| 122 |
// Got coordinates, now reverse geocode them |
| 123 |
const location = await reverseGeocode( |
| 124 |
position.coords.latitude, |
| 125 |
position.coords.longitude |
| 126 |
); |
| 127 |
resolve(location); |
| 128 |
} catch (error) { |
| 129 |
// Reverse geocoding failed, try IP location |
| 130 |
try { |
| 131 |
const ipLocation = await detectLocationByIP(); |
| 132 |
resolve(ipLocation); |
| 133 |
} catch (ipError) { |
| 134 |
reject(new Error('Unable to detect location')); |
| 135 |
} |
| 136 |
} |
| 137 |
}, |
| 138 |
async (error) => { |
| 139 |
// User denied geolocation or it failed |
| 140 |
console.log('Browser geolocation failed:', error.message); |
| 141 |
|
| 142 |
// Fall back to IP geolocation |
| 143 |
try { |
| 144 |
const ipLocation = await detectLocationByIP(); |
| 145 |
resolve(ipLocation); |
| 146 |
} catch (ipError) { |
| 147 |
reject(new Error('Unable to detect location')); |
| 148 |
} |
| 149 |
}, |
| 150 |
{ |
| 151 |
timeout: 5000, // 5 second timeout |
| 152 |
enableHighAccuracy: false // Don't need high accuracy for roasting |
| 153 |
} |
| 154 |
); |
| 155 |
} else { |
| 156 |
// Browser doesn't support geolocation, use IP |
| 157 |
detectLocationByIP() |
| 158 |
.then(resolve) |
| 159 |
.catch(() => reject(new Error('Unable to detect location'))); |
| 160 |
} |
| 161 |
}); |
| 162 |
}; |
| 163 |
|
| 164 |
/** |
| 165 |
* Parse manual location input into structured format |
| 166 |
* @param {string} input - User's manual location input |
| 167 |
*/ |
| 168 |
export const parseManualLocation = (input) => { |
| 169 |
if (!input || typeof input !== 'string') { |
| 170 |
return null; |
| 171 |
} |
| 172 |
|
| 173 |
// Split by commas and trim whitespace |
| 174 |
const parts = input.split(',').map(part => part.trim()).filter(Boolean); |
| 175 |
|
| 176 |
if (parts.length === 0) { |
| 177 |
return null; |
| 178 |
} |
| 179 |
|
| 180 |
// Try to intelligently parse the input |
| 181 |
let city, state, country; |
| 182 |
|
| 183 |
if (parts.length === 1) { |
| 184 |
// Could be city, state, or country |
| 185 |
const location = parts[0]; |
| 186 |
|
| 187 |
// Check if it's a known state/country |
| 188 |
if (isKnownState(location) || isKnownCountry(location)) { |
| 189 |
if (isKnownCountry(location)) { |
| 190 |
country = location; |
| 191 |
} else { |
| 192 |
state = location; |
| 193 |
country = 'United States'; // Assume US for state names |
| 194 |
} |
| 195 |
} else { |
| 196 |
// Assume it's a city |
| 197 |
city = location; |
| 198 |
} |
| 199 |
} else if (parts.length === 2) { |
| 200 |
// Likely "City, State" or "City, Country" |
| 201 |
city = parts[0]; |
| 202 |
|
| 203 |
if (isKnownCountry(parts[1])) { |
| 204 |
country = parts[1]; |
| 205 |
} else { |
| 206 |
state = parts[1]; |
| 207 |
// Assume US if state is provided without country |
| 208 |
country = 'United States'; |
| 209 |
} |
| 210 |
} else if (parts.length >= 3) { |
| 211 |
// "City, State, Country" format |
| 212 |
city = parts[0]; |
| 213 |
state = parts[1]; |
| 214 |
country = parts[2]; |
| 215 |
} |
| 216 |
|
| 217 |
return { |
| 218 |
city, |
| 219 |
state, |
| 220 |
country, |
| 221 |
formatted: input |
| 222 |
}; |
| 223 |
}; |
| 224 |
|
| 225 |
// Helper functions to identify known locations |
| 226 |
const isKnownState = (location) => { |
| 227 |
const states = [ |
| 228 |
'Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California', 'Colorado', |
| 229 |
'Connecticut', 'Delaware', 'Florida', 'Georgia', 'Hawaii', 'Idaho', |
| 230 |
'Illinois', 'Indiana', 'Iowa', 'Kansas', 'Kentucky', 'Louisiana', |
| 231 |
'Maine', 'Maryland', 'Massachusetts', 'Michigan', 'Minnesota', |
| 232 |
'Mississippi', 'Missouri', 'Montana', 'Nebraska', 'Nevada', |
| 233 |
'New Hampshire', 'New Jersey', 'New Mexico', 'New York', |
| 234 |
'North Carolina', 'North Dakota', 'Ohio', 'Oklahoma', 'Oregon', |
| 235 |
'Pennsylvania', 'Rhode Island', 'South Carolina', 'South Dakota', |
| 236 |
'Tennessee', 'Texas', 'Utah', 'Vermont', 'Virginia', 'Washington', |
| 237 |
'West Virginia', 'Wisconsin', 'Wyoming', |
| 238 |
// Common abbreviations |
| 239 |
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', |
| 240 |
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', |
| 241 |
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', |
| 242 |
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', |
| 243 |
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY', |
| 244 |
// Canadian provinces |
| 245 |
'Ontario', 'Quebec', 'British Columbia', 'Alberta', 'Manitoba', |
| 246 |
'Saskatchewan', 'Nova Scotia', 'New Brunswick', 'Newfoundland', |
| 247 |
'Prince Edward Island', 'Northwest Territories', 'Yukon', 'Nunavut' |
| 248 |
]; |
| 249 |
|
| 250 |
return states.some(state => |
| 251 |
state.toLowerCase() === location.toLowerCase() |
| 252 |
); |
| 253 |
}; |
| 254 |
|
| 255 |
const isKnownCountry = (location) => { |
| 256 |
const countries = [ |
| 257 |
'United States', 'USA', 'US', 'America', |
| 258 |
'Canada', 'United Kingdom', 'UK', 'Britain', 'England', |
| 259 |
'Australia', 'New Zealand', 'Ireland', 'Scotland', 'Wales', |
| 260 |
'France', 'Germany', 'Spain', 'Italy', 'Netherlands', |
| 261 |
'Mexico', 'Brazil', 'Argentina', 'Japan', 'China', 'India' |
| 262 |
]; |
| 263 |
|
| 264 |
return countries.some(country => |
| 265 |
country.toLowerCase() === location.toLowerCase() |
| 266 |
); |
| 267 |
}; |
| 268 |
|
| 269 |
// Export all functions |
| 270 |
export default { |
| 271 |
detectLocation, |
| 272 |
detectLocationByIP, |
| 273 |
reverseGeocode, |
| 274 |
parseManualLocation |
| 275 |
}; |