@eshal-bot/chat-widget 0.1.47 → 0.1.49

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.
@@ -8454,9 +8454,9 @@
8454
8454
  }
8455
8455
  };
8456
8456
 
8457
- /**
8458
- * Initializes the PCM audio player worklet that streams inline PCM chunks
8459
- * coming from the ADK websocket events.
8457
+ /**
8458
+ * Initializes the PCM audio player worklet that streams inline PCM chunks
8459
+ * coming from the ADK websocket events.
8460
8460
  */
8461
8461
  async function startAudioPlayerWorklet() {
8462
8462
  if (typeof window === 'undefined') {
@@ -8480,9 +8480,9 @@
8480
8480
  return [audioPlayerNode, audioContext];
8481
8481
  }
8482
8482
 
8483
- /**
8484
- * Bootstraps the PCM recorder audio worklet, captures mic audio at 16kHz
8485
- * mono, and forwards buffers through the provided handler.
8483
+ /**
8484
+ * Bootstraps the PCM recorder audio worklet, captures mic audio at 16kHz
8485
+ * mono, and forwards buffers through the provided handler.
8486
8486
  */
8487
8487
  async function startAudioRecorderWorklet(onPcmChunk) {
8488
8488
  if (typeof window === 'undefined') {
@@ -8519,8 +8519,8 @@
8519
8519
  return [audioRecorderNode, audioRecorderContext, micStream];
8520
8520
  }
8521
8521
 
8522
- /**
8523
- * Stops all microphone tracks.
8522
+ /**
8523
+ * Stops all microphone tracks.
8524
8524
  */
8525
8525
  function stopMicrophone(stream) {
8526
8526
  if (!stream) {
@@ -8537,24 +8537,24 @@
8537
8537
  return pcm16Array.buffer;
8538
8538
  }
8539
8539
 
8540
- /**
8541
- * Client-side Voice-Activity gate for the widget's voice (Bidi) session.
8542
- *
8543
- * Ported from packages/agent-sdk/src/voice/audio/vad.ts (`createSpeechGate`),
8544
- * which was itself originally derived from this widget. The gate sits between
8545
- * the mic worklet and the WebSocket: it forwards PCM ONLY inside a confirmed
8546
- * speech turn and fires `onSpeechEnd` when the turn closes, so the caller can
8547
- * tell the backend the utterance is done (an `audio_end` signal).
8548
- *
8549
- * Why the widget needs this: knowledge-api's non-streaming voice path only
8550
- * transcribes a turn when it receives `audio_end` (or its flaky energy-VAD
8551
- * backstop trips). Without a client gate the widget streamed mic audio forever
8552
- * and the backend just buffered it ("Collecting Audio…") without ever
8553
- * transcribing — so voice connected but never replied. The agent-sdk already
8554
- * gates this way; this brings the widget to parity.
8555
- *
8556
- * Framework-free and side-effect-free (only calls the two callbacks) so it can
8557
- * be reasoned about / tested in isolation.
8540
+ /**
8541
+ * Client-side Voice-Activity gate for the widget's voice (Bidi) session.
8542
+ *
8543
+ * Ported from packages/agent-sdk/src/voice/audio/vad.ts (`createSpeechGate`),
8544
+ * which was itself originally derived from this widget. The gate sits between
8545
+ * the mic worklet and the WebSocket: it forwards PCM ONLY inside a confirmed
8546
+ * speech turn and fires `onSpeechEnd` when the turn closes, so the caller can
8547
+ * tell the backend the utterance is done (an `audio_end` signal).
8548
+ *
8549
+ * Why the widget needs this: knowledge-api's non-streaming voice path only
8550
+ * transcribes a turn when it receives `audio_end` (or its flaky energy-VAD
8551
+ * backstop trips). Without a client gate the widget streamed mic audio forever
8552
+ * and the backend just buffered it ("Collecting Audio…") without ever
8553
+ * transcribing — so voice connected but never replied. The agent-sdk already
8554
+ * gates this way; this brings the widget to parity.
8555
+ *
8556
+ * Framework-free and side-effect-free (only calls the two callbacks) so it can
8557
+ * be reasoned about / tested in isolation.
8558
8558
  */
8559
8559
 
8560
8560
  const DEFAULT_SPEECH_GATE_CONFIG = {
@@ -8583,16 +8583,16 @@
8583
8583
  return Math.sqrt(sumSquares / samples.length);
8584
8584
  }
8585
8585
 
8586
- /**
8587
- * Create a stateful speech gate. Returns `{ process, reset, isSpeaking }`.
8588
- * `config.sampleRate` is required; everything else falls back to
8589
- * DEFAULT_SPEECH_GATE_CONFIG.
8590
- *
8591
- * @param {{ sampleRate: number, energyThreshold?: number, speechStartMs?: number, hangoverMs?: number, preRollMs?: number }} config
8592
- * @param {{ onSpeechStart?: () => void, onVoiceFrame: (pcm: ArrayBuffer) => void, onSpeechEnd: () => void }} callbacks
8593
- * onSpeechStart fires the instant a turn opens — use it for barge-in
8594
- * (stop any assistant TTS still playing). onVoiceFrame emits each voiced
8595
- * PCM frame; onSpeechEnd fires when the turn closes after the hangover.
8586
+ /**
8587
+ * Create a stateful speech gate. Returns `{ process, reset, isSpeaking }`.
8588
+ * `config.sampleRate` is required; everything else falls back to
8589
+ * DEFAULT_SPEECH_GATE_CONFIG.
8590
+ *
8591
+ * @param {{ sampleRate: number, energyThreshold?: number, speechStartMs?: number, hangoverMs?: number, preRollMs?: number }} config
8592
+ * @param {{ onSpeechStart?: () => void, onVoiceFrame: (pcm: ArrayBuffer) => void, onSpeechEnd: () => void }} callbacks
8593
+ * onSpeechStart fires the instant a turn opens — use it for barge-in
8594
+ * (stop any assistant TTS still playing). onVoiceFrame emits each voiced
8595
+ * PCM frame; onSpeechEnd fires when the turn closes after the hangover.
8596
8596
  */
8597
8597
  function createSpeechGate(config, callbacks) {
8598
8598
  var _config$energyThresho, _config$speechStartMs, _config$hangoverMs, _config$preRollMs;
@@ -9288,6 +9288,14 @@
9288
9288
  } = await Promise.resolve().then(function () { return index; });
9289
9289
  const tracer = new UserTrace({
9290
9290
  cache: true,
9291
+ // Capture the visitor's precise location via the browser Geolocation
9292
+ // API (behind the native permission prompt) and reverse-geocode it
9293
+ // into profile.geo, so "Traffic by location" reflects the real city
9294
+ // instead of the coarse/wrong IP-gateway city. Granted → accurate
9295
+ // GPS; denied/dismissed/plain-HTTP → graceful IP fallback. Set
9296
+ // explicitly (not relying on the SDK default) so the behavior is
9297
+ // durable even if @eshal-tracker/sdk changes its default later.
9298
+ geoFromBrowser: true,
9291
9299
  onError: () => {/* swallow — fingerprint is best-effort */}
9292
9300
  });
9293
9301
  const profile = await tracer.init();
@@ -58861,88 +58869,6 @@
58861
58869
  });
58862
58870
  };
