// 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 0–1 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 { 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 { 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 { 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 { 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; reset: () => void; } export function useResolveLocation({ token, tokenName, }: UseResolveLocationOptions): UseResolveLocationReturn { const [location, setLocation] = useState({ region: null, district: null, }); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(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 }; }