diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 1553d72a8..3b91026e7 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -65,10 +65,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } }, [user?.user.bridgeKycStatus]) - useEffect(() => { - fetchUser() - }, []) - const countryPathParts = Array.isArray(params.country) ? params.country : [params.country] const isBankPage = countryPathParts[countryPathParts.length - 1] === 'bank' const countrySlugFromUrl = isBankPage ? countryPathParts.slice(0, -1).join('-') : countryPathParts.join('-') diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx index a27abba93..8f75a730c 100644 --- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx +++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx @@ -4,7 +4,7 @@ import { DepositMethod, DepositMethodList } from '@/components/AddMoney/componen import NavHeader from '@/components/Global/NavHeader' import { RecentMethod, getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import { useRouter } from 'next/navigation' -import { FC, useEffect, useState, useCallback } from 'react' +import { FC, useEffect, useState, useTransition, useCallback } from 'react' import { useUserStore } from '@/redux/hooks' import { AccountType, Account } from '@/interfaces' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' @@ -64,6 +64,7 @@ export const AddWithdrawRouterView: FC = ({ // local flag only for add flow; for withdraw we derive from context const [localShowAllMethods, setLocalShowAllMethods] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false) + const [, startTransition] = useTransition() // determine if we should show the full list of methods (countries/crypto) instead of the default view const shouldShowAllMethods = flow === 'withdraw' ? showAllWithdrawMethods : localShowAllMethods @@ -270,19 +271,26 @@ export const AddWithdrawRouterView: FC = ({ if (flow === 'add' && user) { saveRecentMethod(user.user.userId, country, countryPath) } - router.push(countryPath) + + // use transition for smoother navigation, keeps ui responsive during route change + startTransition(() => { + router.push(countryPath) + }) }} onCryptoClick={() => { if (flow === 'add') { setIsDrawerOpen(true) } else { + const cryptoPath = `${baseRoute}/crypto` // Set crypto method and navigate to main page for amount input setSelectedMethod({ type: 'crypto', countryPath: 'crypto', title: 'Crypto', }) - router.push('/withdraw') + startTransition(() => { + router.push(cryptoPath) + }) } }} flow={flow} diff --git a/src/components/Common/CountryList.tsx b/src/components/Common/CountryList.tsx index 1299ca9f2..90be56456 100644 --- a/src/components/Common/CountryList.tsx +++ b/src/components/Common/CountryList.tsx @@ -10,12 +10,13 @@ import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { SearchInput } from '@/components/SearchUsers/SearchInput' import { SearchResultCard } from '@/components/SearchUsers/SearchResultCard' import Image from 'next/image' -import { useMemo, useState, type ReactNode } from 'react' +import { useMemo, useState, useDeferredValue, type ReactNode } from 'react' import { getCardPosition } from '../Global/Card' import { useGeoLocation } from '@/hooks/useGeoLocation' import { CountryListSkeleton } from './CountryListSkeleton' import AvatarWithBadge from '../Profile/AvatarWithBadge' import StatusBadge from '../Global/Badges/StatusBadge' +import Loading from '../Global/Loading' interface CountryListViewProps { inputTitle: string @@ -46,7 +47,11 @@ export const CountryList = ({ getRightContent, }: CountryListViewProps) => { const [searchTerm, setSearchTerm] = useState('') + // use deferred value to prevent blocking ui during search + const deferredSearchTerm = useDeferredValue(searchTerm) const { countryCode: userGeoLocationCountryCode, isLoading: isGeoLoading } = useGeoLocation() + // track which country is being clicked to show loading state + const [clickedCountryId, setClickedCountryId] = useState(null) const supportedCountries = countryData.filter((country) => country.type === 'country') @@ -68,16 +73,16 @@ export const CountryList = ({ }) }, [userGeoLocationCountryCode]) - // filter countries based on search term + // filter countries based on deferred search term to prevent blocking ui const filteredCountries = useMemo(() => { - if (!searchTerm) return sortedCountries + if (!deferredSearchTerm) return sortedCountries return sortedCountries.filter( (country) => - country.title.toLowerCase().includes(searchTerm.toLowerCase()) || - country.currency?.toLowerCase().includes(searchTerm.toLowerCase()) + country.title.toLowerCase().includes(deferredSearchTerm.toLowerCase()) || + country.currency?.toLowerCase().includes(deferredSearchTerm.toLowerCase()) ) - }, [searchTerm, sortedCountries]) + }, [deferredSearchTerm, sortedCountries]) return (
@@ -152,11 +157,22 @@ export const CountryList = ({ )} + rightContent={ + customRight ?? + (clickedCountryId === country.id ? ( + + ) : !isSupported ? ( + + ) : undefined) + } description={country.currency} - onClick={() => onCountryClick(country)} + onClick={() => { + // set loading state immediately for visual feedback + setClickedCountryId(country.id) + onCountryClick(country) + }} position={position} - isDisabled={!isSupported} + isDisabled={!isSupported || clickedCountryId === country.id} leftIcon={
{ e.currentTarget.style.display = 'none' }} diff --git a/src/hooks/useGeoLocation.ts b/src/hooks/useGeoLocation.ts index 7fc6ce33a..fbe439d1f 100644 --- a/src/hooks/useGeoLocation.ts +++ b/src/hooks/useGeoLocation.ts @@ -1,9 +1,18 @@ 'use client' import { useEffect, useState } from 'react' +// cache key for session storage +const GEO_CACHE_KEY = 'user_geo_country_code' +const GEO_CACHE_TIMESTAMP_KEY = 'user_geo_country_code_timestamp' +const CACHE_DURATION = 24 * 60 * 60 * 1000 // 24 hours in milliseconds + +// in-memory cache to share across all hook instances in the same session +let memoryCache: { countryCode: string | null; timestamp: number } | null = null + /** - * Used to get the user's country code from ipapi.co - * @returns {object} An object containing the country code, whether the request is loading, and any error that occurred + * used to get the user's country code from ipapi.co with caching + * caches result in sessionStorage and memory to avoid refetching on every mount + * @returns {object} an object containing the country code, whether the request is loading, and any error that occurred */ export const useGeoLocation = () => { const [countryCode, setCountryCode] = useState(null) @@ -11,15 +20,43 @@ export const useGeoLocation = () => { const [error, setError] = useState(null) useEffect(() => { - // use ipapi.co to get the user's country code const fetchCountry = async () => { try { + // check memory cache first (fastest) + if (memoryCache && Date.now() - memoryCache.timestamp < CACHE_DURATION) { + setCountryCode(memoryCache.countryCode) + setIsLoading(false) + return + } + + // check sessionStorage cache (survives component unmount/remount) + const cachedCode = sessionStorage.getItem(GEO_CACHE_KEY) + const cachedTimestamp = sessionStorage.getItem(GEO_CACHE_TIMESTAMP_KEY) + + if (cachedCode && cachedTimestamp) { + const timestamp = parseInt(cachedTimestamp, 10) + if (Date.now() - timestamp < CACHE_DURATION) { + // use cached value + setCountryCode(cachedCode) + memoryCache = { countryCode: cachedCode, timestamp } + setIsLoading(false) + return + } + } + + // no valid cache, fetch from api const response = await fetch('https://ipapi.co/country') if (!response.ok) { throw new Error('Failed to fetch country') } - const countryCode = await response.text() - setCountryCode(countryCode) + const fetchedCountryCode = await response.text() + const timestamp = Date.now() + + // save to both caches + setCountryCode(fetchedCountryCode) + sessionStorage.setItem(GEO_CACHE_KEY, fetchedCountryCode) + sessionStorage.setItem(GEO_CACHE_TIMESTAMP_KEY, timestamp.toString()) + memoryCache = { countryCode: fetchedCountryCode, timestamp } } catch (err: any) { setError(err.message) } finally {