58863
58871
 
58864
- const LOGO_MAX_HEIGHT = "3.5rem";
58865
- const LOGO_MAX_HEIGHT_COMPACT = "2.5rem";
58866
- const LOGO_MAX_WIDTH_DEFAULT = "4.5rem";
58867
- const LOGO_MAX_WIDTH_DEFAULT_COMPACT = "3rem";
58868
- const LOGO_MAX_WIDTH_WIDE = "9rem";
58869
- const LOGO_MAX_WIDTH_WIDE_COMPACT = "5.5rem";
58870
- const STATUS_DOT_SIZE = "0.8rem";
58871
- const STATUS_DOT_SIZE_COMPACT = "0.65rem";
58872
- const STATUS_DOT_BORDER_WIDTH = "2px";
58873
- const STATUS_DOT_BORDER_WIDTH_COMPACT = "1.5px";
58874
- const STATUS_DOT_DEFAULT_COLOR = "#34d399";
58875
- const STATUS_DOT_OFFSET = "38%";
58876
- const STATUS_DOT_OFFSET_COMPACT = "22%";
58877
- const LogoImage = _ref => {
58878
- let {
58879
- src,
58880
- showStatusDot = false,
58881
- statusColor = STATUS_DOT_DEFAULT_COLOR,
58882
- size = "default"
58883
- } = _ref;
58884
- const [aspect, setAspect] = reactExports.useState("square");
58885
- const isCompact = size === "compact";
58886
- reactExports.useEffect(() => {
58887
- if (typeof window === "undefined" || !src) {
58888
- return;
58889
- }
58890
- const img = new Image();
58891
- img.src = src;
58892
- img.onload = () => {
58893
- if (img.width > img.height) {
58894
- setAspect("wide");
58895
- } else if (img.height > img.width) {
58896
- setAspect("tall");
58897
- } else {
58898
- setAspect("square");
58899
- }
58900
- };
58901
- }, [src]);
58902
- const containerStyle = {
58903
- position: "relative",
58904
- display: "inline-flex"
58905
- };
58906
- const imageStyle = {
58907
- display: "block",
58908
- objectFit: "cover",
58909
- height: "100%",
58910
- width: "100%",
58911
- // borderRadius:'50%',
58912
- maxHeight: isCompact ? LOGO_MAX_HEIGHT_COMPACT : LOGO_MAX_HEIGHT,
58913
- maxWidth: isCompact ? LOGO_MAX_WIDTH_DEFAULT_COMPACT : LOGO_MAX_WIDTH_DEFAULT
58914
- };
58915
- if (aspect === "wide") {
58916
- imageStyle.maxWidth = isCompact ? LOGO_MAX_WIDTH_WIDE_COMPACT : LOGO_MAX_WIDTH_WIDE;
58917
- }
58918
- if (aspect === "tall") {
58919
- imageStyle.maxHeight = isCompact ? "3rem" : "4rem";
58920
- imageStyle.maxWidth = isCompact ? "3rem" : "4rem";
58921
- }
58922
- return /*#__PURE__*/jsxRuntimeExports.jsxs("div", {
58923
- style: containerStyle,
58924
- children: [/*#__PURE__*/jsxRuntimeExports.jsx("img", {
58925
- src: src,
58926
- alt: "Logo",
58927
- style: imageStyle,
58928
- loading: "lazy"
58929
- }), showStatusDot && /*#__PURE__*/jsxRuntimeExports.jsx("span", {
58930
- style: {
58931
- position: "absolute",
58932
- bottom: 0,
58933
- right: 0,
58934
- width: isCompact ? STATUS_DOT_SIZE_COMPACT : STATUS_DOT_SIZE,
58935
- height: isCompact ? STATUS_DOT_SIZE_COMPACT : STATUS_DOT_SIZE,
58936
- borderRadius: "9999px",
58937
- backgroundColor: statusColor,
58938
- border: "".concat(isCompact ? STATUS_DOT_BORDER_WIDTH_COMPACT : STATUS_DOT_BORDER_WIDTH, " solid #ffffff"),
58939
- transform: "translate(".concat(isCompact ? STATUS_DOT_OFFSET_COMPACT : STATUS_DOT_OFFSET, ", ").concat(isCompact ? STATUS_DOT_OFFSET_COMPACT : STATUS_DOT_OFFSET, ")"),
58940
- pointerEvents: "none"
58941
- }
58942
- })]
58943
- });
58944
- };
58945
-
58946
58872
  const ChatHeader = _ref => {
58947
58873
  let {
58948
58874
  conciergeName,
@@ -59001,8 +58927,16 @@
59001
58927
  className: "absolute inset-0 bg-gradient-to-br from-white/10 to-transparent pointer-events-none"
59002
58928
  }), companyLogo && /*#__PURE__*/jsxRuntimeExports.jsx("div", {
59003
58929
  className: "w-[48px] h-[48px] rounded-full overflow-hidden flex items-center justify-center bg-white/10",
59004
- children: /*#__PURE__*/jsxRuntimeExports.jsx(LogoImage, {
59005
- src: companyLogo
58930
+ children: /*#__PURE__*/jsxRuntimeExports.jsx("img", {
58931
+ src: companyLogo,
58932
+ alt: "Logo",
58933
+ style: {
58934
+ display: "block",
58935
+ width: "100%",
58936
+ height: "100%",
58937
+ objectFit: "cover"
58938
+ },
58939
+ loading: "lazy"
59006
58940
  })
59007
58941
  }), /*#__PURE__*/jsxRuntimeExports.jsx("div", {
59008
58942
  className: "flex items-center gap-2 ".concat(!companyLogo ? "px-3" : ""),
@@ -59479,8 +59413,8 @@
59479
59413
  });
59480
59414
  };
59481
59415
 
59482
- /**
59483
- * Auto-scroll hook that scrolls to bottom when dependencies change
59416
+ /**
59417
+ * Auto-scroll hook that scrolls to bottom when dependencies change
59484
59418
  */
59485
59419
  function useAutoScroll(containerRef) {
59486
59420
  let dependencies = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];
@@ -61939,6 +61873,16 @@
61939
61873
  })
