Files
info-target-mobile/screens/auth/register/lib/useResolveLocation.ts
Samandar Turgunboyev a671706fb3 register address
2026-03-26 14:23:58 +05:00

341 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// hooks/useResolveLocation.ts
import axios from "axios";
import { useCallback, useState } from "react";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface District {
districtId: number;
name: string;
regionId: number;
}
export interface Region {
regionId: number;
name: string;
}
export interface ResolvedLocation {
region: { id: number; name: string } | null;
district: { id: number; name: string } | null;
}
interface NominatimAddress {
state?: string;
county?: string;
district?: string;
suburb?: string;
city_district?: string;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// utils/geoLocation.ts
// ─── Extract meaningful location keyword from raw street address ──────────────
function extractLocationKeyword(address: string): string[] {
const keywords: string[] = [];
const lower = address.toLowerCase();
// Pattern 1: "X район" or "X tumani" or "X district"
const districtMatch = lower.match(
/([а-яёa-z\-]+(?:\s[а-яёa-z\-]+)?)\s*(район|tumani|tuman|district)/i,
);
if (districtMatch?.[1]) keywords.push(districtMatch[1].trim());
// Pattern 2: "X МФЙ" (mahalla) — the word before МФЙ is usually district name
const mfyMatch = lower.match(/([а-яёa-z\-]+(?:\s[а-яёa-z\-]+)?)\s*мфй/i);
if (mfyMatch?.[1]) keywords.push(mfyMatch[1].trim());
// Pattern 3: First word of address (last resort)
const firstWord = address.trim().split(/[\s,]+/)[0];
if (firstWord) keywords.push(firstWord);
// Always include full address for Nominatim
keywords.push(address);
return [...new Set(keywords)]; // deduplicate
}
// Normalize Cyrillic/Uzbek → Latin for fuzzy matching
function toLatinLower(str: string): string {
return (
str
.toLowerCase()
.replace(/ш/g, "sh")
.replace(/ч/g, "ch")
.replace(/ж/g, "zh")
.replace(/ъ/g, "")
.replace(/ь/g, "")
.replace(/а/g, "a")
.replace(/б/g, "b")
.replace(/в/g, "v")
.replace(/г/g, "g")
.replace(/д/g, "d")
.replace(/е/g, "e")
.replace(/ё/g, "yo")
.replace(/з/g, "z")
.replace(/и/g, "i")
.replace(/й/g, "y")
.replace(/к/g, "k")
.replace(/л/g, "l")
.replace(/м/g, "m")
.replace(/н/g, "n")
.replace(/о/g, "o")
.replace(/п/g, "p")
.replace(/р/g, "r")
.replace(/с/g, "s")
.replace(/т/g, "t")
.replace(/у/g, "u")
.replace(/ф/g, "f")
.replace(/х/g, "x")
.replace(/ц/g, "ts")
.replace(/щ/g, "sh")
.replace(/э/g, "e")
.replace(/ю/g, "yu")
.replace(/я/g, "ya")
// Uzbek specific
.replace(/ў/g, "o")
.replace(/қ/g, "q")
.replace(/ғ/g, "g")
.replace(/ҳ/g, "h")
.replace(/[''`]/g, "")
.replace(/\s+/g, " ")
.trim()
);
}
// Score-based fuzzy match: returns 01 similarity
function matchScore(source: string, target: string): number {
const s = toLatinLower(source);
const t = toLatinLower(target);
if (s === t) return 1;
if (s.includes(t) || t.includes(s)) return 0.9;
// Compare first meaningful word
const sWord = s.split(" ")[0];
const tWord = t.split(" ")[0];
if (sWord === tWord) return 0.8;
if (sWord.length >= 4 && tWord.includes(sWord.slice(0, 5))) return 0.7;
if (tWord.length >= 4 && sWord.includes(tWord.slice(0, 5))) return 0.7;
return 0;
}
function findBestDistrictMatch(
query: string,
list: District[],
threshold = 0.7,
): District | null {
let best: District | null = null;
let bestScore = 0;
for (const item of list) {
const score = matchScore(query, item.name);
if (score > bestScore) {
bestScore = score;
best = item;
}
}
return bestScore >= threshold ? best : null;
}
function findBestRegionMatch(
query: string,
list: Region[],
threshold = 0.7,
): Region | null {
let best: Region | null = null;
let bestScore = 0;
for (const item of list) {
const score = matchScore(query, item.name);
if (score > bestScore) {
bestScore = score;
best = item;
}
}
return bestScore >= threshold ? best : null;
}
// ─── Nominatim geocoder ───────────────────────────────────────────────────────
async function geocodeAddress(
address: string,
): Promise<NominatimAddress | null> {
const queries = extractLocationKeyword(address);
for (const query of queries) {
try {
const encoded = encodeURIComponent(`${query}, Uzbekistan`);
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encoded}&format=json&addressdetails=1&limit=1`,
{
headers: {
"Accept-Language": "uz",
"User-Agent": "MyApp/1.0 (your@email.com)",
},
},
);
const data = await res.json();
if (data.length > 0) {
return data[0].address as NominatimAddress;
}
} catch { }
}
return null;
}
// ─── Fetch districts & regions ────────────────────────────────────────────────
async function fetchDistricts(
token: string,
tokenName: string,
): Promise<District[]> {
const res = await axios.get("https://testapi3.didox.uz/v1/districts/all/", {
headers: { "Accept-Language": "uz", [tokenName]: token },
});
// handle both array and {data: [...]} shapes
return Array.isArray(res.data) ? res.data : (res.data?.data ?? []);
}
async function fetchRegions(
token: string,
tokenName: string,
): Promise<Region[]> {
const res = await axios.get("https://testapi3.didox.uz/v1/regions/all/", {
headers: { "Accept-Language": "uz", [tokenName]: token },
});
return Array.isArray(res.data) ? res.data : (res.data?.data ?? []);
}
// ─── Core resolver (usable standalone, outside React) ────────────────────────
export async function resolveLocationFromAddress(
rawAddress: string,
token: string,
tokenName: string,
): Promise<ResolvedLocation> {
if (!rawAddress?.trim() || !token || !tokenName) {
return { region: null, district: null };
}
const [geo, districts, regions] = await Promise.all([
geocodeAddress(rawAddress),
fetchDistricts(token, tokenName),
fetchRegions(token, tokenName),
]);
// Build candidates from Nominatim + direct address keywords
const addressKeywords = extractLocationKeyword(rawAddress);
const candidates = [
geo?.county,
geo?.city_district,
geo?.district,
geo?.suburb,
geo?.state,
...addressKeywords, // ← include extracted keywords directly
].filter(Boolean) as string[];
// ── Try DISTRICT match first ──
for (const candidate of candidates) {
const matched = findBestDistrictMatch(candidate, districts);
if (matched) {
const parentRegion =
regions.find((r: Region) => r.regionId === matched.regionId) ?? null;
return {
district: { id: matched.districtId, name: matched.name },
region: parentRegion
? { id: parentRegion.regionId, name: parentRegion.name }
: null,
};
}
}
// ── Fall back to REGION match ──
for (const candidate of candidates) {
const matched = findBestRegionMatch(candidate, regions);
if (matched) {
return {
region: { id: matched.regionId, name: matched.name },
district: null,
};
}
}
return { region: null, district: null };
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
interface UseResolveLocationOptions {
token: string | null;
tokenName: string | null;
}
interface UseResolveLocationReturn {
location: ResolvedLocation;
isLoading: boolean;
error: string | null;
resolve: (address: string) => Promise<void>;
reset: () => void;
}
export function useResolveLocation({
token,
tokenName,
}: UseResolveLocationOptions): UseResolveLocationReturn {
const [location, setLocation] = useState<ResolvedLocation>({
region: null,
district: null,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const resolve = useCallback(
async (address: string) => {
if (!token || !tokenName) {
setError("Token not ready");
return;
}
if (!address?.trim()) {
setError("Address is empty");
return;
}
setIsLoading(true);
setError(null);
try {
const result = await resolveLocationFromAddress(
address,
token,
tokenName,
);
setLocation(result);
if (!result.region && !result.district) {
setError("Location not found");
}
} catch (e: any) {
setError(e?.message ?? "Failed to resolve location");
setLocation({ region: null, district: null });
} finally {
setIsLoading(false);
}
},
[token, tokenName],
);
const reset = useCallback(() => {
setLocation({ region: null, district: null });
setError(null);
setIsLoading(false);
}, []);
return { location, isLoading, error, resolve, reset };
}