diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx index 1b6880d69..1b610003a 100644 --- a/src/app/(mobile-ui)/history/page.tsx +++ b/src/app/(mobile-ui)/history/page.tsx @@ -9,24 +9,21 @@ import NavHeader from '@/components/Global/NavHeader' import PeanutLoading from '@/components/Global/PeanutLoading' import { TransactionBadge } from '@/components/Global/TransactionBadge' import { useWallet } from '@/hooks/wallet/useWallet' -import { IDashboardItem } from '@/interfaces' import { formatAmount, formatDate, + formatPaymentStatus, getChainLogo, getHeaderTitle, getHistoryTransactionStatus, getTokenLogo, isStableCoin, - printableAddress, - formatPaymentStatus, } from '@/utils' import * as Sentry from '@sentry/nextjs' -import { useInfiniteQuery } from '@tanstack/react-query' +import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { useEffect, useRef, useState } from 'react' -import { isAddress } from 'viem' +import { useEffect, useRef } from 'react' const ITEMS_PER_PAGE = 10 @@ -34,24 +31,20 @@ const HistoryPage = () => { const pathname = usePathname() const { address } = useWallet() const { composeLinkDataArray, fetchLinkDetailsAsync } = useDashboard() - const [dashboardData, setDashboardData] = useState([]) - const [isLoadingDashboard, setIsLoadingDashboard] = useState(true) const loaderRef = useRef(null) - useEffect(() => { - let isStale = false - setIsLoadingDashboard(true) - composeLinkDataArray(address ?? '').then((data) => { - if (isStale) return - setDashboardData(data) - setIsLoadingDashboard(false) - }) - return () => { - isStale = true - } - }, [address]) + const { data: dashboardData, isLoading: isLoadingDashboard } = useQuery({ + queryKey: ['dashboardData', address], + queryFn: () => composeLinkDataArray(address ?? ''), + enabled: !!address, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + placeholderData: keepPreviousData, + }) const fetchHistoryPage = async ({ pageParam = 0 }) => { + if (!dashboardData) return { items: [], nextPage: undefined } + const start = pageParam * ITEMS_PER_PAGE const end = start + ITEMS_PER_PAGE const pageData = dashboardData.slice(start, end) @@ -90,16 +83,19 @@ const HistoryPage = () => { return { items: formattedData, - nextPage: end < dashboardData.length ? pageParam + 1 : undefined, + nextPage: end < (dashboardData?.length || 0) ? pageParam + 1 : undefined, } } const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status, isLoading, error } = useInfiniteQuery({ - queryKey: ['history', address], + queryKey: ['history', address, dashboardData], queryFn: fetchHistoryPage, getNextPageParam: (lastPage) => lastPage.nextPage, - enabled: dashboardData.length > 0, + enabled: !!dashboardData && dashboardData.length > 0, initialPageParam: 0, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + placeholderData: keepPreviousData, }) useEffect(() => { @@ -122,7 +118,7 @@ const HistoryPage = () => { return () => observer.disconnect() }, [hasNextPage, isFetchingNextPage, fetchNextPage]) - if (isLoadingDashboard || isLoading) { + if ((isLoadingDashboard || isLoading) && (!dashboardData || !data?.pages?.length)) { return } @@ -132,7 +128,7 @@ const HistoryPage = () => { return
Error loading history: {error?.message}
} - if (dashboardData.length === 0) { + if (!dashboardData || dashboardData.length === 0) { return (
+ const isLoading = (isFetchingWallets && !wallets.length) || (isFetchingUser && !username) + + // use effect to delay showing content + useEffect(() => { + if (!isLoading) { + // small delay to ensure everything is loaded + const timer = setTimeout(() => { + setContentReady(true) + }, 100) + + return () => clearTimeout(timer) + } else { + setContentReady(false) + } + }, [isLoading]) + + // show loading if we're loading or content isn't ready yet + if (isLoading || !contentReady) { + return } return ( @@ -173,6 +193,7 @@ export default function Home() {
+
{ const pathName = usePathname() + const { isFetchingUser, user } = useAuth() const [isReady, setIsReady] = useState(false) - const { user } = useAuth() - - useEffect(() => { - setIsReady(true) - }, []) + const [hasToken, setHasToken] = useState(false) const isHome = pathName === '/home' const isHistory = pathName === '/history' @@ -36,7 +35,20 @@ const Layout = ({ children }: { children: React.ReactNode }) => { return isPublicPath || (user?.user.hasPwAccess ?? false) || !peanutWalletIsInPreview }, [user, pathName]) - if (!isReady) return null + useEffect(() => { + // check for JWT token + setHasToken(hasValidJwtToken()) + + setIsReady(true) + }, []) + + if (!isReady || (isFetchingUser && !user && !hasToken)) + return ( +
+ +
+ ) + return (
{/* Wrapper div for desktop layout */} diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index 40b94a0fd..f66435556 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -1,11 +1,12 @@ 'use client' +import PeanutLoading from '@/components/Global/PeanutLoading' import { SetupWrapper } from '@/components/Setup/components/SetupWrapper' -import { useSetupFlow } from '@/hooks/useSetupFlow' -import { useSetupStore, useAppDispatch } from '@/redux/hooks' -import { useEffect, useState } from 'react' import { BeforeInstallPromptEvent } from '@/components/Setup/Setup.types' +import { useSetupFlow } from '@/hooks/useSetupFlow' +import { useAppDispatch, useSetupStore } from '@/redux/hooks' import { setupActions } from '@/redux/slices/setup-slice' +import { useEffect, useState } from 'react' export default function SetupPage() { const { steps } = useSetupStore() @@ -17,10 +18,12 @@ export default function SetupPage() { const [deviceType, setDeviceType] = useState<'ios' | 'android' | 'desktop'>('desktop') const dispatch = useAppDispatch() const [unsupportedBrowser, setUnsupportedBrowser] = useState(false) + const [isLoading, setIsLoading] = useState(true) useEffect(() => { // Check if the browser supports passkeys const checkPasskeySupport = async () => { + setIsLoading(true) try { const hasPasskeySupport = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() setUnsupportedBrowser(!hasPasskeySupport) @@ -39,6 +42,8 @@ export default function SetupPage() { if (unsupportedBrowserIndex !== -1) { dispatch(setupActions.setStep(unsupportedBrowserIndex + 1)) } + } finally { + setIsLoading(false) } } @@ -84,6 +89,13 @@ export default function SetupPage() { } }, [step, currentStepIndex, steps]) + if (isLoading) + return ( +
+ +
+ ) + // todo: add loading state if (!step) return null diff --git a/src/components/Global/PeanutLoading/index.tsx b/src/components/Global/PeanutLoading/index.tsx index ee86e0455..c408806d2 100644 --- a/src/components/Global/PeanutLoading/index.tsx +++ b/src/components/Global/PeanutLoading/index.tsx @@ -1,9 +1,15 @@ import { PEANUTMAN_LOGO } from '@/assets' +import { twMerge } from 'tailwind-merge' -export default function PeanutLoading() { +export default function PeanutLoading({ coverFullScreen = false }: { coverFullScreen?: boolean }) { return ( -
-
+
+
logo Loading...
diff --git a/src/components/Home/HomeWaitlist.tsx b/src/components/Home/HomeWaitlist.tsx index 9cd35b4a7..dcfad84ee 100644 --- a/src/components/Home/HomeWaitlist.tsx +++ b/src/components/Home/HomeWaitlist.tsx @@ -5,6 +5,7 @@ import { useEffect } from 'react' import { Card } from '../0_Bruddle' import RollingNumber from '../0_Bruddle/RollingNumber' import Title from '../0_Bruddle/Title' +import PeanutLoading from '../Global/PeanutLoading' const HomeWaitlist = () => { const { push } = useRouter() @@ -17,11 +18,7 @@ const HomeWaitlist = () => { }, [username, isFetchingUser, push]) if (isFetchingUser) { - return ( -
- peanut-club -
- ) + return } return ( diff --git a/src/components/Setup/components/SetupWrapper.tsx b/src/components/Setup/components/SetupWrapper.tsx index 1a76ae902..d467d2e09 100644 --- a/src/components/Setup/components/SetupWrapper.tsx +++ b/src/components/Setup/components/SetupWrapper.tsx @@ -2,12 +2,12 @@ import starImage from '@/assets/icons/star.png' import { Button } from '@/components/0_Bruddle' import CloudsBackground from '@/components/0_Bruddle/CloudsBackground' import Icon from '@/components/Global/Icon' +import { BeforeInstallPromptEvent, LayoutType, ScreenId } from '@/components/Setup/Setup.types' +import InstallPWA from '@/components/Setup/Views/InstallPWA' import classNames from 'classnames' import Image from 'next/image' -import { ReactNode, memo, Children, cloneElement, type ReactElement } from 'react' +import { Children, ReactNode, cloneElement, memo, type ReactElement } from 'react' import { twMerge } from 'tailwind-merge' -import { LayoutType, ScreenId, BeforeInstallPromptEvent } from '@/components/Setup/Setup.types' -import InstallPWA from '@/components/Setup/Views/InstallPWA' /** * props interface for the SetupWrapper component diff --git a/src/hooks/query/user.ts b/src/hooks/query/user.ts index de1d3d30a..72c595b2d 100644 --- a/src/hooks/query/user.ts +++ b/src/hooks/query/user.ts @@ -4,7 +4,7 @@ import { useAppDispatch, useUserStore } from '@/redux/hooks' import { userActions } from '@/redux/slices/user-slice' import { fetchWithSentry } from '@/utils' import { hitUserMetric } from '@/utils/metrics.utils' -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { usePWAStatus } from '../usePWAStatus' export const useUserQuery = (dependsOn?: boolean) => { @@ -45,5 +45,7 @@ export const useUserQuery = (dependsOn?: boolean) => { refetchOnMount: false, // add initial data from Redux if available initialData: authUser || undefined, + // keep previous data + placeholderData: keepPreviousData, }) } diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 61402d7e7..737e14e60 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -24,7 +24,7 @@ import { useAppDispatch, useWalletStore } from '@/redux/hooks' import { walletActions } from '@/redux/slices/wallet-slice' import { areEvmAddressesEqual, backgroundColorFromAddress, fetchWalletBalances, formatAmount } from '@/utils' import * as Sentry from '@sentry/nextjs' -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo } from 'react' import { erc20Abi, formatUnits, getAddress, parseUnits } from 'viem' import { useAccount } from 'wagmi' @@ -234,15 +234,18 @@ export const useWallet = () => { queryKey: ['wallets', user, user?.accounts, wagmiAddress], queryFn: mergeAndProcessWallets, enabled: !!wagmiAddress || !!user, - staleTime: 30 * 1000, // 30 seconds - gcTime: 1 * 60 * 1000, // 1 minute + staleTime: 60 * 1000, // 60 seconds + gcTime: 5 * 60 * 1000, // 5 minutes + placeholderData: keepPreviousData, }) useEffect(() => { - if (isWalletsQueryLoading) { + if (isWalletsQueryLoading && !wallets.length) { dispatch(walletActions.setIsFetchingWallets(true)) + } else if (!isWalletsQueryLoading) { + dispatch(walletActions.setIsFetchingWallets(false)) } - }, [isWalletsQueryLoading]) + }, [isWalletsQueryLoading, wallets.length]) const selectedWallet = useMemo(() => { if (!selectedWalletId || !wallets.length) return undefined diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 000000000..3f59160f6 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,36 @@ +/** + * checks if there's a valid JWT token in cookies and if it hasn't expired + * @returns boolean indicating if a valid JWT token exists + */ +export const hasValidJwtToken = (): boolean => { + if (typeof document === 'undefined') return false + + const cookies = document.cookie.split(';') + const jwtCookie = cookies.find((cookie) => cookie.trim().startsWith('jwt-token=')) + + if (!jwtCookie) return false + + const token = jwtCookie.split('=')[1] + if (!token) return false + + // validation - check if token has three parts separated by dots + const parts = token.split('.') + if (parts.length !== 3) return false + + try { + // decode payload (middle part) to check expiration + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) + + // check if token has an expiration claim + if (!payload.exp) return true // if no expiration, consider it valid + + // check if token has expired + const expirationTime = payload.exp * 1000 // convert to milliseconds + const currentTime = Date.now() + + return currentTime < expirationTime + } catch (error) { + console.error('Error parsing JWT token:', error) + return false + } +}