61940
61874
  }];
61941
61875
 
61876
+ // Reorder the keyless cascade: freeipapi.com is currently the only one
61877
+ // reliably reachable from a browser — ip-api.com's free tier is HTTP-only
61878
+ // (→ HTTP 403 over HTTPS), ipwho.is → 403, ipapi.co → 429. Trying freeipapi
61879
+ // first avoids three doomed round-trips on every lookup. The rest stay as
61880
+ // fallback in case they recover or behave differently from another network.
61881
+ // NOTE: all IP providers locate the ISP gateway, not the device — for an
61882
+ // accurate end-user position use getBrowserLocation() (GPS + reverseGeocode).
61883
+ const FREE_ORDER = ["freeipapi.com", "ipwho.is", "ipapi.co", "ip-api.com"];
61884
+ freeProviders.sort((a, b) => FREE_ORDER.indexOf(a.name) - FREE_ORDER.indexOf(b.name));
61885
+
61942
61886
  // Key-based providers go first (more reliable, higher limits)
61943
61887
  return [...keyedProviders, ...freeProviders];
61944
61888
  }
@@ -62036,22 +61980,130 @@
62036
61980
  }));
62037
61981
  }
62038
61982
 
61983
+ // Default options for navigator.geolocation.getCurrentPosition.
61984
+ // enableHighAccuracy → prefer real GPS over coarse network positioning;
61985
+ // timeout 8s → don't hang forever waiting for a fix;
61986
+ // maximumAge 5 min → accept a recently-cached fix so a silent refresh
61987
+ // (permission already granted) resolves instantly.
61988
+ const DEFAULT_GEO_OPTIONS = {
61989
+ enableHighAccuracy: true,
61990
+ timeout: 8000,
61991
+ maximumAge: 5 * 60 * 1000
61992
+ };
61993
+
62039
61994
  /**
62040
- * Get browser-native geolocation (GPS / network-based, requires user permission)
61995
+ * Get browser-native geolocation (GPS / network-based).
61996
+ * Triggers the native permission prompt on first use for an origin (unless the
61997
+ * user already granted/denied). Resolves null — never rejects — on error,
61998
+ * denial, unsupported, or insecure (plain-HTTP) context, so callers can simply
61999
+ * fall back to IP geo.
62000
+ *
62001
+ * @param {PositionOptions} [options] - merged over DEFAULT_GEO_OPTIONS
62002
+ * @returns {Promise<{latitude:number, longitude:number, accuracy:number}|null>}
62041
62003
  */
