Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions src/components/AddWithdraw/AddWithdrawCountriesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('-')
Expand Down
14 changes: 11 additions & 3 deletions src/components/AddWithdraw/AddWithdrawRouterView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -64,6 +64,7 @@ export const AddWithdrawRouterView: FC<AddWithdrawRouterViewProps> = ({
// local flag only for add flow; for withdraw we derive from context
const [localShowAllMethods, setLocalShowAllMethods] = useState<boolean>(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
Expand Down Expand Up @@ -270,19 +271,26 @@ export const AddWithdrawRouterView: FC<AddWithdrawRouterViewProps> = ({
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}
Expand Down
37 changes: 28 additions & 9 deletions src/components/Common/CountryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string | null>(null)

const supportedCountries = countryData.filter((country) => country.type === 'country')

Expand All @@ -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 (
<div className="flex h-full w-full flex-1 flex-col justify-start gap-4">
Expand Down Expand Up @@ -152,11 +157,22 @@ export const CountryList = ({
<SearchResultCard
key={country.id}
title={country.title}
rightContent={customRight ?? (!isSupported && <StatusBadge status="soon" />)}
rightContent={
customRight ??
(clickedCountryId === country.id ? (
<Loading />
) : !isSupported ? (
<StatusBadge status="soon" />
) : 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={
<div className="relative h-8 w-8">
<Image
Expand All @@ -165,6 +181,9 @@ export const CountryList = ({
width={80}
height={80}
className="h-8 w-8 rounded-full object-cover"
// priority load first 10 flags for better perceived performance
priority={index < 10}
loading={index < 10 ? 'eager' : 'lazy'}
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
Expand Down
47 changes: 42 additions & 5 deletions src/hooks/useGeoLocation.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,62 @@
'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<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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 {
Expand Down
Loading