From 3e30648d9fadff9fa26be8b155c2f3a1e679cbb0 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 9 Feb 2026 11:49:57 +0000 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=90=9B=20fix:=20infinite=20spinner=20?= =?UTF-8?q?on=20/home=20when=20jwt=20expires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the loading gate in the mobile-ui layout blocked on !hasToken, which was set once on mount via hasValidJwtToken() and never updated. when the jwt expired after 30 days, the backend returned 401 but the cookie persisted (getJWTCookie refreshes its maxAge on every call), leaving the app in a dead state: hasToken=false permanently, spinner forever. - remove hasToken/hasValidJwtToken from the loading gate. the gate now blocks only on transient conditions (isFetchingUser, isCheckingAccount, needsRedirect) and !user (resolves once redirect navigates away) - upgrade redirect to router.replace to avoid stacking history entries - add 3s hard navigation fallback for pwa contexts where soft navigation can silently fail --- src/app/(mobile-ui)/layout.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index d3c4bd1bf..4b458be20 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -7,7 +7,6 @@ import WalletNavigation from '@/components/Global/WalletNavigation' import OfflineScreen from '@/components/Global/OfflineScreen' import { ThemeProvider } from '@/config' import { useAuth } from '@/context/authContext' -import { hasValidJwtToken } from '@/utils/auth' import classNames from 'classnames' import { usePathname } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' @@ -32,7 +31,6 @@ const Layout = ({ children }: { children: React.ReactNode }) => { const { isFetchingUser, user } = useAuth() const [isReady, setIsReady] = useState(false) - const [hasToken, setHasToken] = useState(false) const isUserLoggedIn = !!user?.user.userId || false const isHome = pathName === '/home' const isHistory = pathName === '/history' @@ -48,9 +46,6 @@ const Layout = ({ children }: { children: React.ReactNode }) => { const scrollableContentRef = useRef(null) useEffect(() => { - // check for JWT token - setHasToken(hasValidJwtToken()) - setIsReady(true) }, []) @@ -80,9 +75,17 @@ const Layout = ({ children }: { children: React.ReactNode }) => { // enable pull-to-refresh for both ios and android usePullToRefresh({ shouldPullToRefresh }) + const isRedirecting = useRef(false) + useEffect(() => { - if (!isPublicPath && isReady && !isFetchingUser && !user) { - router.push('/setup') + if (!isPublicPath && isReady && !isFetchingUser && !user && !isRedirecting.current) { + isRedirecting.current = true + router.replace('/setup') + // hard navigation fallback in case soft navigation silently fails + const fallback = setTimeout(() => { + window.location.replace('/setup') + }, 3000) + return () => clearTimeout(fallback) } }, [user, isFetchingUser, isReady, isPublicPath, router]) @@ -106,8 +109,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => { ) } } else { - // for protected paths, wait for user auth and account setup check - if (!isReady || isFetchingUser || !hasToken || !user || needsRedirect || isCheckingAccount) { + // for protected paths, wait for auth to settle before rendering + if (!isReady || isFetchingUser || !user || isCheckingAccount || needsRedirect) { return (
From 4004acd543d0a55b26f71017a264573c42c46705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Mon, 9 Feb 2026 15:33:53 -0300 Subject: [PATCH 2/8] fix: show different error message for PIX --- src/app/(mobile-ui)/qr-pay/page.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 9fadf1fbb..d684d7da3 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -717,6 +717,10 @@ export default function QRPayPage() { ) } else if (errorMsg.toLowerCase().includes('expired') || errorMsg.toLowerCase().includes('stale')) { setErrorMessage('Payment session expired. Please scan the QR code again.') + } else if (qrType === EQrType.PIX) { + setErrorMessage( + 'This specific QR code or merchant is not supported. Please try again with a different QR code.' + ) } else { setErrorMessage( 'Could not complete payment. Please scan the QR code again. If problem persists contact support' @@ -726,7 +730,7 @@ export default function QRPayPage() { } finally { setLoadingState('Idle') } - }, [paymentLock?.code, signTransferUserOp, qrCode, currencyAmount, setLoadingState]) + }, [paymentLock?.code, signTransferUserOp, qrCode, currencyAmount, setLoadingState, qrType]) const payQR = useCallback(async () => { if (paymentProcessor === 'SIMPLEFI') { From 578071be31b0ba22741e9362109d2227320bdae2 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 9 Feb 2026 20:34:22 +0000 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=90=9B=20fix:=20clear=20expired=20jwt?= =?UTF-8?q?=20cookie=20and=20sw=20cache=20on=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit when backend returns 401 on /get-user, clear the jwt cookie and send Clear-Site-Data to nuke the service worker cache. this lets the client recover by redirecting to /setup for re-authentication instead of spinning forever with a stale cookie. --- .../api/peanut/user/get-user-from-cookie/route.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/api/peanut/user/get-user-from-cookie/route.ts b/src/app/api/peanut/user/get-user-from-cookie/route.ts index 809746401..5e4814ccc 100644 --- a/src/app/api/peanut/user/get-user-from-cookie/route.ts +++ b/src/app/api/peanut/user/get-user-from-cookie/route.ts @@ -21,11 +21,20 @@ export async function GET(_request: NextRequest) { }) if (response.status !== 200) { + const headers: Record = { + 'Content-Type': 'application/json', + } + + // on auth failure, clear the jwt cookie and sw cache so the client + // can recover even if running old cached code + if (response.status === 401) { + headers['Set-Cookie'] = 'jwt-token=; Path=/; Max-Age=0; SameSite=Lax' + headers['Clear-Site-Data'] = '"cache"' + } + return new NextResponse('Error in get-from-cookie', { status: response.status, - headers: { - 'Content-Type': 'application/json', - }, + headers, }) } From bf43ef7e33a3709d646e16b6d0abc848504156c9 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 9 Feb 2026 20:52:02 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=90=9B=20fix:=20always=20validate=20u?= =?UTF-8?q?ser=20against=20backend=20on=20mount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redux persists user data to localStorage, so on app boot the query was skipping the backend check entirely (enabled: !authUser.userId). this meant expired JWTs were never detected — the app rendered with stale cached data while all API calls failed silently. - remove redux gate from enabled, always fetch on mount - use placeholderData instead of initialData (show cached data instantly but don't skip the fetch) - clear redux on fetch failure so layout redirect triggers --- src/hooks/query/user.ts | 37 +++++++++------------------------- src/redux/slices/user-slice.ts | 2 +- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/src/hooks/query/user.ts b/src/hooks/query/user.ts index 70553493e..d9db7651b 100644 --- a/src/hooks/query/user.ts +++ b/src/hooks/query/user.ts @@ -3,7 +3,7 @@ import { useAppDispatch, useUserStore } from '@/redux/hooks' import { userActions } from '@/redux/slices/user-slice' import { fetchWithSentry } from '@/utils/sentry.utils' import { hitUserMetric } from '@/utils/metrics.utils' -import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { usePWAStatus } from '../usePWAStatus' import { useDeviceType } from '../useGetDeviceType' import { USER } from '@/constants/query.consts' @@ -29,14 +29,9 @@ export const useUserQuery = (dependsOn: boolean = true) => { return userData } else { - // RECOVERY FIX: Log error status for debugging - if (userResponse.status === 400 || userResponse.status === 500) { - console.error('Failed to fetch user with error status:', userResponse.status) - // This indicates a backend issue - user might be in broken state - // The KernelClientProvider recovery logic will handle cleanup - } else { - console.warn('Failed to fetch user. Probably not logged in.') - } + console.warn('Failed to fetch user, status:', userResponse.status) + // clear stale redux data so the app doesn't keep serving cached user + dispatch(userActions.setUser(null)) return null } } @@ -45,25 +40,13 @@ export const useUserQuery = (dependsOn: boolean = true) => { queryKey: [USER], queryFn: fetchUser, retry: 0, - // Enable if dependsOn is true (defaults to true) and no Redux user exists yet - enabled: dependsOn && !authUser?.user.userId, - // Two-tier caching strategy for optimal performance: - // TIER 1: TanStack Query in-memory cache (5 min) - // - Zero latency for active sessions - // - Lost on page refresh (intentional - forces SW cache check) - // TIER 2: Service Worker disk cache (1 week StaleWhileRevalidate) - // - <50ms response on cold start/offline - // - Persists across sessions - // Flow: TQ cache → if stale → fetch() → SW intercepts → SW cache → Network - staleTime: 5 * 60 * 1000, // 5 min (balance: fresh enough + reduces SW hits) - gcTime: 10 * 60 * 1000, // Keep unused data 10 min before garbage collection - // Refetch on mount - TQ automatically skips if data is fresh (< staleTime) + enabled: dependsOn, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, refetchOnMount: true, - // Refetch on focus - TQ automatically skips if data is fresh (< staleTime) refetchOnWindowFocus: true, - // Initialize with Redux data if available (hydration) - initialData: authUser || undefined, - // Keep previous data during refetch (smooth UX, no flicker) - placeholderData: keepPreviousData, + // use redux data as placeholder while fetching (no flicker) + // but always validate against the backend + placeholderData: authUser || undefined, }) } diff --git a/src/redux/slices/user-slice.ts b/src/redux/slices/user-slice.ts index 8fd3c5335..cb5990f8d 100644 --- a/src/redux/slices/user-slice.ts +++ b/src/redux/slices/user-slice.ts @@ -11,7 +11,7 @@ const userSlice = createSlice({ name: AUTH_SLICE, initialState, reducers: { - setUser: (state, action: PayloadAction) => { + setUser: (state, action: PayloadAction) => { state.user = action.payload }, }, From f8d855dfef3c4d785f4105b951c48239f3da85e2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:09:49 +0530 Subject: [PATCH 5/8] fix: lp faq copy --- src/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index eade96129..f36aa3352 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -65,7 +65,7 @@ export default function LandingPage() { { id: '3', question: 'Could a thief drain my wallet if they stole my phone?', - answer: 'Not without your face or fingerprint. The passkey is sealed in the Secure Enclave of your phone and never exported. It’s secured by NIST‑recommended P‑256 Elliptic Curve cryptography. Defeating that would be tougher than guessing all 10¹⁰¹⁰ combinations of a 30‑character password made of emoji.\nThis means that neither Peanut or even regulators could freeze, us or you to hand over your account, because we can’t hand over what we don’t have. Your key never touches our servers; compliance requests only see cryptographic and encrypted signatures. Cracking those signatures would demand more energy than the Sun outputs in a full century.', + answer: 'Not without your face or fingerprint. The passkey is sealed in the Secure Enclave of your phone and never exported. It’s secured by NIST‑recommended P‑256 Elliptic Curve cryptography. Defeating that would be tougher than guessing all 10¹⁰¹⁰ combinations of a 30‑character password made of emoji.\n This means your account is yours alone. Neither Peanut nor anyone else can freeze or seize it — because we never hold your keys. Your key never touches our servers; compliance requests only see cryptographic and encrypted signatures. Cracking those signatures would demand more energy than the Sun outputs in a full century.', }, { id: '4', From 226b2b74c6e9c7d1b366677a31b05d484c836356 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:24:17 +0530 Subject: [PATCH 6/8] fix: convert text into links for support and notio --- src/components/Global/FAQs/index.tsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/components/Global/FAQs/index.tsx b/src/components/Global/FAQs/index.tsx index 0b26c2628..de73f8c00 100644 --- a/src/components/Global/FAQs/index.tsx +++ b/src/components/Global/FAQs/index.tsx @@ -28,6 +28,29 @@ export function FAQsPanel({ heading, questions }: FAQsProps) { setOpenFaq((prevId) => (prevId === id ? null : id)) }, []) + // helper to convert urls in text to clickable links + const linkifyText = useCallback((text: string) => { + const urlRegex = /(https?:\/\/[^\s]+)/g + const parts = text.split(urlRegex) + + return parts.map((part, index) => { + if (part.match(urlRegex)) { + return ( + + {part} + + ) + } + return part + }) + }, []) + return (
@@ -80,7 +103,7 @@ export function FAQsPanel({ heading, questions }: FAQsProps) { transition={{ duration: 0.2 }} className="mt-1 overflow-hidden whitespace-pre-line leading-6 text-n-1" > - {faq.answer} + {linkifyText(faq.answer)} {faq.calModal && ( Date: Mon, 16 Feb 2026 12:36:08 +0530 Subject: [PATCH 7/8] feat: show appx. amt in local currency --- .../withdraw/[country]/bank/page.tsx | 6 ++- src/components/ExchangeRate/index.tsx | 41 +++++++++++++++---- src/components/Payment/PaymentInfoRow.tsx | 37 +++-------------- 3 files changed, 44 insertions(+), 40 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 2e018e868..07c05ca46 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -320,7 +320,11 @@ export default function WithdrawBankPage() { )} - + diff --git a/src/components/ExchangeRate/index.tsx b/src/components/ExchangeRate/index.tsx index 2eb53001c..0e5f89aee 100644 --- a/src/components/ExchangeRate/index.tsx +++ b/src/components/ExchangeRate/index.tsx @@ -2,13 +2,20 @@ import { AccountType } from '@/interfaces' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import useGetExchangeRate, { type IExchangeRate } from '@/hooks/useGetExchangeRate' import { useExchangeRate } from '@/hooks/useExchangeRate' +import { SYMBOLS_BY_CURRENCY_CODE } from '@/hooks/useCurrency' interface IExchangeRateProps extends Omit { nonEuroCurrency?: string sourceCurrency?: string + amountToConvert?: string } -const ExchangeRate = ({ accountType, nonEuroCurrency, sourceCurrency = 'USD' }: IExchangeRateProps) => { +const ExchangeRate = ({ + accountType, + nonEuroCurrency, + sourceCurrency = 'USD', + amountToConvert, +}: IExchangeRateProps) => { const { exchangeRate, isFetchingRate } = useGetExchangeRate({ accountType, enabled: !nonEuroCurrency }) const { exchangeRate: nonEruoExchangeRate, isLoading } = useExchangeRate({ sourceCurrency, @@ -26,27 +33,47 @@ const ExchangeRate = ({ accountType, nonEuroCurrency, sourceCurrency = 'USD' }: let displayValue = '-' let isLoadingRate = false let moreInfoText = '' + let rate: number | null = null if (nonEuroCurrency) { displayValue = nonEruoExchangeRate ? `1 ${sourceCurrency} = ${parseFloat(nonEruoExchangeRate.toString()).toFixed(4)} ${nonEuroCurrency}` : '-' isLoadingRate = isLoading + rate = nonEruoExchangeRate moreInfoText = "This is an approximate value. The actual amount received may vary based on your bank's exchange rate" } else { displayValue = exchangeRate ? `1 USD = ${parseFloat(exchangeRate).toFixed(4)} ${toCurrency}` : '-' isLoadingRate = isFetchingRate + rate = exchangeRate ? parseFloat(exchangeRate) : null moreInfoText = `Exchange rates apply when converting to ${toCurrency}` } + // calculate local currency amount if provided + const localCurrencyAmount = + amountToConvert && rate && rate > 0 ? (parseFloat(amountToConvert) * rate).toFixed(2) : null + + const currency = nonEuroCurrency || toCurrency + const currencySymbol = SYMBOLS_BY_CURRENCY_CODE[currency] || currency + return ( - + <> + + {localCurrencyAmount && ( + + )} + ) } diff --git a/src/components/Payment/PaymentInfoRow.tsx b/src/components/Payment/PaymentInfoRow.tsx index 0e9cebade..ebbe0d35f 100644 --- a/src/components/Payment/PaymentInfoRow.tsx +++ b/src/components/Payment/PaymentInfoRow.tsx @@ -1,8 +1,8 @@ -import { useId, useState } from 'react' import { twMerge } from 'tailwind-merge' import { Icon } from '../Global/Icons/Icon' import Loading from '../Global/Loading' import CopyToClipboard from '../Global/CopyToClipboard' +import { Tooltip } from '../Tooltip' export interface PaymentInfoRowProps { label: string | React.ReactNode @@ -25,9 +25,6 @@ export const PaymentInfoRow = ({ copyValue, onClick, }: PaymentInfoRowProps) => { - const [showMoreInfo, setShowMoreInfo] = useState(false) - const tooltipId = useId() - return (
{moreInfoText && ( -
setShowMoreInfo(true)} - onBlur={() => setShowMoreInfo(false)} - > - setShowMoreInfo(!showMoreInfo)} - /> - {showMoreInfo && ( - - )} +
+ + +
)}
From 9f621f22c058e0e479dc2c76d5a9477f198e50cf Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:29:34 +0530 Subject: [PATCH 8/8] fix: resolve cr comments --- src/components/ExchangeRate/index.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/components/ExchangeRate/index.tsx b/src/components/ExchangeRate/index.tsx index 0e5f89aee..c403495af 100644 --- a/src/components/ExchangeRate/index.tsx +++ b/src/components/ExchangeRate/index.tsx @@ -4,6 +4,11 @@ import useGetExchangeRate, { type IExchangeRate } from '@/hooks/useGetExchangeRa import { useExchangeRate } from '@/hooks/useExchangeRate' import { SYMBOLS_BY_CURRENCY_CODE } from '@/hooks/useCurrency' +// constants for exchange rate messages, specific to ExchangeRate component +const APPROXIMATE_VALUE_MESSAGE = + "This is an approximate value. The actual amount received may vary based on your bank's exchange rate" +const LOCAL_CURRENCY_LABEL = 'Amount you will receive' + interface IExchangeRateProps extends Omit { nonEuroCurrency?: string sourceCurrency?: string @@ -41,8 +46,7 @@ const ExchangeRate = ({ : '-' isLoadingRate = isLoading rate = nonEruoExchangeRate - moreInfoText = - "This is an approximate value. The actual amount received may vary based on your bank's exchange rate" + moreInfoText = APPROXIMATE_VALUE_MESSAGE } else { displayValue = exchangeRate ? `1 USD = ${parseFloat(exchangeRate).toFixed(4)} ${toCurrency}` : '-' isLoadingRate = isFetchingRate @@ -51,8 +55,13 @@ const ExchangeRate = ({ } // calculate local currency amount if provided - const localCurrencyAmount = - amountToConvert && rate && rate > 0 ? (parseFloat(amountToConvert) * rate).toFixed(2) : null + let localCurrencyAmount: string | null = null + if (amountToConvert && rate && rate > 0) { + const amount = parseFloat(amountToConvert) + if (!isNaN(amount) && amount > 0) { + localCurrencyAmount = (amount * rate).toFixed(2) + } + } const currency = nonEuroCurrency || toCurrency const currencySymbol = SYMBOLS_BY_CURRENCY_CODE[currency] || currency @@ -68,9 +77,9 @@ const ExchangeRate = ({ {localCurrencyAmount && ( )}