62042
- function getBrowserGeolocation$1(options = {
62043
- timeout: 8000
62044
- }) {
62004
+ function getBrowserGeolocation$1(options = {}) {
62045
62005
  return new Promise(resolve => {
62046
- if (!navigator?.geolocation) return resolve(null);
62006
+ if (typeof navigator === "undefined" || !navigator.geolocation) return resolve(null);
62007
+ // getCurrentPosition only resolves in a secure context (HTTPS or
62008
+ // localhost). Bail early on plain HTTP rather than attempt a doomed prompt.
62009
+ if (typeof window !== "undefined" && window.isSecureContext === false) return resolve(null);
62047
62010
  navigator.geolocation.getCurrentPosition(pos => resolve({
62048
62011
  latitude: pos.coords.latitude,
62049
62012
  longitude: pos.coords.longitude,
62050
62013
  accuracy: pos.coords.accuracy
62051
- }), () => resolve(null), options);
62014
+ }), () => resolve(null), {
62015
+ ...DEFAULT_GEO_OPTIONS,
62016
+ ...options
62017
+ });
62052
62018
  });
62053
62019
  }
62054
62020
 
62021
+ /**
62022
+ * Query the current geolocation permission WITHOUT triggering a prompt.
62023
+ * Lets the host decide whether to ask: "granted" → fetch silently, "denied" →
62024
+ * skip (IP fallback), "prompt" → show your own pre-ask before getCurrentPosition.
62025
+ * Returns "unsupported" where the Permissions API is unavailable (e.g. older
62026
+ * Safari) so callers can treat it as "not known to be granted".
62027
+ * @returns {Promise<"granted"|"denied"|"prompt"|"unsupported">}
62028
+ */
62029
+ async function getGeolocationPermission$1() {
62030
+ try {
62031
+ if (typeof navigator === "undefined" || !navigator.permissions || !navigator.permissions.query) {
62032
+ return "unsupported";
62033
+ }
62034
+ const status = await navigator.permissions.query({
62035
+ name: "geolocation"
62036
+ });
62037
+ return status.state; // "granted" | "denied" | "prompt"
62038
+ } catch {
62039
+ return "unsupported";
62040
+ }
62041
+ }
62042
+
62043
+ /**
62044
+ * Reverse-geocode latitude/longitude → city / region / country.
62045
+ * Uses BigDataCloud's keyless, CORS-enabled *client* endpoint (no API key,
62046
+ * accurate to the city/state level). Resolves null on network failure or an
62047
+ * unusable response so the caller can keep any prior (IP-based) values.
62048
+ *
62049
+ * @param {number} latitude
62050
+ * @param {number} longitude
62051
+ * @param {Object} [options]
62052
+ * @param {number} [options.timeoutMs]
62053
+ * @param {string} [options.localityLanguage="en"]
62054
+ * @returns {Promise<{city,region,regionCode,country,countryCode,latitude,longitude}|null>}
62055
+ */
62056
+ async function reverseGeocode$1(latitude, longitude, options = {}) {
62057
+ if (typeof latitude !== "number" || typeof longitude !== "number") return null;
62058
+ const timeoutMs = options.timeoutMs || _globalConfig.timeoutMs || 5000;
62059
+ const lang = options.localityLanguage || "en";
62060
+ const url = "https://api.bigdatacloud.net/data/reverse-geocode-client" + `?latitude=${latitude}&longitude=${longitude}&localityLanguage=${lang}`;
62061
+ try {
62062
+ const d = await fetchWithTimeout(url, timeoutMs);
62063
+ const city = d.city || d.locality || "";
62064
+ // Treat an empty body (e.g. mid-ocean coords) as a miss, not a result.
62065
+ if (!city && !d.countryName) return null;
62066
+ return {
62067
+ city,
62068
+ region: d.principalSubdivision || "",
62069
+ regionCode: (d.principalSubdivisionCode || "").split("-").pop() || "",
62070
+ country: d.countryName || "",
62071
+ countryCode: d.countryCode || "",
62072
+ latitude,
62073
+ longitude
62074
+ };
62075
+ } catch {
62076
+ return null;
62077
+ }
62078
+ }
62079
+
62080
+ /**
62081
+ * High-level "exact location" helper. Gets the device GPS fix (prompting if
62082
+ * needed) and reverse-geocodes it. Returns a geo-shaped object tagged
62083
+ * source:"gps" when the place resolved; the bare coords (no source tag) when
62084
+ * only the fix succeeded but reverse-geocoding failed; or null when there is no
62085
+ * fix at all (denied / unsupported / insecure). This is the accurate-location
62086
+ * primitive — prefer it over IP geo for the real end-user position.
62087
+ *
62088
+ * @param {PositionOptions} [options]
62089
+ * @returns {Promise<Object|null>}
62090
+ */
62091
+ async function getBrowserLocation$1(options = {}) {
62092
+ const coords = await getBrowserGeolocation$1(options);
62093
+ if (!coords) return null;
62094
+ const place = await reverseGeocode$1(coords.latitude, coords.longitude, options);
62095
+ const hasPlace = !!(place && (place.city || place.country));
62096
+ return {
62097
+ ...(hasPlace ? place : {}),
62098
+ latitude: coords.latitude,
62099
+ longitude: coords.longitude,
62100
+ accuracy: coords.accuracy,
62101
+ ...(hasPlace ? {
62102
+ source: "gps"
62103
+ } : {})
62104
+ };
62105
+ }
62106
+
62055
62107
  /** Build a Google Maps URL from coordinates */
