diff --git a/app.json b/app.json index f1da3c7..0519f75 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Info target", "slug": "infotarget", - "version": "1.0.3", + "version": "1.0.5", "orientation": "portrait", "icon": "./assets/images/logo.png", "scheme": "infotarget", diff --git a/app/(auth)/select-category.tsx b/app/(auth)/select-category.tsx index 34798b2..5095b4a 100644 --- a/app/(auth)/select-category.tsx +++ b/app/(auth)/select-category.tsx @@ -25,6 +25,15 @@ interface Category { is_leaf: boolean; } +type DRFError = { + [key: string]: Array< + | string + | { + [key: string]: string[]; + } + >; +}; + export default function CategorySelectScreen() { const router = useRouter(); const { t } = useTranslation(); @@ -74,28 +83,55 @@ export default function CategorySelectScreen() { referral: string; first_name: string; last_name: string; - district: number; + district: string; company_name: string; - address: number; + address: string; }) => auth_api.register(body), onSuccess: async () => { router.replace('/(auth)/register-confirm'); await AsyncStorage.setItem('phone', phone); }, - onError: (err: AxiosError) => { - const errMessage = (err.response?.data as any)?.data?.stir?.[0]; - const errMessageDetail = (err.response?.data as any)?.data?.detail; - const errMessageReffral = (err.response?.data as any).data.referral[0]; - const errMessageDetailData = (err.response?.data as any)?.data; + onError: (error: AxiosError) => { + const data = error.response?.data as any; - const message = - errMessage || - errMessageReffral || - errMessageDetail || - errMessageDetailData || - t('Xatolik yuz berdi'); + let message = t('Xatolik yuz berdi'); - Toast.error(String(message)); + if (data) { + const source = data.data || data; // ๐Ÿ”ฅ ba'zida data ichida keladi + + if (typeof source === 'object') { + const firstKey = Object.keys(source)[0]; + const firstValue = source[firstKey]; + + // โœ… 1๏ธโƒฃ Agar oddiy string array boโ€˜lsa + if (Array.isArray(firstValue) && typeof firstValue[0] === 'string') { + message = firstValue[0]; + } + + // โœ… 2๏ธโƒฃ Agar nested object boโ€˜lsa + else if (Array.isArray(firstValue) && typeof firstValue[0] === 'object') { + const firstErrorObj = firstValue[0]; + const innerKey = Object.keys(firstErrorObj)[0]; + const innerValue = firstErrorObj[innerKey]; + + if (Array.isArray(innerValue)) { + message = innerValue[0]; + } + } + + // โœ… 3๏ธโƒฃ Agar string boโ€˜lsa + else if (typeof firstValue === 'string') { + message = firstValue; + } + } + } + + // โ— Network error fallback + if (!error.response) { + message = error.message; + } + + Toast.error(message); } }); @@ -163,8 +199,8 @@ export default function CategorySelectScreen() { director_full_name, first_name, last_name, - district: Number(district), - address: Number(address), + district: district, + address: address, company_name }); }} diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx index 238bf67..37885cd 100644 --- a/components/AuthProvider.tsx +++ b/components/AuthProvider.tsx @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { router } from 'expo-router'; import { createContext, useContext, useEffect, useState } from 'react'; type AuthContextType = { @@ -32,8 +31,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const logout = async () => { await AsyncStorage.removeItem('access_token'); await AsyncStorage.removeItem('refresh_token'); - setIsAuthenticated(false); - router.replace('/(auth)'); + setIsAuthenticated(false) }; return ( diff --git a/components/ui/Header.tsx b/components/ui/Header.tsx index 4b0c3b7..b286672 100644 --- a/components/ui/Header.tsx +++ b/components/ui/Header.tsx @@ -31,7 +31,10 @@ export const CustomHeader = ({ {logoutbtn && ( - + { + await logout(); + router.replace('/(auth)'); + }}> )} diff --git a/constants/formatText.ts b/constants/formatText.ts index 1207ddf..888d9e9 100644 --- a/constants/formatText.ts +++ b/constants/formatText.ts @@ -16,8 +16,7 @@ const latinToCyrillicMap = [ ["'", ""] // apostrofni olib tashlaymiz ]; -export function formatText(str: string | null) { - if (!str) return null; +export function formatText(str: string) { let result = str; for (let [latin, cyrillic] of latinToCyrillicMap) { const regex = new RegExp(latin, "g"); @@ -32,6 +31,7 @@ const cyrillicToLatinMap = [ ["ะจ", "Sh"], ["ัˆ", "sh"], ["ะง", "Ch"], ["ั‡", "ch"], ["ั‘", "yo"], ["ะ", "YO"], + ["ะฏ", "Ya"], ["ั", "ya"], ["ะ", "A"], ["ะ‘", "B"], ["ะ”", "D"], ["ะ•", "E"], ["ะค", "F"], ["ะ“", "G"], ["าฒ", "H"], ["ะ˜", "I"], ["ะ–", "J"], ["ะš", "K"], ["ะ›", "L"], ["ะœ", "M"], ["ะ", "N"], ["ะž", "O"], ["ะŸ", "P"], @@ -44,8 +44,7 @@ const cyrillicToLatinMap = [ ["ะฒ", "v"], ["ั…", "x"], ["ะน", "y"], ["ะท", "z"], ]; -export function formatTextToLatin(str: string | null) { - if (!str) return null; +export function formatTextToLatin(str: string) { let result = str; for (let [cyrillic, latin] of cyrillicToLatinMap) { const regex = new RegExp(cyrillic, "g"); diff --git a/screens/auth/confirm/ConfirmScreen.tsx b/screens/auth/confirm/ConfirmScreen.tsx index 868f32e..9fb0617 100644 --- a/screens/auth/confirm/ConfirmScreen.tsx +++ b/screens/auth/confirm/ConfirmScreen.tsx @@ -65,7 +65,6 @@ const ConfirmScreen = () => { savedToken(res.data.data.token.access); await AsyncStorage.setItem('refresh_token', res.data.data.token.refresh); await login(res.data.data.token.access); - // **Push tokenni qayta serverga yuborish** const pushToken = await registerForPushNotificationsAsync(); if (pushToken) { @@ -78,6 +77,7 @@ const ConfirmScreen = () => { // Notification querylarni refetch queryClient.refetchQueries({ queryKey: ['notification-list'] }); queryClient.refetchQueries({ queryKey: ['notifications-list'] }); + queryClient.refetchQueries({ queryKey: ['get_me'] }); // Dashboardga yoโ€˜naltirish router.replace('/(dashboard)'); diff --git a/screens/auth/login/lib/api.ts b/screens/auth/login/lib/api.ts index 136951a..ad8bc2c 100644 --- a/screens/auth/login/lib/api.ts +++ b/screens/auth/login/lib/api.ts @@ -146,8 +146,8 @@ export const auth_api = { director_full_name: string; first_name: string; last_name: string; - district: number; - address: number; + district: string; + address: string; company_name: string; }) { const res = await httpClient.post(API_URLS.Register, body); diff --git a/screens/auth/register-confirm/ConfirmScreen.tsx b/screens/auth/register-confirm/ConfirmScreen.tsx index 5862ea9..96586f8 100644 --- a/screens/auth/register-confirm/ConfirmScreen.tsx +++ b/screens/auth/register-confirm/ConfirmScreen.tsx @@ -77,6 +77,7 @@ const RegisterConfirmScreen = () => { // Notification querylarni refetch queryClient.refetchQueries({ queryKey: ['notification-list'] }); queryClient.refetchQueries({ queryKey: ['notifications-list'] }); + queryClient.refetchQueries({ queryKey: ['get_me'] }); // Dashboardga yoโ€˜naltirish router.replace('/(dashboard)'); diff --git a/screens/auth/register/RegisterCategorySelection.tsx b/screens/auth/register/RegisterCategorySelection.tsx index a506739..4da767c 100644 --- a/screens/auth/register/RegisterCategorySelection.tsx +++ b/screens/auth/register/RegisterCategorySelection.tsx @@ -38,9 +38,9 @@ export default function CategorySelectScreen() { director_full_name: string; first_name: string; last_name: string; - district: number; + district: string; company_name: string; - address: number; + address: string; }) => auth_api.register(body), onSuccess: () => router.replace('/'), }); @@ -75,9 +75,9 @@ export default function CategorySelectScreen() { director_full_name: String(director_full_name), first_name: String(first_name), last_name: String(last_name), - district: Number(address), + district: address, company_name: String(company_name), - address: Number(address), + address: address, }); } }} diff --git a/screens/auth/register/RegisterForm.tsx b/screens/auth/register/RegisterForm.tsx index 8c20e80..7001191 100644 --- a/screens/auth/register/RegisterForm.tsx +++ b/screens/auth/register/RegisterForm.tsx @@ -1,7 +1,6 @@ import AuthHeader from '@/components/ui/AuthHeader'; import { decryptToken } from '@/constants/crypto'; import { formatPhone, normalizeDigits } from '@/constants/formatPhone'; -import { formatText, formatTextToLatin } from '@/constants/formatText'; import { products_api } from '@/screens/home/lib/api'; import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from '@gorhom/bottom-sheet'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -32,12 +31,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { auth_api, GetInfo } from '../login/lib/api'; import PhonePrefix from '../login/ui/PhonePrefix'; import { UseLoginForm } from '../login/ui/UseLoginForm'; - -interface CoordsData { - lat: number; - lon: number; - polygon: [number, number][][]; -} +import { useResolveLocation } from './lib/useResolveLocation'; export default function RegisterFormScreen() { const router = useRouter(); @@ -52,57 +46,71 @@ export default function RegisterFormScreen() { const [info, setInfo] = useState(null); const [loading, setLoading] = useState(false); const [referal, setReferal] = useState(''); - const [error, setError] = useState(null) - const [district, setDistrict] = useState(null) - const [region, setRegion] = useState(null) - const [token, setTokens] = useState<{ name: string, value: string } | null>(null) + const [error, setError] = useState(null); + const [token, setTokens] = useState<{ name: string, value: string } | null>(null); const [directorTinInput, setDirectorTinInput] = useState(''); + // โ”€โ”€โ”€ Token โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const { data } = useQuery({ queryKey: ["tokens"], queryFn: async () => auth_api.get_tokens(), select(data) { - return data.data.data.results + return data.data.data.results; }, - }) + }); useEffect(() => { if (data?.length) { - const token = data[0] - const tokenValue = decryptToken(token.value) + const token = data[0]; + const tokenValue = decryptToken(token.value); if (tokenValue) { - setTokens({ name: token.key, value: tokenValue }) + setTokens({ name: token.key, value: tokenValue }); } } - }, [data]) + }, [data]); + + // โ”€โ”€โ”€ Location resolver hook โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const { + location, + resolve: resolveLocation, + reset: resetLocation, + } = useResolveLocation({ + token: token?.value ?? null, + tokenName: token?.name ?? null, + }); + + console.log("location", location.district?.id); + + + // โ”€โ”€โ”€ STIR info mutation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const { mutate } = useMutation({ - mutationFn: (stir: string) => auth_api.get_info({ value: stir, token: token?.value || "", tokenName: token?.name || "" }), + mutationFn: (stir: string) => + auth_api.get_info({ + value: stir, + token: token?.value || "", + tokenName: token?.name || "", + }), onSuccess: (res) => { setInfo(res.data); setLoading(false); - setError(null) - setDistrict(res.data.address) + setError(null); + if (res.data?.address) { + resolveLocation(res.data.address); + } }, onError: () => { setInfo(null); setLoading(false); - setError("Foydalanuvchi topilmadi") + setError("Foydalanuvchi topilmadi"); + resetLocation(); }, }); - const { data: districts } = useQuery({ - queryKey: ["discrit"], - queryFn: async () => auth_api.get_district({ token: token?.value || "", tokenName: token?.name || "" }), - enabled: !!token, - }) - - const { data: regions } = useQuery({ - queryKey: ["regions"], - queryFn: async () => auth_api.get_region({ token: token?.value || "", tokenName: token?.name || "" }), - enabled: !!token, - }) + // โ”€โ”€โ”€ Country list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const { data: countryResponse, isLoading: countryLoading } = useQuery({ queryKey: ['country-detail'], @@ -110,109 +118,38 @@ export default function RegisterFormScreen() { select: (res) => res.data?.data || [], }); - const getRegionDistrictFromAddress = async (address: string) => { - try { - const encoded = encodeURIComponent(address + ", 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 (turgunboyevsamandar4@gamil.com)' } } - ); - const data = await res.json(); - - if (data.length > 0) { - const addr = data[0].address; - return { - district: addr.county || addr.district || addr.suburb, - region: addr.state, - }; - } - } catch (e) { } - return null; - }; - - useEffect(() => { - if (district) { - const dis = formatText(district)?.split(" ")[0].toLocaleUpperCase() - let reg = null - if (dis) { - reg = districts?.data.find((item) => item.name.includes(dis)) - }; - - if (reg) { - const region = regions?.data.find((item) => item.regionId == reg.regionId) - setRegion(region?.name || "") - } - } - }, [district]) - - - const [districtId, setDistrictId] = useState(null); - const [regionId, setRegionId] = useState(null); - - useEffect(() => { - if (!district || !countryResponse?.length) return; - - const resolve = async () => { - const geo = await getRegionDistrictFromAddress(district.split(" ")[0]); - - const searchRegion = geo?.region || region; - const searchDistrict = geo?.district || district - - for (const country of countryResponse) { - const regionName = formatTextToLatin(searchRegion)?.split(" ")[0]; - let foundRegion = null - if (regionName) { - foundRegion = country.region.find((r: any) => - formatTextToLatin(r.name)?.includes(regionName) - ); - } - - if (foundRegion) { - setRegionId(foundRegion.id); - setDistrictId(null); - return; - } - - for (const reg of country.region || []) { - const dis = formatTextToLatin(searchDistrict)?.split(" ")[0].toUpperCase(); - let foundDistrict = null - if (dis) { - foundDistrict = reg.districts.find((d: any) => { - return formatTextToLatin(d.name)?.toUpperCase().includes(dis.slice(0, 4)) - } - ); - }; - - if (foundDistrict) { - setDistrictId(foundDistrict.id); - setRegionId(null); - return; - } - } - } - }; - resolve(); - }, [district, countryResponse]); + // โ”€โ”€โ”€ Validation helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ useEffect(() => { if (info === null || (stir.length === 9 && info.name && info.fullName)) { - setError(null) + setError(null); } else if (info?.name === null || info?.fullName === null) { - setError("Sizning shaxsiy ma'lumotlaringiz topilmadi") + setError("Sizning shaxsiy ma'lumotlaringiz topilmadi"); } else if (!info?.selfEmployment && !info?.isItd) { - setError("Siz o'zini o'zi band qilgan yoki yakka tartibdagi tadbirkorlik bo'lishingiz kerak") + setError("Siz o'zini o'zi band qilgan yoki yakka tartibdagi tadbirkorlik bo'lishingiz kerak"); } - }, [info]) + }, [info]); const hasDirectorTin = info?.directorPinfl && String(info.directorPinfl).length > 0; - const isDirectorTinValid = !hasDirectorTin || directorTinInput === String(info.directorPinfl); const hasValidName = Boolean(info?.name || info?.fullName); + const valid = + phone.length === 9 && + (stir.length === 9 || stir.length === 14) && + info && + hasValidName && + isDirectorTinValid && + error === null; + + // โ”€โ”€โ”€ Country sheet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const filteredCountries = useMemo(() => { if (!countrySearch.trim()) return countryResponse || []; const q = countrySearch.toLowerCase().trim(); - return (countryResponse || []).filter((c: any) => c.name?.toLowerCase().includes(q)); + return (countryResponse || []).filter((c: any) => + c.name?.toLowerCase().includes(q) + ); }, [countryResponse, countrySearch]); const openCountrySheet = useCallback(() => { @@ -225,8 +162,9 @@ export default function RegisterFormScreen() { const selectedCountryName = useMemo(() => { if (!selectedCountry) return t('Tanlang'); return ( - countryResponse?.find((c: any) => c.flag?.toUpperCase() === selectedCountry)?.name || - t('Tanlang') + countryResponse?.find( + (c: any) => c.flag?.toUpperCase() === selectedCountry + )?.name || t('Tanlang') ); }, [selectedCountry, countryResponse, t]); @@ -248,13 +186,7 @@ export default function RegisterFormScreen() { setTimeout(() => setCountrySearch(''), 300); }, []); - const valid = - phone.length === 9 && - (stir.length === 9 || stir.length === 14) && - info && - hasValidName && - isDirectorTinValid && - error === null; + // โ”€โ”€โ”€ Render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ return ( <> @@ -295,49 +227,44 @@ export default function RegisterFormScreen() { - {t('Ro\'yxatdan o\'tish')} + {t("Ro'yxatdan o'tish")} + + {/* Country */} - - {t('Davlat')} - - {countryLoading ? ( - - ) : ( - <> - - - {selectedCountryName} - - - )} - - - + {t('Davlat')} + + {countryLoading ? ( + + ) : ( + <> + + + {selectedCountryName} + + + )} + + + {/* STIR */} {t('STIR')} @@ -351,14 +278,10 @@ export default function RegisterFormScreen() { onChangeText={(text) => { const v = normalizeDigits(text).slice(0, 14); setStir(v); - if (v.length === 9 || v.length === 14) { setLoading(true); + resetLocation(); mutate(v); - setRegionId(null) - setDistrictId(null) - setRegion(null) - setDistrict(null) } }} /> @@ -366,6 +289,7 @@ export default function RegisterFormScreen() { + {/* Referal */} {t('Referal')} @@ -382,6 +306,7 @@ export default function RegisterFormScreen() { + {/* Phone */} {t('Telefon raqami')} @@ -397,10 +322,16 @@ export default function RegisterFormScreen() { + {/* Director TIN */} {hasDirectorTin && ( {t('Direktor STIR')} - + setDirectorTinInput(normalizeDigits(t))} /> - {directorTinInput.length === 14 && !isDirectorTinValid && ( - {t('Direktor STIR notoโ€˜gโ€˜ri')} + {t("Direktor STIR noto'g'ri")} )} )} - {error !== null ? + {/* Info / Error */} + {error !== null ? ( {t(error)} - : info && hasValidName && - {info.fullName || info.name} - } + ) : ( + info && hasValidName && ( + {info.fullName || info.name} + ) + )} + {/* Submit */} {t('Davom etish')} + - - + + + {/* Country BottomSheet */} - + ); } @@ -580,13 +521,6 @@ const styles = StyleSheet.create({ marginBottom: 8, letterSpacing: 0.5, }, - subtitle: { - fontSize: 14, - color: '#94a3b8', - textAlign: 'center', - lineHeight: 20, - paddingHorizontal: 10, - }, card: { backgroundColor: '#ffffff', borderRadius: 28, @@ -617,6 +551,15 @@ const styles = StyleSheet.create({ borderColor: '#e2e8f0', gap: 8, }, + inputDisabled: { + backgroundColor: '#f1f5f9', + borderColor: '#e2e8f0', + }, + textInput: { + flex: 1, + fontSize: 15, + color: '#1e293b', + }, bottomSheetBg: { backgroundColor: '#ffffff', borderTopLeftRadius: 24, @@ -708,55 +651,6 @@ const styles = StyleSheet.create({ color: '#94a3b8', fontWeight: '500', }, - textInput: { - flex: 1, - fontSize: 15, - color: '#1e293b', - }, - passportRow: { - flexDirection: 'row', - gap: 10, - }, - passportSeries: { - width: 100, - flex: undefined, - }, - passportNumber: { - flex: 1, - }, - infoBox: { - backgroundColor: '#f0fdf4', - padding: 14, - marginTop: 10, - borderRadius: 14, - borderWidth: 1, - borderColor: '#bbf7d0', - }, - infoLabel: { - fontSize: 11, - fontWeight: '600' as const, - color: '#059669', - marginBottom: 2, - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - infoText: { - fontWeight: '700' as const, - color: '#166534', - fontSize: 14, - }, - errorBox: { - backgroundColor: '#fef2f2', - padding: 14, - borderRadius: 14, - borderWidth: 1, - borderColor: '#fecaca', - }, - errorText: { - fontWeight: '700' as const, - color: '#dc2626', - fontSize: 14, - }, btn: { height: 54, backgroundColor: '#2563eb', @@ -795,11 +689,6 @@ const styles = StyleSheet.create({ borderRadius: 175, backgroundColor: 'rgba(16, 185, 129, 0.08)', }, - inputDisabled: { - backgroundColor: '#f1f5f9', - borderColor: '#e2e8f0', - }, - info: { padding: 12, borderRadius: 12, diff --git a/screens/auth/register/lib/useResolveLocation.ts b/screens/auth/register/lib/useResolveLocation.ts new file mode 100644 index 0000000..585a26a --- /dev/null +++ b/screens/auth/register/lib/useResolveLocation.ts @@ -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 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 }; +} diff --git a/screens/profile/ui/NotificationTab.tsx b/screens/profile/ui/NotificationTab.tsx index 652a421..ef00db4 100644 --- a/screens/profile/ui/NotificationTab.tsx +++ b/screens/profile/ui/NotificationTab.tsx @@ -98,31 +98,7 @@ export function NotificationTab() { contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} ListHeaderComponent={() => { - if (notifications.length === 0) { - return ( - - - {t("Hozircha bildirishnomalar yo'q")} - - - - {t("Yangi xabarlar shu yerda paydo boโ€˜ladi")} - - - ); - } - - if (notifications.some((n) => !n.is_read)) { + if (notifications.length > 0 && notifications.some((n) => !n.is_read)) { return ( - - {t("Barcha bildirishnomalar oโ€˜qilgan")} - + {notifications.length > 0 && ( + + {t("Barcha bildirishnomalar oโ€˜qilgan")} + + )} ); }}