Browse Source

feat(geo): integrate Flutter bridge get_location action for IP-based country and city detection

master
mortezaei 3 days ago
parent
commit
76a8c4ba98
  1. 168
      src/lib/geo-region.test.ts
  2. 443
      src/lib/geo-region.ts
  3. 4
      src/types/window.d.ts

168
src/lib/geo-region.test.ts

@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getUserGeoRegion,
getStoredUserGeoRegion,
setStoredUserGeoRegion,
resetUserGeoRegionForTesting,
} from "./geo-region";
import { http } from "./http";
vi.mock("./http", () => ({
http: {
get: vi.fn(),
},
}));
describe("geo-region", () => {
const originalHabibApp = window.HabibApp;
const originalAddFlutterResponseListener = window.addFlutterResponseListener;
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
resetUserGeoRegionForTesting();
});
afterEach(() => {
window.HabibApp = originalHabibApp;
window.addFlutterResponseListener = originalAddFlutterResponseListener;
resetUserGeoRegionForTesting();
});
describe("getStoredUserGeoRegion / setStoredUserGeoRegion", () => {
it("returns null when nothing is stored", () => {
expect(getStoredUserGeoRegion()).toBeNull();
});
it("persists region and retrieves from memory and localStorage", () => {
const sample = {
ip: "1.2.3.4",
city: "Tehran",
country: "Iran",
countryCode: "IR",
phoneCode: "+98",
};
setStoredUserGeoRegion(sample);
expect(getStoredUserGeoRegion()).toEqual(sample);
expect(localStorage.getItem("user_geo_region")).toBe(
JSON.stringify(sample),
);
expect(localStorage.getItem("geoIPPhoneCode")).toBe("+98");
});
});
describe("getUserGeoRegion", () => {
it("returns stored region immediately if not forced", async () => {
const cached = {
city: "London",
country: "United Kingdom",
countryCode: "GB",
phoneCode: "+44",
};
setStoredUserGeoRegion(cached);
const result = await getUserGeoRegion(false);
expect(result).toEqual(cached);
expect(http.get).not.toHaveBeenCalled();
});
it("fetches via Flutter bridge when in Flutter WebView", async () => {
let listenerCallback: ((event: any) => void) | undefined;
window.addFlutterResponseListener = vi.fn().mockImplementation((cb) => {
listenerCallback = cb;
return () => {};
});
const mockPostMessage = vi.fn().mockImplementation(() => {
setTimeout(() => {
listenerCallback?.({
action: "get_location",
success: true,
data: {
ip: "5.6.7.8",
city: "Isfahan",
country: "Iran",
country_code: "IR",
},
});
}, 10);
});
window.HabibApp = { postMessage: mockPostMessage };
const result = await getUserGeoRegion(true);
expect(mockPostMessage).toHaveBeenCalledWith(
JSON.stringify({ action: "get_location" }),
);
expect(result).toEqual({
ip: "5.6.7.8",
city: "Isfahan",
country: "Iran",
countryCode: "IR",
phoneCode: "+98",
});
expect(getStoredUserGeoRegion()?.countryCode).toBe("IR");
});
it("fetches from HTTP backend when in standard browser", async () => {
delete (window as any).HabibApp;
(http.get as any).mockResolvedValueOnce({
data: {
ip: "10.0.0.1",
city: "Shiraz",
country: "Iran",
country_code: "IR",
},
});
const result = await getUserGeoRegion(true);
expect(http.get).toHaveBeenCalledWith("/account/auth/user/region/", {
timeout: 4000,
});
expect(result).toEqual({
ip: "10.0.0.1",
city: "Shiraz",
country: "Iran",
countryCode: "IR",
phoneCode: "+98",
});
});
it("falls back to HTTP backend if Flutter bridge returns failure", async () => {
let listenerCallback: ((event: any) => void) | undefined;
window.addFlutterResponseListener = vi.fn().mockImplementation((cb) => {
listenerCallback = cb;
return () => {};
});
const mockPostMessage = vi.fn().mockImplementation(() => {
setTimeout(() => {
listenerCallback?.({
action: "get_location",
success: false,
});
}, 10);
});
window.HabibApp = { postMessage: mockPostMessage };
(http.get as any).mockResolvedValueOnce({
data: {
ip: "192.168.1.1",
city: "Mashhad",
country: "Iran",
country_code: "IR",
},
});
const result = await getUserGeoRegion(true);
expect(result.city).toBe("Mashhad");
expect(result.countryCode).toBe("IR");
expect(result.country).toBe("Iran");
expect(result.phoneCode).toBe("+98");
});
});
});

443
src/lib/geo-region.ts

