341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
// 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<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 };
|
||
}
|