62056
62108
  function toMapsUrl$1(lat, lon) {
62057
62109
  return `https://www.google.com/maps?q=${lat},${lon}`;
@@ -62061,6 +62113,9 @@
62061
62113
  resolveLocation: resolveLocation$1,
62062
62114
  listProviders: listProviders$1,
62063
62115
  getBrowserGeolocation: getBrowserGeolocation$1,
62116
+ getGeolocationPermission: getGeolocationPermission$1,
62117
+ reverseGeocode: reverseGeocode$1,
62118
+ getBrowserLocation: getBrowserLocation$1,
62064
62119
  toMapsUrl: toMapsUrl$1
62065
62120
  };
62066
62121
 
@@ -62244,6 +62299,9 @@
62244
62299
  resolveLocation,
62245
62300
  listProviders,
62246
62301
  getBrowserGeolocation,
62302
+ getGeolocationPermission,
62303
+ reverseGeocode,
62304
+ getBrowserLocation,
62247
62305
  toMapsUrl
62248
62306
  } = location;
62249
62307
  const {
@@ -62276,7 +62334,13 @@
62276
62334
  * @param {Object} [options]
62277
62335
  * @param {Function} [options.onReady] - Callback when profile is ready
62278
62336
  * @param {Function} [options.onError] - Callback on error
62279
- * @param {boolean} [options.geoFromBrowser=false] - Also request GPS (asks permission)
62337
+ * @param {boolean|"auto"} [options.geoFromBrowser=true] - Capture precise GPS
62338
+ * and reverse-geocode it into `geo`. DEFAULT true = request on init()
62339
+ * (shows the native permission prompt unless already granted/denied);
62340
+ * false = never (IP only); "auto" = use GPS only when permission was
62341
+ * ALREADY granted (silent, never prompts).
62342
+ * @param {Object} [options.geoApiKeys] - API keys for keyed geo providers
62343
+ * @param {PositionOptions} [options.geoOptions] - Override getCurrentPosition opts
62280
62344
  * @param {boolean} [options.cache=true] - Use cached profile if < maxAgeMs old
62281
62345
  * @param {number} [options.maxAgeMs=300000] - Max cache age (default 5 min)
62282
62346
  * @param {boolean} [options.autoInit=false] - Auto-init on construction
@@ -62286,7 +62350,13 @@
62286
62350
  this._options = {
62287
62351
  onReady: options.onReady || null,
62288
62352
  onError: options.onError || null,
62289
- geoFromBrowser: options.geoFromBrowser || false,
62353
+ // Default ON: capture precise GPS on init() behind the browser's native
62354
+ // permission prompt. Use `=== undefined` (not `|| `) so an explicit
62355
+ // `false` opt-out is honored; pass "auto" to use an already-granted
62356
+ // permission silently without ever prompting.
62357
+ geoFromBrowser: options.geoFromBrowser === undefined ? true : options.geoFromBrowser,
62358
+ geoApiKeys: options.geoApiKeys || null,
62359
+ geoOptions: options.geoOptions || null,
62290
62360
  cache: options.cache !== false,
62291
62361
  maxAgeMs: options.maxAgeMs || 5 * 60 * 1000,
62292
62362
  autoInit: options.autoInit || false,
@@ -62336,15 +62406,46 @@
62336
62406
  confidence
62337
62407
  } = generateFingerprint();
62338
62408
 
62339
- // 3. IP + Geo — run in parallel
62340
- const [ipResult, browserGeo] = await Promise.all([detectIP(), this._options.geoFromBrowser ? getBrowserGeolocation() : Promise.resolve(null)]);
62341
- const ip = ipResult?.ip || "unknown";
62342
- const ipVersion = ipResult ? getIPVersion(ip) : "unknown";
62343
-
62344
- // 4. Resolve geo from IP
62345
- let geo = null;
62346
- if (ip !== "unknown") {
62347
- geo = await resolveLocation(ip);
62409
+ // 3. IP→geo chain and (optional) precise browser GPS run concurrently.
62410
+ // GPS is the slow path (permission prompt + satellite fix) so we never
62411
+ // block the IP lookup on it.
62412
+ const ipApiKeys = this._options.geoApiKeys ? {
62413
+ apiKeys: this._options.geoApiKeys
62414
+ } : {};
62415
+ const [ipBundle, gpsLoc] = await Promise.all([(async () => {
62416
+ const ipResult = await detectIP();
62417
+ const ipAddr = ipResult?.ip || "unknown";
62418
+ const ipGeo = ipAddr !== "unknown" ? await resolveLocation(ipAddr, ipApiKeys) : null;
62419
+ return {
62420
+ ip: ipAddr,
62421
+ ipVersion: ipResult ? getIPVersion(ipAddr) : "unknown",
62422
+ geo: ipGeo
62423
+ };
62424
+ })(), this._collectBrowserLocation()]);
62425
+ const {
62426
+ ip,
62427
+ ipVersion
62428
+ } = ipBundle;
62429
+
62430
+ // 4. Geo: start from the (coarse) IP location, then — when we have a
62431
+ // precise GPS fix — OVERRIDE city/region/country with the
62432
+ // reverse-geocoded values. IP geo only locates the ISP gateway, so a
62433
+ // real device fix always wins. Denied/unavailable → IP value stays.
62434
+ let geo = ipBundle.geo;
62435
+ let browserGeo = null;
62436
+ if (gpsLoc) {
62437
+ browserGeo = {
62438
+ latitude: gpsLoc.latitude,
62439
+ longitude: gpsLoc.longitude,
62440
+ accuracy: gpsLoc.accuracy
62441
+ };
62442
+ if (gpsLoc.source === "gps") {
62443
+ // gpsLoc carries city/region/regionCode/country/countryCode/lat/lng/source
62444
+ geo = {
62445
+ ...(geo || {}),
62446
+ ...gpsLoc
62447
+ };
62448
+ }
62348
62449
  }
62349
62450
 
62350
62451
  // 5. Browser context
@@ -62386,6 +62487,56 @@
62386
62487
  }
62387
62488
  }