@ -43,7 +43,10 @@ export function getStoredUserGeoRegion(): UserGeoRegion | null {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored) { if (stored) {
const parsed = JSON.parse(stored) as UserGeoRegion; const parsed = JSON.parse(stored) as UserGeoRegion;
if (parsed && (parsed.country || parsed.phoneCode)) {
if (
parsed &&
(parsed.country || parsed.phoneCode || parsed.city || parsed.countryCode)
) {
cachedRegion = parsed; cachedRegion = parsed;
return parsed; return parsed;
} }
@ -79,158 +82,330 @@ function resolvePhoneCodeFromCountryCode(isoCode?: string): string | undefined {
return undefined; return undefined;
} }
export function getUserGeoRegion(force = false): Promise<UserGeoRegion> {
if (!force) {
const existing = getStoredUserGeoRegion();
if (existing && (existing.city || existing.country)) {
return Promise.resolve(existing);
}
} else {
cachedRegion = null;
geoRegionPromise = null;
}
/**
* Fetch geo region from backend HTTP API with multi-tier public fallbacks.
*/
async function fetchHttpGeoRegion(): Promise<UserGeoRegion> {
try {
// 1. Primary: Habib Backend User Region API (/account/auth/user/region/)
try {
console.log(
"[GEO_BRIDGE_LOG] 🚀 Requesting /account/auth/user/region/ from backend...",
);
const response = await http.get<{
ip?: string;
country?: string;
country_code?: string;
city?: string;
}>("/account/auth/user/region/", {
timeout: 4000,
});
if (geoRegionPromise) {
return geoRegionPromise;
}
const data = response.data;
console.log(
"[GEO_BRIDGE_LOG] 📥 Backend Raw Response:",
JSON.stringify(data),
);
if (data && (data.country || data.country_code || data.city || data.ip)) {
const rawCountry = data.country || data.country_code || "";
const isoCode =
data.country_code ||
(rawCountry && rawCountry.trim().length === 2
? rawCountry.trim().toUpperCase()
: undefined);
const phoneCode = resolvePhoneCodeFromCountryCode(isoCode);
const countryName =
resolveCountryName(data.country || isoCode, "en") || data.country;
const countryFa =
resolveCountryName(data.country || isoCode, "fa") || countryName;
geoRegionPromise = (async () => {
try {
// 1. Primary: Habib Backend User Region API (/account/auth/user/region/)
try {
console.log("[GEO_AUTO_LOG] 🚀 Requesting /account/auth/user/region/ from backend...");
const response = await http.get<{
ip?: string;
country?: string;
country_code?: string;
city?: string;
}>("/account/auth/user/region/", {
timeout: 4000,
});
const data = response.data;
console.log("[GEO_AUTO_LOG] 📥 Backend Raw Response:", JSON.stringify(data));
if (data && (data.country || data.country_code || data.city)) {
const isoCode =
data.country_code ||
(data.country && data.country.trim().length === 2
? data.country.trim().toUpperCase()
: undefined);
const phoneCode = resolvePhoneCodeFromCountryCode(isoCode);
const countryName =
resolveCountryName(data.country || isoCode, "en") || data.country;
const countryFa =
resolveCountryName(data.country || isoCode, "fa") || countryName;
console.log(
`[GEO_AUTO_LOG] 🌍 Resolved Country: FA=${countryFa} | EN=${countryName} | Code=${isoCode} | City=${data.city || "None"} | IP=${data.ip || "None"}`,
);
console.log(
`[GEO_BRIDGE_LOG] 🌍 Resolved Country: FA=${countryFa} | EN=${countryName} | Code=${isoCode} | City=${data.city || "None"} | IP=${data.ip || "None"}`,
);
const region: UserGeoRegion = {
ip: data.ip,
city: data.city,
country: countryName,
countryCode: isoCode,
phoneCode: phoneCode || "+44",
};
setStoredUserGeoRegion(region);
return region;
}
} catch (err: any) {
console.error(
"[GEO_BRIDGE_LOG] ❌ Backend Region Error:",
err?.response?.data || err?.message || err,
);
}
// 2. Secondary fallback: ipapi.co with 2s timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
console.log("[GEO_BRIDGE_LOG] 🔄 Falling back to ipapi.co...");
const res = await fetch("https://ipapi.co/json/", {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res?.ok) {
const data = await res.json();
if (
data &&
(data.country_name || data.city || data.country_calling_code)
) {
const rawPhone = data.country_calling_code
? String(data.country_calling_code).trim()
: "";
const phoneCode = rawPhone.startsWith("+")
? rawPhone
: rawPhone
? `+${rawPhone}`
: "+44";
const region: UserGeoRegion = { const region: UserGeoRegion = {
ip: data.ip, ip: data.ip,
city: data.city, city: data.city,
country: countryName,
countryCode: isoCode,
phoneCode: phoneCode || "+44",
country: data.country_name,
countryCode: data.country_code,
phoneCode,
}; };
setStoredUserGeoRegion(region); setStoredUserGeoRegion(region);
return region; return region;
} }
} catch (err: any) {
console.error(
"[GEO_AUTO_LOG] ❌ Backend Region Error:",
err?.response?.data || err?.message || err,
);
} }
} catch {
clearTimeout(timeoutId);
}
// 2. Secondary fallback: ipapi.co with 2s timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch("https://ipapi.co/json/", {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res?.ok) {
const data = await res.json();
if (
data &&
(data.country_name || data.city || data.country_calling_code)
) {
const rawPhone = data.country_calling_code
? String(data.country_calling_code).trim()
: "";
const phoneCode = rawPhone.startsWith("+")
? rawPhone
: rawPhone
? `+${rawPhone}`
: "+44";
const region: UserGeoRegion = {
ip: data.ip,
city: data.city,
country: data.country_name,
countryCode: data.country_code,
phoneCode,
};
setStoredUserGeoRegion(region);
return region;
}
// 3. Tertiary fallback: ipwho.is with 2s timeout
const secondaryController = new AbortController();
const secondaryTimeoutId = setTimeout(
() => secondaryController.abort(),
2000,
);
try {
console.log("[GEO_BRIDGE_LOG] 🔄 Falling back to ipwho.is...");
const res = await fetch("https://ipwho.is/", {
signal: secondaryController.signal,
});
clearTimeout(secondaryTimeoutId);
if (res?.ok) {
const data = await res.json();
if (data && (data.country || data.city || data.calling_code)) {
const rawPhone = data.calling_code
? String(data.calling_code).trim()
: "";
const phoneCode = rawPhone.startsWith("+")
? rawPhone
: rawPhone
? `+${rawPhone}`
: "+44";
const region: UserGeoRegion = {
ip: data.ip,
city: data.city,
country: data.country,
countryCode: data.country_code,
phoneCode,
};
setStoredUserGeoRegion(region);
return region;
} }
} catch {
clearTimeout(timeoutId);
} }
} catch {
clearTimeout(secondaryTimeoutId);
}
// 3. Tertiary fallback: ipwho.is with 2s timeout
const secondaryController = new AbortController();
const secondaryTimeoutId = setTimeout(
() => secondaryController.abort(),
2000,
);
try {
const res = await fetch("https://ipwho.is/", {
signal: secondaryController.signal,
});
clearTimeout(secondaryTimeoutId);
if (res?.ok) {
const data = await res.json();
if (data && (data.country || data.city || data.calling_code)) {
const rawPhone = data.calling_code
? String(data.calling_code).trim()
: "";
const phoneCode = rawPhone.startsWith("+")
? rawPhone
: rawPhone
? `+${rawPhone}`
: "+44";
const region: UserGeoRegion = {
ip: data.ip,
city: data.city,
country: data.country,
countryCode: data.country_code,
phoneCode,
};
setStoredUserGeoRegion(region);
return region;
// 4. Default fallback
console.log("[GEO_BRIDGE_LOG] ⚠️ Using default fallback region (+44)...");
const defaultRegion: UserGeoRegion = {
phoneCode: "+44",
};
setStoredUserGeoRegion(defaultRegion);
return defaultRegion;
} catch {
const defaultRegion: UserGeoRegion = {
phoneCode: "+44",
};
setStoredUserGeoRegion(defaultRegion);
return defaultRegion;
}
}
/**
* Fetch geo region via Flutter bridge protocol.
*/
function fetchFlutterBridgeGeoRegion(): Promise<UserGeoRegion> {
return new Promise<UserGeoRegion>((resolve) => {
let resolved = false;
let unsubscribe: (() => void) | undefined;
let timer: ReturnType<typeof setTimeout> | null = null;
const finish = (region: UserGeoRegion) => {
if (resolved) return;
resolved = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
if (unsubscribe) {
try {
unsubscribe();
} catch {}
unsubscribe = undefined;
}
resolve(region);
};
console.log(
"[GEO_BRIDGE_LOG] 🌉 Registering Flutter response listener for get_location...",
);
// 1) Register a listener with window.addFlutterResponseListener
if (
typeof window !== "undefined" &&
typeof window.addFlutterResponseListener === "function"
) {
unsubscribe = window.addFlutterResponseListener(
(event: FlutterResponseEvent) => {
if (!event) return;
const action = event.action?.toLowerCase();
// 2) Listens for event.action === "get_location"
if (action === "get_location") {
console.log(
"[GEO_BRIDGE_LOG] 📥 Flutter get_location response received:",
JSON.stringify(event),
);
const data = (event.data || (event as any).payload) as {
ip?: string;
country?: string;
country_code?: string;
city?: string;
} | undefined;
if (
event.success &&
data &&
(data.country || data.country_code || data.city || data.ip)
) {
const rawCountry = data.country || data.country_code || "";
const isoCode =
data.country_code ||
(rawCountry && rawCountry.trim().length === 2
? rawCountry.trim().toUpperCase()
: undefined);
const phoneCode = resolvePhoneCodeFromCountryCode(isoCode);
const countryName =
resolveCountryName(data.country || isoCode, "en") ||
data.country;
const countryFa =
resolveCountryName(data.country || isoCode, "fa") ||
countryName;
console.log(
`[GEO_BRIDGE_LOG] 🌍 Resolved from Flutter bridge: FA=${countryFa} | EN=${countryName} | Code=${isoCode} | City=${data.city || "None"} | IP=${data.ip || "None"} | Phone=${phoneCode || "+44"}`,
);
const region: UserGeoRegion = {
ip: data.ip,
city: data.city,
country: countryName,
countryCode: isoCode,
phoneCode: phoneCode || "+44",
};
setStoredUserGeoRegion(region);
finish(region);
} else {
console.warn(
"[GEO_BRIDGE_LOG] ⚠️ Flutter get_location unsuccessful or empty, falling back to HTTP",
);
fetchHttpGeoRegion()
.then(finish)
.catch(() => finish({ phoneCode: "+44" }));
}
} }
}
} catch {
clearTimeout(secondaryTimeoutId);
},
);
}
// 3) Posts message: window.HabibApp.postMessage(JSON.stringify({ action: "get_location" }))
try {
if (typeof window !== "undefined" && window.HabibApp?.postMessage) {
console.log(
"[GEO_BRIDGE_LOG] 📤 Posting { action: 'get_location' } to window.HabibApp...",
);
window.HabibApp.postMessage(JSON.stringify({ action: "get_location" }));
} else if (
typeof window !== "undefined" &&
typeof (window as any).sendToFlutter === "function"
) {
console.log(
"[GEO_BRIDGE_LOG] 📤 Sending get_location via sendToFlutter...",
);
(window as any).sendToFlutter("get_location");
} }
} catch (e) {
console.error(
"[GEO_BRIDGE_LOG] ❌ Failed to post get_location message:",
e,
);
}
// 4. Default fallback
const defaultRegion: UserGeoRegion = {
phoneCode: "+44",
};
setStoredUserGeoRegion(defaultRegion);
return defaultRegion;
} catch {
const defaultRegion: UserGeoRegion = {
phoneCode: "+44",
};
setStoredUserGeoRegion(defaultRegion);
return defaultRegion;
// 4) Has a timeout fallback of 4000ms: if no response from Flutter, fallback to calling http.get("/account/auth/user/region/")
timer = setTimeout(() => {
if (!resolved) {
console.warn(
"[GEO_BRIDGE_LOG] ⏱️ Flutter bridge get_location timed out after 4000ms, falling back to HTTP...",
);
fetchHttpGeoRegion()
.then(finish)
.catch(() => finish({ phoneCode: "+44" }));
}
}, 4000);
});
}
export function getUserGeoRegion(force = false): Promise<UserGeoRegion> {
// If !force: Check cachedRegion or getStoredUserGeoRegion(). If present, return it immediately.
if (!force) {
const existing = cachedRegion || getStoredUserGeoRegion();
if (
existing &&
(existing.city || existing.country || existing.phoneCode || existing.countryCode)
) {
console.log(
"[GEO_BRIDGE_LOG] 📦 Returning cached/stored user geo region:",
JSON.stringify(existing),
);
return Promise.resolve(existing);
} }
})();
} else {
cachedRegion = null;
geoRegionPromise = null;
}
if (geoRegionPromise) {
return geoRegionPromise;
}
// Check if running inside Flutter Webview: typeof window !== "undefined" && window.HabibApp?.postMessage
const isFlutter =
typeof window !== "undefined" &&
(Boolean(window.HabibApp?.postMessage) ||
typeof (window as any).sendToFlutter === "function");
if (isFlutter) {
console.log(
"[GEO_BRIDGE_LOG] 📱 Inside Flutter WebView detected, requesting location via Flutter bridge",
);
geoRegionPromise = fetchFlutterBridgeGeoRegion();
} else {
console.log(
"[GEO_BRIDGE_LOG] 🌐 Standard browser environment detected, requesting location via HTTP",
);
geoRegionPromise = fetchHttpGeoRegion();
}
return geoRegionPromise; return geoRegionPromise;
} }

4
src/types/window.d.ts

@ -19,6 +19,10 @@ declare global {
// get_location // get_location
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
ip?: string;
country?: string;
country_code?: string;
city?: string;
// get_view_paddings / safe_area_changed (flat edges) // get_view_paddings / safe_area_changed (flat edges)
top?: number; top?: number;
bottom?: number; bottom?: number;

Loading…
Cancel
Save