From 6b25f3e0561c2937c31ed9bda418119c85489c3b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 1 Oct 2025 20:39:28 +0530 Subject: [PATCH 1/2] fix: add loading feedback in country list for smooth ux --- .../AddWithdraw/AddWithdrawCountriesList.tsx | 4 -- .../AddWithdraw/AddWithdrawRouterView.tsx | 12 +++-- src/components/Common/CountryList.tsx | 36 ++++++++++---- src/hooks/useGeoLocaion.ts | 47 +++++++++++++++++-- 4 files changed, 78 insertions(+), 21 deletions(-) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 4b2a0ec55..889f1a1c8 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -64,10 +64,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 189747014..1588bc0e1 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 } from 'react' +import { FC, useEffect, useState, useTransition } from 'react' import { useUserStore } from '@/redux/hooks' import { AccountType, Account } from '@/interfaces' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' @@ -41,6 +41,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 @@ -234,14 +235,19 @@ export const AddWithdrawRouterView: FC = ({ viewMode="add-withdraw" onCountryClick={(country) => { const countryPath = `${baseRoute}/${country.path}` - 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` - router.push(cryptoPath) + startTransition(() => { + router.push(cryptoPath) + }) } }} flow={flow} diff --git a/src/components/Common/CountryList.tsx b/src/components/Common/CountryList.tsx index 4d467357d..c6fe8497c 100644 --- a/src/components/Common/CountryList.tsx +++ b/src/components/Common/CountryList.tsx @@ -4,12 +4,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 } from 'react' +import { useMemo, useState, useDeferredValue } from 'react' import { getCardPosition } from '../Global/Card' import { useGeoLocaion } from '@/hooks/useGeoLocaion' import { CountryListSkeleton } from './CountryListSkeleton' import AvatarWithBadge from '../Profile/AvatarWithBadge' import StatusBadge from '../Global/Badges/StatusBadge' +import Loading from '../Global/Loading' interface CountryListViewProps { inputTitle: string @@ -32,7 +33,11 @@ interface CountryListViewProps { */ export const CountryList = ({ inputTitle, viewMode, onCountryClick, onCryptoClick, flow }: CountryListViewProps) => { const [searchTerm, setSearchTerm] = useState('') + // use deferred value to prevent blocking ui during search + const deferredSearchTerm = useDeferredValue(searchTerm) const { countryCode: userGeoLocationCountryCode, isLoading: isGeoLoading } = useGeoLocaion() + // track which country is being clicked to show loading state + const [clickedCountryId, setClickedCountryId] = useState(null) const supportedCountries = useMemo(() => { return countryData.filter((country) => country.type === 'country') @@ -56,16 +61,16 @@ export const CountryList = ({ inputTitle, viewMode, onCountryClick, onCryptoClic }) }, [supportedCountries, userGeoLocationCountryCode, isGeoLoading]) - // 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 (
@@ -115,11 +120,21 @@ export const CountryList = ({ inputTitle, viewMode, onCountryClick, onCryptoClic } + rightContent={ + 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/useGeoLocaion.ts b/src/hooks/useGeoLocaion.ts index 816f4032a..f9e58afdd 100644 --- a/src/hooks/useGeoLocaion.ts +++ b/src/hooks/useGeoLocaion.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 useGeoLocaion = () => { const [countryCode, setCountryCode] = useState(null) @@ -11,15 +20,43 @@ export const useGeoLocaion = () => { 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 { From 67b64d487658ca8c389e1255febdc3543296e0aa Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 1 Oct 2025 20:42:22 +0530 Subject: [PATCH 2/2] fix: type in hook name --- src/components/Common/CountryList.tsx | 4 ++-- src/hooks/{useGeoLocaion.ts => useGeoLocation.ts} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/hooks/{useGeoLocaion.ts => useGeoLocation.ts} (98%) diff --git a/src/components/Common/CountryList.tsx b/src/components/Common/CountryList.tsx index c6fe8497c..174bee7b0 100644 --- a/src/components/Common/CountryList.tsx +++ b/src/components/Common/CountryList.tsx @@ -6,7 +6,7 @@ import { SearchResultCard } from '@/components/SearchUsers/SearchResultCard' import Image from 'next/image' import { useMemo, useState, useDeferredValue } from 'react' import { getCardPosition } from '../Global/Card' -import { useGeoLocaion } from '@/hooks/useGeoLocaion' +import { useGeoLocation } from '@/hooks/useGeoLocation' import { CountryListSkeleton } from './CountryListSkeleton' import AvatarWithBadge from '../Profile/AvatarWithBadge' import StatusBadge from '../Global/Badges/StatusBadge' @@ -35,7 +35,7 @@ export const CountryList = ({ inputTitle, viewMode, onCountryClick, onCryptoClic const [searchTerm, setSearchTerm] = useState('') // use deferred value to prevent blocking ui during search const deferredSearchTerm = useDeferredValue(searchTerm) - const { countryCode: userGeoLocationCountryCode, isLoading: isGeoLoading } = useGeoLocaion() + const { countryCode: userGeoLocationCountryCode, isLoading: isGeoLoading } = useGeoLocation() // track which country is being clicked to show loading state const [clickedCountryId, setClickedCountryId] = useState(null) diff --git a/src/hooks/useGeoLocaion.ts b/src/hooks/useGeoLocation.ts similarity index 98% rename from src/hooks/useGeoLocaion.ts rename to src/hooks/useGeoLocation.ts index f9e58afdd..fbe439d1f 100644 --- a/src/hooks/useGeoLocaion.ts +++ b/src/hooks/useGeoLocation.ts @@ -14,7 +14,7 @@ let memoryCache: { countryCode: string | null; timestamp: number } | null = null * 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 useGeoLocaion = () => { +export const useGeoLocation = () => { const [countryCode, setCountryCode] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null)