@el7ven/cookie-kit 0.3.1 → 0.3.2
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 +21 -1
- package/src/core/AnalyticsManager.js +400 -0
- package/src/core/ConsentManager.js +710 -0
- package/src/core/ConsentMode.js +109 -0
- package/src/core/FocusTrap.js +130 -0
- package/src/core/GeoDetector.js +144 -0
- package/src/core/ScriptLoader.js +229 -0
- package/src/core/StorageAdapter.js +179 -0
- package/src/geo/GeoDetector.js +536 -0
- package/src/geo/RegionRules.js +126 -0
- package/src/geo/index.js +16 -0
- package/src/index.js +55 -17
- package/src/locales/en.js +54 -0
- package/src/locales/index.js +20 -0
- package/src/locales/ro.js +54 -0
- package/src/plugins/CMPPlugin.js +187 -0
- package/src/plugins/PluginManager.js +234 -0
- package/src/plugins/index.js +7 -0
- package/src/providers/GoogleConsentModeProvider.js +278 -0
- package/src/providers/index.js +6 -0
- package/src/rewriting/ScriptRewriter.js +278 -0
- package/src/rewriting/index.js +6 -0
- package/src/scripts/ScriptLoader.js +310 -0
- package/src/scripts/ScriptManager.js +278 -0
- package/src/scripts/ScriptRegistry.js +175 -0
- package/src/scripts/ScriptScanner.js +178 -0
- package/src/scripts/index.js +9 -0
- package/src/trackers/TrackerDetector.js +488 -0
- package/src/trackers/TrackerPatterns.js +307 -0
- package/src/trackers/TrackerRegistry.js +172 -0
- package/src/trackers/index.js +15 -0
- package/src/utils/cookies.js +37 -0
- package/src/utils/dom.js +58 -0
- package/src/utils/helpers.js +89 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe Storage Adapter — handles localStorage failures (Safari private mode, quota exceeded)
|
|
3
|
+
* @module StorageAdapter
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class StorageAdapter {
|
|
7
|
+
constructor(config = {}) {
|
|
8
|
+
this.storageType = config.storageType || 'localStorage'
|
|
9
|
+
this.storageKey = config.storageKey || 'cookie_consent'
|
|
10
|
+
this.cookieExpireDays = config.consentExpireDays || 365
|
|
11
|
+
this._testStorage()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Test if storage is available (Safari private mode blocks localStorage)
|
|
16
|
+
*/
|
|
17
|
+
_testStorage() {
|
|
18
|
+
this.isLocalStorageAvailable = false
|
|
19
|
+
this.isCookieAvailable = false
|
|
20
|
+
|
|
21
|
+
// Test localStorage
|
|
22
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
23
|
+
try {
|
|
24
|
+
const testKey = '__storage_test__'
|
|
25
|
+
localStorage.setItem(testKey, 'test')
|
|
26
|
+
localStorage.removeItem(testKey)
|
|
27
|
+
this.isLocalStorageAvailable = true
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.warn('[StorageAdapter] localStorage not available:', e.message)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Test cookies
|
|
34
|
+
if (typeof document !== 'undefined') {
|
|
35
|
+
try {
|
|
36
|
+
document.cookie = '__cookie_test__=test; path=/; SameSite=Lax'
|
|
37
|
+
this.isCookieAvailable = document.cookie.includes('__cookie_test__')
|
|
38
|
+
document.cookie = '__cookie_test__=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.warn('[StorageAdapter] Cookies not available:', e.message)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get data from storage
|
|
47
|
+
*/
|
|
48
|
+
get(key = null) {
|
|
49
|
+
const storageKey = key || this.storageKey
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Try localStorage first
|
|
53
|
+
if (this.storageType === 'localStorage' || this.storageType === 'both') {
|
|
54
|
+
if (this.isLocalStorageAvailable) {
|
|
55
|
+
const value = localStorage.getItem(storageKey)
|
|
56
|
+
if (value) return value
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fallback to cookies
|
|
61
|
+
if (this.storageType === 'cookie' || this.storageType === 'both') {
|
|
62
|
+
if (this.isCookieAvailable) {
|
|
63
|
+
return this._readCookie(storageKey)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return null
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.error('[StorageAdapter] Error reading storage:', e)
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Set data to storage
|
|
76
|
+
*/
|
|
77
|
+
set(key, value) {
|
|
78
|
+
const storageKey = typeof key === 'string' ? key : this.storageKey
|
|
79
|
+
const data = typeof key === 'string' ? value : key
|
|
80
|
+
|
|
81
|
+
// Validate data before saving
|
|
82
|
+
if (data === null || data === undefined) {
|
|
83
|
+
console.warn('[StorageAdapter] Attempted to save null/undefined data, skipping')
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof data === 'string' && (data === 'null' || data === 'undefined')) {
|
|
88
|
+
console.warn('[StorageAdapter] Attempted to save literal "null"/"undefined" string, skipping')
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// Try localStorage
|
|
94
|
+
if (this.storageType === 'localStorage' || this.storageType === 'both') {
|
|
95
|
+
if (this.isLocalStorageAvailable) {
|
|
96
|
+
try {
|
|
97
|
+
localStorage.setItem(storageKey, data)
|
|
98
|
+
} catch (e) {
|
|
99
|
+
// Quota exceeded — try to clear old data
|
|
100
|
+
if (e.name === 'QuotaExceededError') {
|
|
101
|
+
console.warn('[StorageAdapter] localStorage quota exceeded, falling back to cookies')
|
|
102
|
+
this.isLocalStorageAvailable = false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fallback to cookies
|
|
109
|
+
if (this.storageType === 'cookie' || this.storageType === 'both') {
|
|
110
|
+
if (this.isCookieAvailable) {
|
|
111
|
+
this._writeCookie(storageKey, data, this.cookieExpireDays)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return true
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.error('[StorageAdapter] Error writing storage:', e)
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove data from storage
|
|
124
|
+
*/
|
|
125
|
+
remove(key = null) {
|
|
126
|
+
const storageKey = key || this.storageKey
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// Remove from localStorage
|
|
130
|
+
if (this.storageType === 'localStorage' || this.storageType === 'both') {
|
|
131
|
+
if (this.isLocalStorageAvailable) {
|
|
132
|
+
localStorage.removeItem(storageKey)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Remove from cookies
|
|
137
|
+
if (this.storageType === 'cookie' || this.storageType === 'both') {
|
|
138
|
+
if (this.isCookieAvailable) {
|
|
139
|
+
this._writeCookie(storageKey, '', -1)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return true
|
|
144
|
+
} catch (e) {
|
|
145
|
+
console.error('[StorageAdapter] Error removing storage:', e)
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check if storage is available
|
|
152
|
+
*/
|
|
153
|
+
isAvailable() {
|
|
154
|
+
return this.isLocalStorageAvailable || this.isCookieAvailable
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get storage type being used
|
|
159
|
+
*/
|
|
160
|
+
getActiveStorageType() {
|
|
161
|
+
if (this.isLocalStorageAvailable) return 'localStorage'
|
|
162
|
+
if (this.isCookieAvailable) return 'cookie'
|
|
163
|
+
return 'none'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Cookie helpers ---
|
|
167
|
+
|
|
168
|
+
_readCookie(name) {
|
|
169
|
+
if (typeof document === 'undefined') return null
|
|
170
|
+
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'))
|
|
171
|
+
return match ? decodeURIComponent(match[2]) : null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_writeCookie(name, value, days) {
|
|
175
|
+
if (typeof document === 'undefined') return
|
|
176
|
+
const expires = new Date(Date.now() + days * 864e5).toUTCString()
|
|
177
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GeoDetector — detect user's geographic region
|
|
3
|
+
* @module GeoDetector
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getRegion, requiresConsentBanner } from './RegionRules.js'
|
|
7
|
+
|
|
8
|
+
export class GeoDetector {
|
|
9
|
+
constructor(config = {}) {
|
|
10
|
+
this.config = {
|
|
11
|
+
mode: 'auto', // 'auto' | 'eu-only' | 'always' | 'never'
|
|
12
|
+
|
|
13
|
+
// Detection methods (in order of preference)
|
|
14
|
+
useEdgeHeaders: true, // CF-IPCountry, X-Vercel-IP-Country, etc
|
|
15
|
+
useIPAPI: true, // ipapi.co, ipinfo.io (requires API call)
|
|
16
|
+
useTimezoneFallback: true, // Browser timezone (approximate)
|
|
17
|
+
|
|
18
|
+
// IP API settings
|
|
19
|
+
ipApiUrl: 'https://ipinfo.io/json',
|
|
20
|
+
ipApiTimeout: 3000,
|
|
21
|
+
ipApiToken: null, // Add an API key to increase limits
|
|
22
|
+
|
|
23
|
+
// Backup IP APIs
|
|
24
|
+
backupIpApiUrl: 'https://ipinfo.io/json',
|
|
25
|
+
freeGeoIpUrl: 'https://freegeoip.app/json/',
|
|
26
|
+
ipApiComUrl: 'https://ip-api.com/json/',
|
|
27
|
+
|
|
28
|
+
// Cache settings
|
|
29
|
+
cacheRegion: true,
|
|
30
|
+
cacheKey: 'cmp_user_region',
|
|
31
|
+
cacheDuration: 24 * 60 * 60 * 1000, // 24h for production
|
|
32
|
+
|
|
33
|
+
debug: false,
|
|
34
|
+
...config
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.detectedRegion = null
|
|
38
|
+
this.detectionMethod = null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect user's region
|
|
43
|
+
* @returns {Promise<string>} Region code (EU, UK, US_CA, ROW, etc)
|
|
44
|
+
*/
|
|
45
|
+
async detect() {
|
|
46
|
+
this._debug('Starting geo detection...')
|
|
47
|
+
|
|
48
|
+
// Check cache first
|
|
49
|
+
if (this.config.cacheRegion) {
|
|
50
|
+
const cached = this._getCachedRegion()
|
|
51
|
+
if (cached) {
|
|
52
|
+
this._debug('Using cached region:', cached)
|
|
53
|
+
this.detectedRegion = cached.region
|
|
54
|
+
this.detectionMethod = 'cache'
|
|
55
|
+
return cached.region
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let countryCode = null
|
|
60
|
+
let stateCode = null
|
|
61
|
+
|
|
62
|
+
// 1. Try edge headers (fastest, most accurate)
|
|
63
|
+
if (this.config.useEdgeHeaders) {
|
|
64
|
+
const edgeResult = this._detectFromEdgeHeaders()
|
|
65
|
+
if (edgeResult) {
|
|
66
|
+
countryCode = edgeResult.country
|
|
67
|
+
stateCode = edgeResult.state
|
|
68
|
+
this.detectionMethod = 'edge-headers'
|
|
69
|
+
this._debug('Detected from edge headers:', countryCode)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. Try IP API (requires network request)
|
|
74
|
+
if (!countryCode && this.config.useIPAPI) {
|
|
75
|
+
// Try primary API (ipinfo.io)
|
|
76
|
+
try {
|
|
77
|
+
const ipResult = await this._detectFromIPAPI()
|
|
78
|
+
if (ipResult) {
|
|
79
|
+
countryCode = ipResult.country
|
|
80
|
+
stateCode = ipResult.state
|
|
81
|
+
this.detectionMethod = 'ip-api'
|
|
82
|
+
this._debug('Detected from IP API:', countryCode)
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
this._debug('Primary IP API failed:', error)
|
|
86
|
+
|
|
87
|
+
// Try freegeoip.app
|
|
88
|
+
try {
|
|
89
|
+
const freeResult = await this._detectFromFreeGeoIp()
|
|
90
|
+
if (freeResult) {
|
|
91
|
+
countryCode = freeResult.country
|
|
92
|
+
stateCode = freeResult.state
|
|
93
|
+
this.detectionMethod = 'free-geo-ip'
|
|
94
|
+
this._debug('Detected from freegeoip.app:', countryCode)
|
|
95
|
+
}
|
|
96
|
+
} catch (freeError) {
|
|
97
|
+
this._debug('freegeoip.app failed:', freeError)
|
|
98
|
+
|
|
99
|
+
// Try ip-api.com
|
|
100
|
+
try {
|
|
101
|
+
const comResult = await this._detectFromIpApiCom()
|
|
102
|
+
if (comResult) {
|
|
103
|
+
countryCode = comResult.country
|
|
104
|
+
stateCode = comResult.state
|
|
105
|
+
this.detectionMethod = 'ip-api-com'
|
|
106
|
+
this._debug('Detected from ip-api.com:', countryCode)
|
|
107
|
+
}
|
|
108
|
+
} catch (comError) {
|
|
109
|
+
this._debug('ip-api.com also failed:', comError)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 3. Fallback to timezone (approximate)
|
|
116
|
+
if (!countryCode && this.config.useTimezoneFallback) {
|
|
117
|
+
countryCode = this._detectFromTimezone()
|
|
118
|
+
this.detectionMethod = 'timezone-fallback'
|
|
119
|
+
this._debug('Detected from timezone (approximate):', countryCode)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Get region from country code
|
|
123
|
+
const region = countryCode ? getRegion(countryCode, stateCode) : 'ROW'
|
|
124
|
+
this.detectedRegion = region
|
|
125
|
+
|
|
126
|
+
this._debug(`Country: ${countryCode}, State: ${stateCode}, Region: ${region}`)
|
|
127
|
+
|
|
128
|
+
// Cache result
|
|
129
|
+
if (this.config.cacheRegion) {
|
|
130
|
+
this._cacheRegion(region)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this._debug(`Region detected: ${region} (method: ${this.detectionMethod})`)
|
|
134
|
+
return region
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check if consent banner should be shown
|
|
139
|
+
* @returns {Promise<boolean>}
|
|
140
|
+
*/
|
|
141
|
+
async shouldShowBanner() {
|
|
142
|
+
const mode = this.config.mode
|
|
143
|
+
|
|
144
|
+
// Mode overrides
|
|
145
|
+
if (mode === 'always') return true
|
|
146
|
+
if (mode === 'never') return false
|
|
147
|
+
|
|
148
|
+
// Detect region
|
|
149
|
+
const region = await this.detect()
|
|
150
|
+
|
|
151
|
+
if (mode === 'eu-only') {
|
|
152
|
+
// Show only for EU/EEA/UK/CH
|
|
153
|
+
return ['EU', 'EEA', 'UK', 'CH'].includes(region)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Auto mode: show if region requires consent
|
|
157
|
+
return requiresConsentBanner(region)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get detected region (without re-detecting)
|
|
162
|
+
* @returns {string|null}
|
|
163
|
+
*/
|
|
164
|
+
getRegion() {
|
|
165
|
+
return this.detectedRegion
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get detection method used
|
|
170
|
+
* @returns {string|null}
|
|
171
|
+
*/
|
|
172
|
+
getDetectionMethod() {
|
|
173
|
+
return this.detectionMethod
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Clear cached region
|
|
178
|
+
*/
|
|
179
|
+
clearCache() {
|
|
180
|
+
if (typeof localStorage !== 'undefined') {
|
|
181
|
+
localStorage.removeItem(this.config.cacheKey)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Force refresh region (bypass cache)
|
|
187
|
+
* @returns {Promise<string>}
|
|
188
|
+
*/
|
|
189
|
+
async forceRefresh() {
|
|
190
|
+
this.clearCache()
|
|
191
|
+
this.detectedRegion = null
|
|
192
|
+
this.detectionMethod = null
|
|
193
|
+
return this.detect()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Detection Methods ---
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Detect from edge/CDN headers
|
|
200
|
+
* @private
|
|
201
|
+
* @returns {Object|null} { country, state }
|
|
202
|
+
*/
|
|
203
|
+
_detectFromEdgeHeaders() {
|
|
204
|
+
// This only works server-side or with edge functions
|
|
205
|
+
// In browser, we can't access these headers directly
|
|
206
|
+
// This is a placeholder for SSR/Edge implementation
|
|
207
|
+
|
|
208
|
+
if (typeof window === 'undefined') {
|
|
209
|
+
// Server-side: check request headers
|
|
210
|
+
// Example: req.headers['cf-ipcountry']
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Browser: not available
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Detect from IP API
|
|
220
|
+
* @private
|
|
221
|
+
* @returns {Promise<Object|null>} { country, state }
|
|
222
|
+
*/
|
|
223
|
+
async _detectFromIPAPI() {
|
|
224
|
+
if (typeof fetch === 'undefined') {
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const controller = new AbortController()
|
|
230
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.ipApiTimeout)
|
|
231
|
+
|
|
232
|
+
const url = this.config.ipApiToken
|
|
233
|
+
? `${this.config.ipApiUrl}?token=${this.config.ipApiToken}`
|
|
234
|
+
: this.config.ipApiUrl
|
|
235
|
+
|
|
236
|
+
const response = await fetch(url, {
|
|
237
|
+
signal: controller.signal
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
clearTimeout(timeoutId)
|
|
241
|
+
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
throw new Error(`IP API returned ${response.status}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const data = await response.json()
|
|
247
|
+
|
|
248
|
+
this._debug('IP API response:', data)
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
country: data.country_code || data.country,
|
|
252
|
+
state: data.region_code || this._mapUSState(data.region) // Map full state name to state code
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
this._debug('IP API error:', error.message)
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Map US state names to codes
|
|
262
|
+
* @private
|
|
263
|
+
*/
|
|
264
|
+
_mapUSState(stateName) {
|
|
265
|
+
if (!stateName) return null
|
|
266
|
+
|
|
267
|
+
const stateMap = {
|
|
268
|
+
'Alabama': 'AL', 'Alaska': 'AK', 'Arizona': 'AZ', 'Arkansas': 'AR',
|
|
269
|
+
'California': 'CA', 'Colorado': 'CO', 'Connecticut': 'CT', 'Delaware': 'DE',
|
|
270
|
+
'Florida': 'FL', 'Georgia': 'GA', 'Hawaii': 'HI', 'Idaho': 'ID',
|
|
271
|
+
'Illinois': 'IL', 'Indiana': 'IN', 'Iowa': 'IA', 'Kansas': 'KS',
|
|
272
|
+
'Kentucky': 'KY', 'Louisiana': 'LA', 'Maine': 'ME', 'Maryland': 'MD',
|
|
273
|
+
'Massachusetts': 'MA', 'Michigan': 'MI', 'Minnesota': 'MN', 'Mississippi': 'MS',
|
|
274
|
+
'Missouri': 'MO', 'Montana': 'MT', 'Nebraska': 'NE', 'Nevada': 'NV',
|
|
275
|
+
'New Hampshire': 'NH', 'New Jersey': 'NJ', 'New Mexico': 'NM', 'New York': 'NY',
|
|
276
|
+
'North Carolina': 'NC', 'North Dakota': 'ND', 'Ohio': 'OH', 'Oklahoma': 'OK',
|
|
277
|
+
'Oregon': 'OR', 'Pennsylvania': 'PA', 'Rhode Island': 'RI', 'South Carolina': 'SC',
|
|
278
|
+
'South Dakota': 'SD', 'Tennessee': 'TN', 'Texas': 'TX', 'Utah': 'UT',
|
|
279
|
+
'Vermont': 'VT', 'Virginia': 'VA', 'Washington': 'WA', 'West Virginia': 'WV',
|
|
280
|
+
'Wisconsin': 'WI', 'Wyoming': 'WY'
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return stateMap[stateName] || null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Detect from freegeoip.app
|
|
288
|
+
* @private
|
|
289
|
+
* @returns {Promise<Object|null>} { country, state }
|
|
290
|
+
*/
|
|
291
|
+
async _detectFromFreeGeoIp() {
|
|
292
|
+
if (typeof fetch === 'undefined') {
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const controller = new AbortController()
|
|
298
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.ipApiTimeout)
|
|
299
|
+
|
|
300
|
+
const response = await fetch(this.config.freeGeoIpUrl, {
|
|
301
|
+
signal: controller.signal
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
clearTimeout(timeoutId)
|
|
305
|
+
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
throw new Error(`freegeoip.app returned ${response.status}`)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const data = await response.json()
|
|
311
|
+
|
|
312
|
+
this._debug('freegeoip.app response:', data)
|
|
313
|
+
|
|
314
|
+
// freegeoip.app format: {country_code: "US", region_name: "Florida"}
|
|
315
|
+
return {
|
|
316
|
+
country: data.country_code,
|
|
317
|
+
state: this._mapUSState(data.region_name)
|
|
318
|
+
}
|
|
319
|
+
} catch (error) {
|
|
320
|
+
this._debug('freegeoip.app error:', error.message)
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Detect from ip-api.com
|
|
327
|
+
* @private
|
|
328
|
+
* @returns {Promise<Object|null>} { country, state }
|
|
329
|
+
*/
|
|
330
|
+
async _detectFromIpApiCom() {
|
|
331
|
+
if (typeof fetch === 'undefined') {
|
|
332
|
+
return null
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const controller = new AbortController()
|
|
337
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.ipApiTimeout)
|
|
338
|
+
|
|
339
|
+
const response = await fetch(this.config.ipApiComUrl, {
|
|
340
|
+
signal: controller.signal
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
clearTimeout(timeoutId)
|
|
344
|
+
|
|
345
|
+
if (!response.ok) {
|
|
346
|
+
throw new Error(`ip-api.com returned ${response.status}`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const data = await response.json()
|
|
350
|
+
|
|
351
|
+
this._debug('ip-api.com response:', data)
|
|
352
|
+
|
|
353
|
+
// ip-api.com format: {countryCode: "US", regionName: "Florida"}
|
|
354
|
+
return {
|
|
355
|
+
country: data.countryCode,
|
|
356
|
+
state: this._mapUSState(data.regionName)
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
this._debug('ip-api.com error:', error.message)
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Detect from backup IP API (ipinfo.io)
|
|
366
|
+
* @private
|
|
367
|
+
* @returns {Promise<Object|null>} { country, state }
|
|
368
|
+
*/
|
|
369
|
+
async _detectFromBackupIPAPI() {
|
|
370
|
+
if (typeof fetch === 'undefined') {
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const controller = new AbortController()
|
|
376
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.ipApiTimeout)
|
|
377
|
+
|
|
378
|
+
const response = await fetch(this.config.backupIpApiUrl, {
|
|
379
|
+
signal: controller.signal
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
clearTimeout(timeoutId)
|
|
383
|
+
|
|
384
|
+
if (!response.ok) {
|
|
385
|
+
throw new Error(`Backup IP API returned ${response.status}`)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const data = await response.json()
|
|
389
|
+
|
|
390
|
+
this._debug('Backup IP API response:', data)
|
|
391
|
+
|
|
392
|
+
// ipinfo.io format: { country: "US", region: "Florida" }
|
|
393
|
+
return {
|
|
394
|
+
country: data.country,
|
|
395
|
+
state: this._mapUSState(data.region) // Map full state name to state code
|
|
396
|
+
}
|
|
397
|
+
} catch (error) {
|
|
398
|
+
this._debug('Backup IP API error:', error.message)
|
|
399
|
+
return null
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Detect from browser timezone (approximate)
|
|
405
|
+
* @private
|
|
406
|
+
* @returns {string|null} Country code
|
|
407
|
+
*/
|
|
408
|
+
_detectFromTimezone() {
|
|
409
|
+
if (typeof Intl === 'undefined') {
|
|
410
|
+
return null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
415
|
+
|
|
416
|
+
// Approximate mapping: timezone → country
|
|
417
|
+
const timezoneMap = {
|
|
418
|
+
// Europe
|
|
419
|
+
'Europe/London': 'GB',
|
|
420
|
+
'Europe/Dublin': 'IE',
|
|
421
|
+
'Europe/Paris': 'FR',
|
|
422
|
+
'Europe/Berlin': 'DE',
|
|
423
|
+
'Europe/Rome': 'IT',
|
|
424
|
+
'Europe/Madrid': 'ES',
|
|
425
|
+
'Europe/Amsterdam': 'NL',
|
|
426
|
+
'Europe/Brussels': 'BE',
|
|
427
|
+
'Europe/Vienna': 'AT',
|
|
428
|
+
'Europe/Stockholm': 'SE',
|
|
429
|
+
'Europe/Copenhagen': 'DK',
|
|
430
|
+
'Europe/Helsinki': 'FI',
|
|
431
|
+
'Europe/Oslo': 'NO',
|
|
432
|
+
'Europe/Warsaw': 'PL',
|
|
433
|
+
'Europe/Prague': 'CZ',
|
|
434
|
+
'Europe/Budapest': 'HU',
|
|
435
|
+
'Europe/Bucharest': 'RO',
|
|
436
|
+
'Europe/Athens': 'GR',
|
|
437
|
+
'Europe/Lisbon': 'PT',
|
|
438
|
+
'Europe/Zurich': 'CH',
|
|
439
|
+
|
|
440
|
+
// Americas
|
|
441
|
+
'America/New_York': 'US',
|
|
442
|
+
'America/Chicago': 'US',
|
|
443
|
+
'America/Denver': 'US',
|
|
444
|
+
'America/Los_Angeles': 'US',
|
|
445
|
+
'America/Sao_Paulo': 'BR',
|
|
446
|
+
'America/Mexico_City': 'MX',
|
|
447
|
+
'America/Toronto': 'CA',
|
|
448
|
+
|
|
449
|
+
// Asia
|
|
450
|
+
'Asia/Tokyo': 'JP',
|
|
451
|
+
'Asia/Shanghai': 'CN',
|
|
452
|
+
'Asia/Hong_Kong': 'HK',
|
|
453
|
+
'Asia/Singapore': 'SG',
|
|
454
|
+
'Asia/Dubai': 'AE',
|
|
455
|
+
'Asia/Kolkata': 'IN',
|
|
456
|
+
|
|
457
|
+
// Australia
|
|
458
|
+
'Australia/Sydney': 'AU',
|
|
459
|
+
'Australia/Melbourne': 'AU'
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const countryCode = timezoneMap[timezone]
|
|
463
|
+
|
|
464
|
+
if (countryCode) {
|
|
465
|
+
return countryCode
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Fallback: check timezone prefix
|
|
469
|
+
if (timezone.startsWith('Europe/')) {
|
|
470
|
+
// Return a representative EU country instead of 'EU'
|
|
471
|
+
// This ensures getRegion() works correctly
|
|
472
|
+
return 'DE' // Germany as default EU country
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return null
|
|
476
|
+
} catch (error) {
|
|
477
|
+
this._debug('Timezone detection error:', error)
|
|
478
|
+
return null
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get cached region
|
|
484
|
+
* @private
|
|
485
|
+
*/
|
|
486
|
+
_getCachedRegion() {
|
|
487
|
+
if (typeof localStorage === 'undefined') {
|
|
488
|
+
return null
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
const cached = localStorage.getItem(this.config.cacheKey)
|
|
493
|
+
if (!cached) return null
|
|
494
|
+
|
|
495
|
+
const data = JSON.parse(cached)
|
|
496
|
+
const age = Date.now() - data.timestamp
|
|
497
|
+
|
|
498
|
+
if (age > this.config.cacheDuration) {
|
|
499
|
+
// Cache expired
|
|
500
|
+
localStorage.removeItem(this.config.cacheKey)
|
|
501
|
+
return null
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return data
|
|
505
|
+
} catch (error) {
|
|
506
|
+
return null
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Cache region
|
|
512
|
+
* @private
|
|
513
|
+
*/
|
|
514
|
+
_cacheRegion(region) {
|
|
515
|
+
if (typeof localStorage === 'undefined') {
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
const data = {
|
|
521
|
+
region,
|
|
522
|
+
timestamp: Date.now(),
|
|
523
|
+
method: this.detectionMethod
|
|
524
|
+
}
|
|
525
|
+
localStorage.setItem(this.config.cacheKey, JSON.stringify(data))
|
|
526
|
+
} catch (error) {
|
|
527
|
+
this._debug('Failed to cache region:', error)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
_debug(...args) {
|
|
532
|
+
if (this.config.debug) {
|
|
533
|
+
console.log('[GeoDetector]', ...args)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|