1class IntercomManager { 2 constructor(config) { 3 this.APP_ID = config.appId; 4 this.API_BASE = config.apiBase; 5 this.AUTH_ENDPOINT = config.authEndpoint; 6 7 this.MAX_RETRIES = config.maxRetries ?? 50; 8 this.RETRY_INTERVAL = config.retryInterval ?? 100; 9 this.REQUEST_TIMEOUT = config.requestTimeout ?? 6000; 10 this.SESSION_DURATION = config.sessionDuration ?? 3600000; // 1h 11 12 this.IDENTITY_COOKIE = config.identityCookieName ?? 'ic_identity_v1'; 13 } 14 15 /* ------------------ CARGA DEL WIDGET ------------------ */ 16 async loadIntercomScript() { 17 return new Promise((resolve, reject) => { 18 // si ya existe el tag script del widget, no lo duplicamos 19 const src = `https://widget.intercom.io/widget/${this.APP_ID}`; 20 if (document.querySelector(`script[src="${src}"]`)) return resolve(); 21 22 const script = document.createElement('script'); 23 script.type = 'text/javascript'; 24 script.async = true; 25 script.src = src; 26 script.onload = () => resolve(); 27 script.onerror = () => reject(new Error('Failed to load Intercom script')); 28 document.head.appendChild(script); 29 }); 30 } 31 32 async waitForIntercom() { 33 return new Promise((resolve, reject) => { 34 let attempts = 0; 35 const check = () => { 36 if (typeof window.Intercom === 'function') return resolve(); 37 attempts++; 38 if (attempts >= this.MAX_RETRIES) return reject(new Error('Intercom failed to initialize')); 39 setTimeout(check, this.RETRY_INTERVAL); 40 }; 41 check(); 42 }); 43 } 44 45 /* ------------------ AUTH DEL USUARIO ------------------ */ 46 async fetchUserAuth() { 47 const controller = new AbortController(); 48 const timeoutId = setTimeout(() => controller.abort(), this.REQUEST_TIMEOUT); 49 50 try { 51 const response = await fetch(this.AUTH_ENDPOINT, { 52 method: 'GET', 53 headers: { 'Content-Type': 'application/json' }, 54 credentials: 'include', 55 signal: controller.signal 56 }); 57 58 clearTimeout(timeoutId); 59 60 if (!response.ok) { 61 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 62 } 63 64 const data = await response.json(); 65 if (!data?.jsonMC?.resultado) { 66 throw new Error('Authentication failed'); 67 } 68 69 const token = data.jsonMC.respuesta || null; 70 const uidFromJwt = token ? this.extractUserIdFromJwt(token) : null; 71 72 return { 73 isAuthenticated: true, 74 token, 75 userId: uidFromJwt // puede ser null si no viene en el JWT 76 }; 77 78 } catch (error) { 79 clearTimeout(timeoutId); 80 console.warn('User auth failed:', error?.message || error); 81 return { isAuthenticated: false }; 82 } 83 } 84 85 extractUserIdFromJwt(jwt) { 86 try { 87 const parts = jwt.split('.'); 88 if (parts.length < 2) return null; 89 const payload = JSON.parse(this.base64UrlDecode(parts[1])); 90 // Intercom usa normalmente "user_id"; a veces podés querer "sub" 91 return payload.user_id || payload.sub || null; 92 } catch { 93 return null; 94 } 95 } 96 97 base64UrlDecode(str) { 98 // polyfill simple para atob base64url 99 const pad = (s) => s + '==='.slice((s.length + 3) % 4); 100 const b64 = pad(str.replace(/-/g, '+').replace(/_/g, '/')); 101 return decodeURIComponent( 102 [...atob(b64)] 103 .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) 104 .join('') 105 ); 106 } 107 108 /* ------------------ BOOT/SHUTDOWN ------------------ */ 109 bootIntercom(settings, { forceShutdown = false } = {}) { 110 try { 111 if (forceShutdown && window.Intercom) { 112 window.Intercom('shutdown'); 113 } 114 window.Intercom('boot', settings); 115 } catch (error) { 116 console.error('Failed to boot Intercom:', error); 117 throw error; 118 } 119 } 120 121 /* ------------------ FLUJO PRINCIPAL ------------------ */ 122 async initialize() { 123 try { 124 await this.loadIntercomScript(); 125 await this.waitForIntercom(); 126 127 const auth = await this.fetchUserAuth(); 128 const countryData = await this.countryFromIP(); 129 130 let iso = countryData?.iso?this.inCountryList(countryData.iso):"int"; 131 let countryName = this.countryNameFromIso(iso); 132 133 const hostname = window.location.hostname; 134 135 // Atributos People (NO usar prefijo conversation_*) 136 const personAttrs = this.filterNullish({ 137 dw_visit_pais_iso: iso, 138 dw_visit_pais_nombre: countryName, 139 dw_visit_website: hostname, 140 dw_items_carrito: this.getCookie('items_carrito'), 141 dw_utm_medium: this.getCookie('dw_medium'), 142 dw_utm_source: this.getCookie('dw_source') 143 }); 144 145 const base = { 146 app_id: this.APP_ID,
147 api_base: this.API_BASE, 148 session_duration: this.SESSION_DURATION, 149 ...personAttrs 150 }; 151 152 // Identidad deseada para decidir shutdown solo cuando sea necesario 153 const desiredIdentity = auth?.isAuthenticated && auth?.token 154 ? `user:${auth.userId || 'unknown'}` 155 : 'anon'; 156 157 const prevIdentity = this.getCookie(this.IDENTITY_COOKIE); 158 const forceShutdown = this.shouldShutdown(prevIdentity, desiredIdentity); 159 160 const settings = (desiredIdentity.startsWith('user:')) 161 ? { ...base, intercom_user_jwt: auth.token } 162 : base; 163 164 this.bootIntercom(settings, { forceShutdown }); 165 this.setCookie(this.IDENTITY_COOKIE, desiredIdentity, { maxAge: 31536000, path: '/', sameSite: 'Lax' }); 166 167 } catch (error) { 168 console.error('Intercom initialization failed:', error); 169 // Fallback: boot como visitor mÃnimo 170 try { 171 this.bootIntercom({ 172 app_id: this.APP_ID, 173 api_base: this.API_BASE, 174 session_duration: this.SESSION_DURATION 175 }, { forceShutdown: true }); 176 this.setCookie(this.IDENTITY_COOKIE, 'anon', { maxAge: 31536000, path: '/', sameSite: 'Lax' }); 177 } catch (fallbackError) { 178 console.error('Fallback boot also failed:', fallbackError); 179 } 180 } 181 } 182 183 shouldShutdown(prev, desired) { 184 if (!prev) return false; // primera vez, no cortamos 185 if (prev === desired) return false; // misma identidad, conservamos 186 const wasUser = prev.startsWith('user:'); 187 const isUser = desired.startsWith('user:'); 188 // shutdown solo si cambia entre anon <-> user o cambia el userID 189 return (wasUser !== isUser) || (wasUser && isUser && prev !== desired); 190 } 191 192 /* ------------------ HELPERS ------------------ */ 193 safeGlobal(varName) { 194 try { 195 // evita ReferenceError si la variable global no existe 196 return (typeof window[varName] !== 'undefined') ? window[varName] : null; 197 } catch { 198 return null; 199 } 200 } 201 202 filterNullish(obj) { 203 const out = {}; 204 for (const [k, v] of Object.entries(obj)) { 205 if (v !== null && v !== undefined && v !== '') out[k] = v; 206 } 207 return out; 208 } 209 210 getCookie(name) { 211 const nameEq = name + '='; 212 const parts = document.cookie.split(';'); 213 for (let c of parts) { 214 c = c.trim(); 215 if (c.indexOf(nameEq) === 0) return decodeURIComponent(c.substring(nameEq.length)); 216 } 217 return null; 218 } 219 220 setCookie(name, value, { maxAge, path = '/', sameSite = 'Lax' } = {}) { 221 let cookie = `${name}=${encodeURIComponent(value)}; Path=${path}; SameSite=${sameSite}`; 222 if (typeof maxAge === 'number') cookie += `; Max-Age=${maxAge}`; 223 document.cookie = cookie; 224 } 225 226 227 async countryFromIP() { 228 const providers = [ 229 () => this.countryFromCloudflareTrace(), 230 () => this.countryFromIpwhois(), 231 () => this.countryFromCountryIs() 232 ]; 233 234 for (const provider of providers) { 235 try { 236 const result = await provider(); 237 if (result && result.iso) return result; 238 } catch (err) { 239 // proveedor caido o bloqueado, se prueba el siguiente 240 } 241 } 242 243 return { iso: null, name: null, source: 'ip', ok: false, error: 'IP service failed' }; 244 } 245 246 // Cloudflare sirve /cdn-cgi/trace en el propio dominio: mismo origen, gratis y sin limite. 247 async countryFromCloudflareTrace() { 248 const res = await fetch('/cdn-cgi/trace', { signal: AbortSignal.timeout(2500) }); 249 if (!res.ok) throw new Error('cloudflare trace failed'); 250 251 const raw = {}; 252 const text = await res.text(); 253 for (const line of text.trim().split('\n')) { 254 const [key, ...rest] = line.split('='); 255 raw[key] = rest.join('='); 256 } 257 258 const iso = raw.loc && raw.loc !== 'XX' ? raw.loc : null; 259 return { iso, name: iso, source: 'ip', raw }; 260 } 261 262 async countryFromIpwhois() { 263 const res = await fetch('https://ipwho.is/', { signal: AbortSignal.timeout(2500) }); 264 if (!res.ok) throw new Error('ipwho.is failed'); 265 266 const data = await res.json(); 267 if (data && data.success === false) throw new Error(data.message || 'ipwho.is error'); 268 269 return { iso: data.country_code || null, name: data.country || null, source: 'ip', raw: data }; 270 } 271 272 async countryFromCountryIs() { 273 const res = await fetch('https://api.country.is/', { signal: AbortSignal.timeout(2500) }); 274 if (!res.ok) throw new Error('country.is failed'); 275 276 const data = await res.json(); 277 return { iso: data.country || null, name: data.country || null, source: 'ip', raw: data }; 278 } 279 280 inCountryList(iso) { 281 if (typeof iso !== 'string' || !iso) return "int"; 282 const isoLower = iso.toLowerCase(); 283 const list = ["ar", "bo", "br", "cl", "co", "es", "us", "mx", "pe", "uy", "ve"]; 284 if (list.includes(isoLower)) { 285 return isoLower; 286 } 287 return "int"; 288 } 289 inCountryNameList(iso,name) { 290 if (typeof iso !== 'string' || !iso) return "Internacional"; 291 const isoLower = iso.toLowerCase(); 292 const list = ["ar", "bo", "br", "cl", "co", "es", "us", "mx", "pe", "uy", "ve"]; 293 if (list.includes(isoLower)) { 294 return name; 295 } 296 return "Internacional"; 297 } 298
299 // Nombres locales: no dependemos de que el proveedor de IP devuelva el nombre del pais. 300 countryNameFromIso(iso) { 301 const names = { 302 ar: "Argentina", bo: "Bolivia", br: "Brazil", cl: "Chile", 303 co: "Colombia", es: "Spain", us: "United States", mx: "Mexico", 304 pe: "Peru", uy: "Uruguay", ve: "Venezuela" 305 }; 306 if (typeof iso !== 'string' || !iso) return "Internacional"; 307 return names[iso.toLowerCase()] || "Internacional"; 308 } 309}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.