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 (
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') { 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/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, }) } 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', diff --git a/src/components/ExchangeRate/index.tsx b/src/components/ExchangeRate/index.tsx index 2eb53001c..c403495af 100644 --- a/src/components/ExchangeRate/index.tsx +++ b/src/components/ExchangeRate/index.tsx @@ -2,13 +2,25 @@ 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' + +// 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 + 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 +38,51 @@ 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 - moreInfoText = - "This is an approximate value. The actual amount received may vary based on your bank's exchange rate" + rate = nonEruoExchangeRate + moreInfoText = APPROXIMATE_VALUE_MESSAGE } 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 + 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 + return ( - + <> + + {localCurrencyAmount && ( + + )} + ) } 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 && ( { - const [showMoreInfo, setShowMoreInfo] = useState(false) - const tooltipId = useId() - return (
{moreInfoText && ( -
setShowMoreInfo(true)} - onBlur={() => setShowMoreInfo(false)} - > - setShowMoreInfo(!showMoreInfo)} - /> - {showMoreInfo && ( - - )} +
+ + +
)}
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 }, },