@sanvika/geolocation 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -10
- package/src/client/index.js +128 -37
- package/src/client/loadGoogleMapsScript.js +121 -0
- package/src/server/index.js +95 -72
- package/src/server/serviceClient.js +69 -0
- package/src/server/stateDetection.js +0 -112
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanvika/geolocation",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Sanvika ecosystem geolocation SDK —
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Sanvika ecosystem geolocation SDK — fully centralized via geolocation.sanvikaproduction.com. IP lookup, reverse/forward geocoding, Places autocomplete + details, Maps JS loader. Zero Google API keys in consumer projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -17,13 +17,7 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@sanvika/logger": ">=0.2"
|
|
19
19
|
},
|
|
20
|
-
"keywords": [
|
|
21
|
-
"sanvika",
|
|
22
|
-
"geolocation",
|
|
23
|
-
"ip",
|
|
24
|
-
"location",
|
|
25
|
-
"india"
|
|
26
|
-
],
|
|
20
|
+
"keywords": ["sanvika", "geolocation", "ip", "location", "india"],
|
|
27
21
|
"author": "Sanvika Production",
|
|
28
22
|
"license": "UNLICENSED",
|
|
29
23
|
"repository": {
|
|
@@ -33,4 +27,4 @@
|
|
|
33
27
|
"publishConfig": {
|
|
34
28
|
"access": "public"
|
|
35
29
|
}
|
|
36
|
-
}
|
|
30
|
+
}
|
package/src/client/index.js
CHANGED
|
@@ -1,15 +1,127 @@
|
|
|
1
1
|
// packages/sanvika-geolocation-sdk/src/client/index.js
|
|
2
|
-
// Client-side (browser) geolocation
|
|
3
|
-
// Usage: import {
|
|
2
|
+
// Client-side (browser) geolocation helpers.
|
|
3
|
+
// Usage: import { ... } from "@sanvika/geolocation/client"
|
|
4
|
+
//
|
|
5
|
+
// Env vars (read from process.env in Next.js / similar):
|
|
6
|
+
// NEXT_PUBLIC_GEOLOCATION_URL=https://geolocation.sanvikaproduction.com
|
|
7
|
+
// NEXT_PUBLIC_GEOLOCATION_CLIENT_ID=fapk
|
|
8
|
+
//
|
|
9
|
+
// Consumer projects NO LONGER need proxy routes for geolocation.
|
|
10
|
+
// All calls go directly to geolocation.sanvikaproduction.com via public clientId auth.
|
|
11
|
+
// Only getUserLocation / updateUserLocation still need FAPK routes (they use SA SSO auth).
|
|
4
12
|
|
|
5
13
|
import { createLogger } from "@sanvika/logger";
|
|
6
14
|
import { setWithTTL, getWithTTL, TTL } from "./localStorageTTL.js";
|
|
7
15
|
|
|
16
|
+
// ─── Direct service helpers (no consumer proxy routes needed) ────────────────
|
|
17
|
+
|
|
18
|
+
function _geoUrl() {
|
|
19
|
+
return (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_GEOLOCATION_URL)
|
|
20
|
+
|| "https://geolocation.sanvikaproduction.com";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _clientId() {
|
|
24
|
+
return (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_GEOLOCATION_CLIENT_ID) || "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function _servicePost(path, body) {
|
|
28
|
+
const clientId = _clientId();
|
|
29
|
+
const qs = clientId ? `?clientId=${encodeURIComponent(clientId)}` : "";
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`${_geoUrl()}${path}${qs}`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
});
|
|
36
|
+
return res.ok ? res.json() : { success: false };
|
|
37
|
+
} catch {
|
|
38
|
+
return { success: false };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reverse geocode lat/lng → address parts via geolocation service directly.
|
|
44
|
+
* Replaces fetch("/api/location/getLocationDetails") in consumer projects.
|
|
45
|
+
*/
|
|
46
|
+
export async function reverseGeocode(latitude, longitude, opts = {}) {
|
|
47
|
+
const r = await _servicePost("/api/v1/geocode/reverse", { lat: latitude, lng: longitude });
|
|
48
|
+
if (!r.success || !r.found) return null;
|
|
49
|
+
return {
|
|
50
|
+
country: r.country || "",
|
|
51
|
+
state: r.normalizedState || r.state || "",
|
|
52
|
+
city: r.city || "",
|
|
53
|
+
nearbyLandmark: r.sublocality || "",
|
|
54
|
+
postalCode: r.postalCode || "",
|
|
55
|
+
fullAddress: r.fullAddress || "",
|
|
56
|
+
latitude: Number(latitude),
|
|
57
|
+
longitude: Number(longitude),
|
|
58
|
+
geoLocation: { type: "Point", coordinates: [Number(longitude), Number(latitude)] },
|
|
59
|
+
placeId: r.placeId || "",
|
|
60
|
+
source: opts.source || "reverse_geocode",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Forward geocode address → lat/lng via geolocation service directly.
|
|
66
|
+
* Replaces fetch("/api/location/getCoordinates") in consumer projects.
|
|
67
|
+
*/
|
|
68
|
+
export async function forwardGeocode(address, { country = "IN" } = {}) {
|
|
69
|
+
return _servicePost("/api/v1/geocode/forward", { address, country });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* IP-based location lookup via geolocation service directly from browser.
|
|
74
|
+
* Replaces fetch("/api/location/getIpLocation") in consumer projects.
|
|
75
|
+
* Browser's real IP is detected server-side by the geolocation service.
|
|
76
|
+
*/
|
|
77
|
+
export async function getIpLocation() {
|
|
78
|
+
try {
|
|
79
|
+
const clientId = _clientId();
|
|
80
|
+
const qs = clientId ? `?clientId=${encodeURIComponent(clientId)}` : "";
|
|
81
|
+
const res = await fetch(`${_geoUrl()}/api/v1/locate${qs}`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify({}),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) return null;
|
|
87
|
+
const data = await res.json();
|
|
88
|
+
if (!data.success || !data.location) return null;
|
|
89
|
+
const l = data.location;
|
|
90
|
+
return { latitude: l.lat ?? null, longitude: l.lng ?? null, city: l.city || "Unknown", state: l.region || "", country: l.country || "India", source: "ip" };
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Places autocomplete via geolocation service directly.
|
|
98
|
+
* Replaces fetch("/api/location/places/autocomplete") in consumer projects.
|
|
99
|
+
*/
|
|
100
|
+
export async function placesAutocomplete(input, { country = "IN", types = "geocode", language = "en", sessionToken } = {}) {
|
|
101
|
+
if (!input || input.trim().length < 2) return { success: true, predictions: [] };
|
|
102
|
+
return _servicePost("/api/v1/places/autocomplete", { input: input.trim(), country, types, language, sessionToken });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Place details by placeId via geolocation service directly.
|
|
107
|
+
* Replaces fetch("/api/location/places/details") in consumer projects.
|
|
108
|
+
*/
|
|
109
|
+
export async function placesDetails(placeId, { language = "en", sessionToken } = {}) {
|
|
110
|
+
if (!placeId) return { success: false };
|
|
111
|
+
return _servicePost("/api/v1/places/details", { placeId, language, sessionToken });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export {
|
|
117
|
+
loadGoogleMapsScript,
|
|
118
|
+
getMapsConfig,
|
|
119
|
+
resetMapsLoader,
|
|
120
|
+
} from "./loadGoogleMapsScript.js";
|
|
121
|
+
|
|
8
122
|
const logger = createLogger({ namespace: "SanvikaGeolocation" });
|
|
9
123
|
const LOCATION_CACHE_KEY = "sanvika_location_cache";
|
|
10
124
|
|
|
11
|
-
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
12
|
-
|
|
13
125
|
async function tryFallbacks(options) {
|
|
14
126
|
if (options.useCachedFallback) {
|
|
15
127
|
const cached = getWithTTL(LOCATION_CACHE_KEY);
|
|
@@ -27,26 +139,18 @@ async function tryFallbacks(options) {
|
|
|
27
139
|
|
|
28
140
|
if (options.useIpFallback) {
|
|
29
141
|
try {
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
142
|
+
// Call geolocation service directly — no FAPK proxy needed
|
|
143
|
+
const ipLoc = await getIpLocation();
|
|
144
|
+
if (ipLoc?.latitude && ipLoc?.longitude) {
|
|
33
145
|
logger.info("Using IP fallback for location");
|
|
34
|
-
return {
|
|
35
|
-
latitude: data.location.latitude,
|
|
36
|
-
longitude: data.location.longitude,
|
|
37
|
-
accuracy: 20000,
|
|
38
|
-
source: "ip",
|
|
39
|
-
timestamp: Date.now(),
|
|
40
|
-
};
|
|
146
|
+
return { latitude: ipLoc.latitude, longitude: ipLoc.longitude, accuracy: 20000, source: "ip", timestamp: Date.now() };
|
|
41
147
|
}
|
|
42
|
-
} catch { /* silent
|
|
148
|
+
} catch { /* silent */ }
|
|
43
149
|
}
|
|
44
150
|
|
|
45
151
|
throw new Error("All location methods failed");
|
|
46
152
|
}
|
|
47
153
|
|
|
48
|
-
// ─── Main exports ────────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
154
|
export function getLocationPromise(options = {}) {
|
|
51
155
|
const finalOptions = {
|
|
52
156
|
enableHighAccuracy: true,
|
|
@@ -111,17 +215,9 @@ export async function getFullLocationDetails(options = {}) {
|
|
|
111
215
|
if (!locationData.latitude || !locationData.longitude) return locationData;
|
|
112
216
|
|
|
113
217
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
body: JSON.stringify({
|
|
118
|
-
latitude: locationData.latitude,
|
|
119
|
-
longitude: locationData.longitude,
|
|
120
|
-
source: locationData.source,
|
|
121
|
-
}),
|
|
122
|
-
});
|
|
123
|
-
const data = await res.json();
|
|
124
|
-
if (data.success) return { ...data.location, source: locationData.source, timestamp: locationData.timestamp };
|
|
218
|
+
// Call geolocation service directly — no FAPK proxy needed
|
|
219
|
+
const details = await reverseGeocode(locationData.latitude, locationData.longitude, { source: locationData.source });
|
|
220
|
+
if (details) return { ...details, source: locationData.source, timestamp: locationData.timestamp };
|
|
125
221
|
} catch (error) {
|
|
126
222
|
logger.error("Error fetching location details", error);
|
|
127
223
|
}
|
|
@@ -162,16 +258,11 @@ export function getCachedLocation() {
|
|
|
162
258
|
|
|
163
259
|
export async function getCityFromCoordinates(latitude, longitude) {
|
|
164
260
|
const cached = getCachedLocation();
|
|
165
|
-
if (cached?.city) return cached.city;
|
|
166
|
-
|
|
261
|
+
if (cached?.address?.city) return cached.address.city;
|
|
167
262
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
body: JSON.stringify({ latitude, longitude, source: "city_fetch" }),
|
|
172
|
-
});
|
|
173
|
-
const data = await res.json();
|
|
174
|
-
if (data.success && data.location?.city) return data.location.city;
|
|
263
|
+
// Call geolocation service directly — no FAPK proxy needed
|
|
264
|
+
const details = await reverseGeocode(latitude, longitude);
|
|
265
|
+
if (details?.city) return details.city;
|
|
175
266
|
} catch (error) {
|
|
176
267
|
logger.error("Error getting city from coordinates:", error);
|
|
177
268
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// packages/sanvika-geolocation-sdk/src/client/loadGoogleMapsScript.js
|
|
2
|
+
// Browser-side loader for Google Maps JS API.
|
|
3
|
+
// Fetches API key + Map ID from geolocation.sanvikaproduction.com (centralized),
|
|
4
|
+
// then injects the standard Google Maps script tag with the centrally-issued key.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// import { loadGoogleMapsScript } from "@sanvika/geolocation/client";
|
|
8
|
+
// await loadGoogleMapsScript({ libraries: ["places", "marker"] });
|
|
9
|
+
//
|
|
10
|
+
// Env vars (auto-read from process.env):
|
|
11
|
+
// NEXT_PUBLIC_GEOLOCATION_URL=https://geolocation.sanvikaproduction.com
|
|
12
|
+
// NEXT_PUBLIC_GEOLOCATION_CLIENT_ID=fapk
|
|
13
|
+
|
|
14
|
+
import { createLogger } from "@sanvika/logger";
|
|
15
|
+
|
|
16
|
+
const logger = createLogger({ namespace: "SanvikaGeolocation.MapsLoader" });
|
|
17
|
+
|
|
18
|
+
let inflight = null;
|
|
19
|
+
let cachedConfig = null;
|
|
20
|
+
|
|
21
|
+
function readEnv(key) {
|
|
22
|
+
try {
|
|
23
|
+
return (typeof process !== "undefined" && process.env?.[key]) || null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fetchMapsConfig({ apiBase, clientId }) {
|
|
30
|
+
if (cachedConfig) return cachedConfig;
|
|
31
|
+
const url = `${apiBase}/api/v1/mapsjs/config?clientId=${encodeURIComponent(clientId)}`;
|
|
32
|
+
const res = await fetch(url, { credentials: "omit" });
|
|
33
|
+
const data = await res.json().catch(() => ({}));
|
|
34
|
+
if (!res.ok || !data?.success) {
|
|
35
|
+
throw new Error(`mapsjs/config failed: ${data?.error || `HTTP_${res.status}`}`);
|
|
36
|
+
}
|
|
37
|
+
cachedConfig = data.config;
|
|
38
|
+
return cachedConfig;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loadGoogleMapsScript({
|
|
42
|
+
libraries = ["places", "marker"],
|
|
43
|
+
version = "weekly",
|
|
44
|
+
apiBase: apiBaseInput,
|
|
45
|
+
clientId: clientIdInput,
|
|
46
|
+
} = {}) {
|
|
47
|
+
if (typeof window === "undefined") {
|
|
48
|
+
throw new Error("loadGoogleMapsScript must run in the browser");
|
|
49
|
+
}
|
|
50
|
+
if (window.google?.maps?.Map) return window.google;
|
|
51
|
+
if (inflight) return inflight;
|
|
52
|
+
|
|
53
|
+
const apiBase = (apiBaseInput || readEnv("NEXT_PUBLIC_GEOLOCATION_URL") || "").replace(/\/$/, "");
|
|
54
|
+
const clientId = clientIdInput || readEnv("NEXT_PUBLIC_GEOLOCATION_CLIENT_ID");
|
|
55
|
+
if (!apiBase || !clientId) {
|
|
56
|
+
throw new Error("loadGoogleMapsScript: NEXT_PUBLIC_GEOLOCATION_URL and NEXT_PUBLIC_GEOLOCATION_CLIENT_ID are required");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
inflight = (async () => {
|
|
60
|
+
const config = await fetchMapsConfig({ apiBase, clientId });
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const callbackName = `__sanvika_maps_cb_${Date.now()}`;
|
|
64
|
+
window[callbackName] = () => {
|
|
65
|
+
delete window[callbackName];
|
|
66
|
+
if (window.google?.maps?.Map) {
|
|
67
|
+
logger.info("Google Maps loaded", { libraries, mapId: !!config.mapId });
|
|
68
|
+
resolve(window.google);
|
|
69
|
+
} else {
|
|
70
|
+
reject(new Error("Maps loaded but window.google.maps.Map missing"));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const params = new URLSearchParams({
|
|
75
|
+
key: config.apiKey,
|
|
76
|
+
v: version,
|
|
77
|
+
libraries: libraries.join(","),
|
|
78
|
+
loading: "async",
|
|
79
|
+
callback: callbackName,
|
|
80
|
+
});
|
|
81
|
+
if (config.mapId) params.set("map_ids", config.mapId);
|
|
82
|
+
|
|
83
|
+
const existing = document.querySelector("script[data-sanvika-maps]");
|
|
84
|
+
if (existing) {
|
|
85
|
+
if (window.google?.maps?.Map) {
|
|
86
|
+
delete window[callbackName];
|
|
87
|
+
resolve(window.google);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const script = document.createElement("script");
|
|
93
|
+
script.src = `https://maps.googleapis.com/maps/api/js?${params.toString()}`;
|
|
94
|
+
script.async = true;
|
|
95
|
+
script.defer = true;
|
|
96
|
+
script.dataset.sanvikaMaps = "1";
|
|
97
|
+
script.onerror = (e) => {
|
|
98
|
+
delete window[callbackName];
|
|
99
|
+
inflight = null;
|
|
100
|
+
reject(e);
|
|
101
|
+
};
|
|
102
|
+
document.head.appendChild(script);
|
|
103
|
+
});
|
|
104
|
+
})();
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
return await inflight;
|
|
108
|
+
} catch (e) {
|
|
109
|
+
inflight = null;
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getMapsConfig() {
|
|
115
|
+
return cachedConfig;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function resetMapsLoader() {
|
|
119
|
+
cachedConfig = null;
|
|
120
|
+
inflight = null;
|
|
121
|
+
}
|
package/src/server/index.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
// packages/sanvika-geolocation-sdk/src/server/index.js
|
|
2
|
-
// Server-side
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
getStateCacheStats,
|
|
10
|
-
} from "./stateDetection.js";
|
|
2
|
+
// Server-side helpers — all Google Maps + IP lookup calls route through
|
|
3
|
+
// geolocation.sanvikaproduction.com. Consumer projects don't need any
|
|
4
|
+
// Google API keys in their own env.
|
|
5
|
+
//
|
|
6
|
+
// Required env vars in consumer projects:
|
|
7
|
+
// GEOLOCATION_URL=https://geolocation.sanvikaproduction.com
|
|
8
|
+
// GEOLOCATION_CLIENT_SECRET=<plain text, registered in admin panel>
|
|
11
9
|
|
|
12
10
|
export {
|
|
13
11
|
INDIAN_STATES,
|
|
@@ -18,91 +16,116 @@ export {
|
|
|
18
16
|
isValidIndianState,
|
|
19
17
|
} from "./india.js";
|
|
20
18
|
|
|
19
|
+
export {
|
|
20
|
+
GeolocationServiceClient,
|
|
21
|
+
getServiceClient,
|
|
22
|
+
resetServiceClient,
|
|
23
|
+
} from "./serviceClient.js";
|
|
24
|
+
|
|
21
25
|
import { createLogger } from "@sanvika/logger";
|
|
26
|
+
import { getServiceClient } from "./serviceClient.js";
|
|
27
|
+
import { normalizeStateName } from "./india.js";
|
|
22
28
|
|
|
23
29
|
const logger = createLogger({ namespace: "SanvikaGeolocation" });
|
|
24
30
|
|
|
25
|
-
const locationCache = new Map();
|
|
26
|
-
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
27
|
-
|
|
28
31
|
export function isPrivateIp(ip) {
|
|
32
|
+
if (!ip) return true;
|
|
29
33
|
const privateRanges = [
|
|
30
|
-
/^127\./,
|
|
31
|
-
/^
|
|
32
|
-
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
33
|
-
/^192\.168\./,
|
|
34
|
-
/^::1$/,
|
|
35
|
-
/^fe80:/i,
|
|
36
|
-
/^fc00:/i,
|
|
37
|
-
/^fd00:/i,
|
|
34
|
+
/^127\./, /^10\./, /^172\.(1[6-9]|2[0-9]|3[0-1])\./, /^192\.168\./,
|
|
35
|
+
/^::1$/, /^fe80:/i, /^fc00:/i, /^fd00:/i,
|
|
38
36
|
];
|
|
39
37
|
return privateRanges.some((r) => r.test(ip));
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
function
|
|
40
|
+
function defaultLocation() {
|
|
43
41
|
return {
|
|
44
|
-
city: "Unknown",
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
countryCode: "IN",
|
|
48
|
-
zipCode: null,
|
|
49
|
-
latitude: null,
|
|
50
|
-
longitude: null,
|
|
51
|
-
timezone: "Asia/Kolkata",
|
|
52
|
-
source: "default",
|
|
42
|
+
city: "Unknown", state: "Unknown", country: "India", countryCode: "IN",
|
|
43
|
+
zipCode: null, latitude: null, longitude: null,
|
|
44
|
+
timezone: "Asia/Kolkata", source: "default",
|
|
53
45
|
};
|
|
54
46
|
}
|
|
55
47
|
|
|
48
|
+
/** Resolve approximate location from an IP via geolocation service (cached server-side). */
|
|
56
49
|
export async function getLocationFromIp(ip) {
|
|
57
|
-
if (!ip || ip === "unknown" || isPrivateIp(ip))
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
if (!ip || ip === "unknown" || isPrivateIp(ip)) return defaultLocation();
|
|
51
|
+
const r = await getServiceClient().request("/api/v1/locate", { body: { ip } });
|
|
52
|
+
if (!r.success || !r.location) return defaultLocation();
|
|
53
|
+
const l = r.location;
|
|
54
|
+
return {
|
|
55
|
+
city: l.city || "Unknown",
|
|
56
|
+
state: l.region || "Unknown",
|
|
57
|
+
country: l.country || "India",
|
|
58
|
+
countryCode: l.countryCode || "IN",
|
|
59
|
+
zipCode: null,
|
|
60
|
+
latitude: l.lat ?? null,
|
|
61
|
+
longitude: l.lng ?? null,
|
|
62
|
+
timezone: l.timezone || "Asia/Kolkata",
|
|
63
|
+
source: r.source || "lookup",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
/** Reverse geocode lat/lng → address parts (proxied via geolocation service, cached). */
|
|
68
|
+
export async function reverseGeocode(latitude, longitude) {
|
|
69
|
+
const lat = typeof latitude === "string" ? parseFloat(latitude) : latitude;
|
|
70
|
+
const lng = typeof longitude === "string" ? parseFloat(longitude) : longitude;
|
|
71
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
|
72
|
+
return { success: false, error: "INVALID_COORDINATES" };
|
|
64
73
|
}
|
|
74
|
+
return getServiceClient().request("/api/v1/geocode/reverse", { body: { lat, lng } });
|
|
75
|
+
}
|
|
65
76
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
headers: { Accept: "application/json" },
|
|
72
|
-
signal: AbortSignal.timeout(3000),
|
|
73
|
-
}
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
if (!response.ok) throw new Error(`ip-api returned ${response.status}`);
|
|
77
|
-
|
|
78
|
-
const data = await response.json();
|
|
77
|
+
/** Forward geocode address → lat/lng (proxied, cached). */
|
|
78
|
+
export async function forwardGeocode(address, { country = "IN" } = {}) {
|
|
79
|
+
if (!address) return { success: false, error: "ADDRESS_REQUIRED" };
|
|
80
|
+
return getServiceClient().request("/api/v1/geocode/forward", { body: { address, country } });
|
|
81
|
+
}
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
/** Server-side Places autocomplete — input → predictions[]. */
|
|
84
|
+
export async function placesAutocomplete(input, { country = "IN", types = "geocode", language = "en", sessionToken } = {}) {
|
|
85
|
+
if (!input) return { success: false, error: "INPUT_REQUIRED" };
|
|
86
|
+
return getServiceClient().request("/api/v1/places/autocomplete", {
|
|
87
|
+
body: { input, country, types, language, sessionToken },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
84
90
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
longitude: data.lon || null,
|
|
93
|
-
timezone: data.timezone || "Asia/Kolkata",
|
|
94
|
-
source: "ip_api",
|
|
95
|
-
};
|
|
91
|
+
/** Server-side Place details by placeId. */
|
|
92
|
+
export async function placesDetails(placeId, { language = "en", fields, sessionToken } = {}) {
|
|
93
|
+
if (!placeId) return { success: false, error: "PLACE_ID_REQUIRED" };
|
|
94
|
+
return getServiceClient().request("/api/v1/places/details", {
|
|
95
|
+
body: { placeId, language, fields, sessionToken },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Detect Indian state + city from coordinates.
|
|
101
|
+
* Now proxied through reverseGeocode (which calls geolocation service) — no Google key in consumer env.
|
|
102
|
+
*/
|
|
103
|
+
export async function detectStateFromCoordinates(latitude, longitude) {
|
|
104
|
+
const lat = typeof latitude === "string" ? parseFloat(latitude) : latitude;
|
|
105
|
+
const lng = typeof longitude === "string" ? parseFloat(longitude) : longitude;
|
|
106
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
|
107
|
+
return { state: null, city: null, fromCache: false };
|
|
103
108
|
}
|
|
109
|
+
const r = await reverseGeocode(lat, lng);
|
|
110
|
+
if (!r.success || !r.found) return { state: null, city: null, fromCache: false };
|
|
111
|
+
const state = r.normalizedState || normalizeStateName(r.state);
|
|
112
|
+
if (state) {
|
|
113
|
+
logger.info("State detected", { state, city: r.city });
|
|
114
|
+
} else if (r.state) {
|
|
115
|
+
logger.warn("State not recognized as Indian", { rawState: r.state });
|
|
116
|
+
}
|
|
117
|
+
return { state, city: r.city || null, fromCache: false };
|
|
104
118
|
}
|
|
105
119
|
|
|
106
|
-
export function
|
|
107
|
-
|
|
120
|
+
export async function isUserInTargetStates(latitude, longitude, targetStates) {
|
|
121
|
+
if (!Array.isArray(targetStates) || targetStates.length === 0) return false;
|
|
122
|
+
const { state } = await detectStateFromCoordinates(latitude, longitude);
|
|
123
|
+
if (!state) return false;
|
|
124
|
+
const normalized = targetStates.map(normalizeStateName).filter(Boolean);
|
|
125
|
+
return normalized.includes(state);
|
|
108
126
|
}
|
|
127
|
+
|
|
128
|
+
// Legacy no-ops kept for backwards compatibility.
|
|
129
|
+
export function clearLocationCache() {}
|
|
130
|
+
export function clearStateCache() {}
|
|
131
|
+
export function getStateCacheStats() { return { keys: 0 }; }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// packages/sanvika-geolocation-sdk/src/server/serviceClient.js
|
|
2
|
+
// HTTP client for geolocation.sanvikaproduction.com — used by all server-side helpers.
|
|
3
|
+
// Env vars: GEOLOCATION_URL, GEOLOCATION_CLIENT_SECRET (or pass to constructor).
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 6000;
|
|
6
|
+
|
|
7
|
+
function readEnv(key) {
|
|
8
|
+
try {
|
|
9
|
+
return (typeof process !== "undefined" && process.env?.[key]) || null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class GeolocationServiceClient {
|
|
16
|
+
#url;
|
|
17
|
+
#secret;
|
|
18
|
+
|
|
19
|
+
constructor({ url, secret } = {}) {
|
|
20
|
+
const finalUrl = url || readEnv("GEOLOCATION_URL");
|
|
21
|
+
const finalSecret = secret || readEnv("GEOLOCATION_CLIENT_SECRET");
|
|
22
|
+
if (!finalUrl || !finalSecret) {
|
|
23
|
+
throw new Error("@sanvika/geolocation/server: GEOLOCATION_URL and GEOLOCATION_CLIENT_SECRET are required");
|
|
24
|
+
}
|
|
25
|
+
this.#url = finalUrl.replace(/\/$/, "");
|
|
26
|
+
this.#secret = finalSecret;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get baseUrl() { return this.#url; }
|
|
30
|
+
|
|
31
|
+
async request(path, { method = "POST", body, query } = {}) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
34
|
+
|
|
35
|
+
const qs = query ? "?" + new URLSearchParams(query).toString() : "";
|
|
36
|
+
const init = {
|
|
37
|
+
method,
|
|
38
|
+
headers: { "x-client-secret": this.#secret },
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
};
|
|
41
|
+
if (body !== undefined) {
|
|
42
|
+
init.headers["Content-Type"] = "application/json";
|
|
43
|
+
init.body = JSON.stringify(body);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${this.#url}${path}${qs}`, init);
|
|
48
|
+
const json = await res.json().catch(() => ({}));
|
|
49
|
+
if (!res.ok || !json.success) {
|
|
50
|
+
return { success: false, status: res.status, error: json?.error || `HTTP_${res.status}` };
|
|
51
|
+
}
|
|
52
|
+
return { success: true, ...json };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { success: false, error: err.name === "AbortError" ? "TIMEOUT" : err.message };
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let _instance = null;
|
|
62
|
+
export function getServiceClient() {
|
|
63
|
+
if (!_instance) _instance = new GeolocationServiceClient();
|
|
64
|
+
return _instance;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resetServiceClient() {
|
|
68
|
+
_instance = null;
|
|
69
|
+
}
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
// Reverse geocoding: lat/lng → Indian state + city via Google Geocoding API
|
|
2
|
-
// Supports GOOGLE_MAPS_API_KEY (server-safe) or NEXT_PUBLIC_GOOGLE_MAPS_API_KEY (Next.js)
|
|
3
|
-
|
|
4
|
-
import { createLogger } from "@sanvika/logger";
|
|
5
|
-
import { normalizeStateName } from "./india.js";
|
|
6
|
-
|
|
7
|
-
const logger = createLogger({ namespace: "SanvikaGeolocation.StateDetection" });
|
|
8
|
-
|
|
9
|
-
// In-memory cache — key: "lat_lng" rounded to 2dp (~1.1km accuracy)
|
|
10
|
-
const stateCache = new Map();
|
|
11
|
-
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
12
|
-
|
|
13
|
-
function getCacheKey(lat, lng) {
|
|
14
|
-
return `${Math.round(lat * 100) / 100}_${Math.round(lng * 100) / 100}`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function getApiKey() {
|
|
18
|
-
return (
|
|
19
|
-
(typeof process !== "undefined" && process.env?.GOOGLE_MAPS_API_KEY) ||
|
|
20
|
-
(typeof process !== "undefined" && process.env?.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY) ||
|
|
21
|
-
null
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function detectStateFromCoordinates(latitude, longitude) {
|
|
26
|
-
const lat = typeof latitude === "string" ? parseFloat(latitude) : latitude;
|
|
27
|
-
const lng = typeof longitude === "string" ? parseFloat(longitude) : longitude;
|
|
28
|
-
|
|
29
|
-
if (!lat || !lng || !Number.isFinite(lat) || !Number.isFinite(lng)) {
|
|
30
|
-
logger.warn("Invalid coordinates", { latitude, longitude });
|
|
31
|
-
return { state: null, city: null, fromCache: false };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const key = getCacheKey(lat, lng);
|
|
35
|
-
const cached = stateCache.get(key);
|
|
36
|
-
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
37
|
-
return { state: cached.state, city: cached.city, fromCache: true };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const apiKey = getApiKey();
|
|
41
|
-
if (!apiKey) {
|
|
42
|
-
logger.error("Google Maps API key not configured (set GOOGLE_MAPS_API_KEY or NEXT_PUBLIC_GOOGLE_MAPS_API_KEY)");
|
|
43
|
-
return { state: null, city: null, fromCache: false };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
|
|
48
|
-
url.searchParams.set("latlng", `${lat},${lng}`);
|
|
49
|
-
url.searchParams.set("key", apiKey);
|
|
50
|
-
url.searchParams.set("result_type", "administrative_area_level_1|locality");
|
|
51
|
-
|
|
52
|
-
const res = await fetch(url.toString(), {
|
|
53
|
-
signal: AbortSignal.timeout(5000),
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
if (!res.ok) throw new Error(`Geocoding API returned ${res.status}`);
|
|
57
|
-
|
|
58
|
-
const data = await res.json();
|
|
59
|
-
|
|
60
|
-
if (data.status !== "OK") {
|
|
61
|
-
logger.warn("Geocoding API non-OK status", { status: data.status, lat, lng });
|
|
62
|
-
return { state: null, city: null, fromCache: false };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
let rawState = null;
|
|
66
|
-
let city = null;
|
|
67
|
-
|
|
68
|
-
for (const result of (data.results || [])) {
|
|
69
|
-
for (const component of (result.address_components || [])) {
|
|
70
|
-
if (component.types.includes("administrative_area_level_1") && !rawState) {
|
|
71
|
-
rawState = component.long_name;
|
|
72
|
-
}
|
|
73
|
-
if (component.types.includes("locality") && !city) {
|
|
74
|
-
city = component.long_name;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (rawState && city) break;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const state = normalizeStateName(rawState);
|
|
81
|
-
|
|
82
|
-
if (state) {
|
|
83
|
-
logger.info("State detected", { state, city, rawState });
|
|
84
|
-
} else if (rawState) {
|
|
85
|
-
logger.warn("State not recognized as Indian state", { rawState });
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
stateCache.set(key, { state, city, rawState, timestamp: Date.now() });
|
|
89
|
-
return { state, city, fromCache: false };
|
|
90
|
-
} catch (error) {
|
|
91
|
-
logger.error("State detection failed", { error: error.message, lat, lng });
|
|
92
|
-
return { state: null, city: null, fromCache: false };
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export async function isUserInTargetStates(latitude, longitude, targetStates) {
|
|
97
|
-
if (!Array.isArray(targetStates) || targetStates.length === 0) return false;
|
|
98
|
-
const { state } = await detectStateFromCoordinates(latitude, longitude);
|
|
99
|
-
if (!state) return false;
|
|
100
|
-
const normalized = targetStates.map(normalizeStateName).filter(Boolean);
|
|
101
|
-
const match = normalized.includes(state);
|
|
102
|
-
logger.debug("State targeting", { state, targetStates: normalized, match });
|
|
103
|
-
return match;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function clearStateCache() {
|
|
107
|
-
stateCache.clear();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function getStateCacheStats() {
|
|
111
|
-
return { keys: stateCache.size };
|
|
112
|
-
}
|