register address

This commit is contained in:
Samandar Turgunboyev
2026-03-26 14:23:58 +05:00
parent fee9213c59
commit a671706fb3
12 changed files with 555 additions and 311 deletions

View File

@@ -0,0 +1,340 @@
// 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 };
}