62388
62489
 
62490
+ /**
62491
+ * Decide whether to capture browser GPS during init(), honoring geoFromBrowser:
62492
+ * false → null (skip)
62493
+ * "auto" → only when permission is ALREADY granted (silent, never prompts)
62494
+ * true → attempt (getCurrentPosition shows the prompt unless denied)
62495
+ * @private
62496
+ * @returns {Promise<Object|null>}
62497
+ */
62498
+ async _collectBrowserLocation() {
62499
+ const mode = this._options.geoFromBrowser;
62500
+ if (!mode) return null;
62501
+ if (mode === "auto") {
62502
+ const perm = await getGeolocationPermission();
62503
+ if (perm !== "granted") return null; // never prompt during a silent init
62504
+ }
62505
+ return getBrowserLocation(this._options.geoOptions || {});
62506
+ }
62507
+
62508
+ /**
62509
+ * Capture precise browser location AFTER your UI has obtained user consent
62510
+ * (e.g. an in-widget "Share your location?" pre-ask). Triggers the native
62511
+ * permission prompt, reverse-geocodes the fix, merges it into the current
62512
+ * profile's `geo` (source:"gps"), re-persists, and fires onReady. No-ops
62513
+ * gracefully when denied/unavailable (keeps the IP fallback) or before init().
62514
+ * @param {PositionOptions} [options]
62515
+ * @returns {Promise<UserProfile|null>}
62516
+ */
62517
+ async captureBrowserLocation(options = {}) {
62518
+ const loc = await getBrowserLocation({
62519
+ ...(this._options.geoOptions || {}),
62520
+ ...options
62521
+ });
62522
+ if (!loc || !this.profile) return this.profile; // denied/unavailable or not init'd yet
62523
+ this.profile.browserGeo = {
62524
+ latitude: loc.latitude,
62525
+ longitude: loc.longitude,
62526
+ accuracy: loc.accuracy
62527
+ };
62528
+ this.profile.mapsUrl = toMapsUrl(loc.latitude, loc.longitude);
62529
+ if (loc.source === "gps") {
62530
+ this.profile.geo = {
62531
+ ...(this.profile.geo || {}),
62532
+ ...loc
62533
+ };
62534
+ }
62535
+ saveSessionMeta(this.profile);
62536
+ if (this._options.onReady) this._options.onReady(this.profile);
62537
+ return this.profile;
62538
+ }
62539
+
62389
62540
  /**
62390
62541
  * Get the current profile (must call init() first)
62391
62542
  * @returns {UserProfile|null}
@@ -62480,6 +62631,9 @@
62480
62631
  src.exports.resolveLocation = resolveLocation;
62481
62632
  src.exports.listProviders = listProviders;
62482
62633
  src.exports.getBrowserGeolocation = getBrowserGeolocation;
62634
+ src.exports.getGeolocationPermission = getGeolocationPermission;
62635
+ src.exports.reverseGeocode = reverseGeocode;
62636
+ src.exports.getBrowserLocation = getBrowserLocation;
62483
62637
  src.exports.getOrCreateUserID = getOrCreateUserID;
62484
62638
  src.exports.getOrCreateSessionID = getOrCreateSessionID;
62485
62639
  src.exports.clearSession = clearSession;