Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
66f617a
refactor(qr,rewards): extract qr/[code] and rewards pages onto featur…
kushagrasarathe Sep 6, 2026
95a1788
test(qr): run the extracted claim-flow hook through the nuqs testing …
kushagrasarathe Sep 6, 2026
057a3cf
refactor(card): extract card, card-recovery and fix-card-signature pa…
kushagrasarathe Sep 6, 2026
656b0cd
refactor(perks,kyc,request): decompose PerkClaimModal, SumsubKycWrapp…
kushagrasarathe Sep 6, 2026
a02480a
refactor(add-money): extract bank, root and crypto pages onto feature…
kushagrasarathe Sep 6, 2026
6e6386e
refactor(claim): decompose Claim.tsx and Link/Initial.view.tsx in pla…
kushagrasarathe Sep 6, 2026
2124610
refactor(invites-graph): decompose the 2.5k-loc InvitesGraph monolith…
kushagrasarathe Sep 6, 2026
2a43453
refactor(claim): add the extracted claim flow hooks, views and tests …
kushagrasarathe Sep 6, 2026
c9d15e9
test(request): render create-request states through the nuqs testing …
kushagrasarathe Sep 6, 2026
cbb96c0
fix(claim): resolve the legacy campaign wire from live search params …
kushagrasarathe Sep 6, 2026
ea5c93d
test(claim): pin the legacy campaign-wire url-order precedence (chip …
kushagrasarathe Sep 7, 2026
5c41c93
test(claim): pin the post-auth auto-claim step-param consumer contrac…
kushagrasarathe Sep 7, 2026
9fc23cc
merge feat/TASK-21457-qr-pay-split: restack onto the tech-debt-carryi…
kushagrasarathe Sep 7, 2026
4fa431c
Merge remote-tracking branch 'origin/feat/TASK-21457-qr-pay-split' in…
jjramirezn Sep 8, 2026
28903a8
fix(claim): consume the ?step= auto-claim trigger once per mount
jjramirezn Sep 8, 2026
2eb3075
refactor: three small review findings — dead add-money back branch, s…
jjramirezn Sep 8, 2026
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
635 changes: 2 additions & 633 deletions src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ jest.mock('nuqs', () => ({
return [value, setter]
},
useQueryStates: (_parsers: any, _opts?: any) => {
return [mockQueryState, mockSetQueryState]
// one URL in reality: params set through mockSearchParams (the
// useSearchParams channel) must be visible to nuqs reads too
return [{ ...Object.fromEntries(mockSearchParams), ...mockQueryState }, mockSetQueryState]
},
parseAsString: { withDefault: (d: string) => d },
parseAsStringEnum: (_values: string[]) => ({
Expand Down
166 changes: 4 additions & 162 deletions src/app/(mobile-ui)/add-money/crypto/page.tsx
Original file line number Diff line number Diff line change
@@ -1,167 +1,9 @@
'use client'

import ChooseNetworkView from '@/components/AddMoney/views/ChooseNetwork.view'
import CryptoDepositView from '@/components/AddMoney/views/CryptoDeposit.view'
import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView'
import { useAuth } from '@/context/authContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { rhinoApi } from '@/services/rhino'
import type { DepositAddressStatusResponse, RhinoChainType } from '@/services/services.types'
import type { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
import { NETWORK_LABELS, CHAIN_LOGOS, TOKEN_LOGOS, type ChainName, type TokenName } from '@/constants/rhino.consts'
import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts'
import { getExplorerUrl } from '@/utils/general.utils'
import { EHistoryUserRole } from '@/hooks/useTransactionHistory'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useQueryState, parseAsStringEnum, parseAsString } from 'nuqs'
import { useRouter } from 'next/navigation'
import { useSafeBack } from '@/hooks/useSafeBack'
import { readReturnTo, RETURN_TO_PARAM } from '@/utils/return-to.utils'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useTranslations } from 'next-intl'
import { AddMoneyCryptoPage } from '@/features/add-money/AddMoneyCryptoPage'

// static — peanut wallet is always on arbitrum
const DEPOSIT_EXPLORER_BASE_URL = getExplorerUrl(PEANUT_WALLET_CHAIN.id.toString())

const AddMoneyCryptoPage = () => {
const { user } = useAuth()
const t = useTranslations('addMoney')
// an explicit (sanitized) origin wins over history-back: the home Add
// drawer carries the caller's returnTo here, and popping history would
// land on the intermediate /home entry instead (chip P15). nuqs per the
// URL-as-State rule; readReturnTo validates same-origin on the raw value.
const [rawReturnTo] = useQueryState(RETURN_TO_PARAM, parseAsString)
const safeBack = useSafeBack('/add-money')
const returnTo = readReturnTo(
{ get: (key: string) => (key === RETURN_TO_PARAM ? rawReturnTo : null) },
'/add-money/crypto'
)
const router = useRouter()
const onBack = returnTo ? () => router.push(returnTo) : safeBack
const { address: peanutWalletAddress } = useWallet()
// no default: a bare /add-money/crypto shows the choose-network step per the
// Add/Crypto board (17830:78020); ?network= deep-links keep working
const [networkParam, setNetworkParam] = useQueryState(
'network',
parseAsStringEnum<RhinoChainType>(['EVM', 'SOL', 'TRON'])
)
const needsNetworkChoice = networkParam === null
const network: RhinoChainType = networkParam ?? 'EVM'
const [showSuccessView, setShowSuccessView] = useState(false)
const [depositResult, setDepositResult] = useState<DepositAddressStatusResponse | null>(null)

const {
data: depositAddressData,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ['rhino-deposit-address', user?.user.userId, peanutWalletAddress, network],
queryFn: () =>
rhinoApi.createDepositAddress(peanutWalletAddress as string, network, user?.user.userId as string),
enabled: !!user && !!peanutWalletAddress && !needsNetworkChoice,
staleTime: 1000 * 60 * 60 * 24, // 24 hours
})

const handleSuccess = useCallback(
(amount: number, statusData?: DepositAddressStatusResponse) => {
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_COMPLETED, {
amount,
chain_type: network,
method_type: 'crypto',
acquisition_source: user?.invitedBy ? 'referred' : 'organic',
})
setDepositResult(statusData ?? { status: 'completed', amount })
setShowSuccessView(true)
},
[network, user?.invitedBy]
)

// build minimal transaction details for the receipt drawer
const depositTransactionDetails: TransactionDetails | null = useMemo(() => {
if (!depositResult) return null
const usdAmount = depositResult.amount?.toString() ?? '0'
const chainName = depositResult.chainIn ?? NETWORK_LABELS[network]
const tokenSymbol = depositResult.tokenSymbol ?? 'USDT'
const chainIconUrl = CHAIN_LOGOS[chainName as ChainName] ?? CHAIN_LOGOS.ETHEREUM
const tokenIconUrl = TOKEN_LOGOS[tokenSymbol as TokenName] ?? TOKEN_LOGOS.USDT
const explorerUrl =
depositResult.txHash && DEPOSIT_EXPLORER_BASE_URL
? `${DEPOSIT_EXPLORER_BASE_URL}/tx/${depositResult.txHash}`
: undefined
const now = new Date()
return {
id: depositResult.txHash ?? 'deposit',
txHash: depositResult.txHash,
explorerUrl,
direction: 'add',
userName: chainName,
fullName: chainName,
amount: parseFloat(usdAmount),
initials: 'CD',
status: 'completed',
date: now,
createdAt: now,
completedAt: now,
tokenSymbol,
sourceView: 'history',
extraDataForDrawer: {
isLinkTransaction: false,
originalType: 'TRANSACTION_INTENT',
originalUserRole: EHistoryUserRole.RECIPIENT,
kind: 'CRYPTO_DEPOSIT',
},
tokenDisplayDetails: {
tokenSymbol,
tokenIconUrl,
chainName,
chainIconUrl,
},
currency: { amount: usdAmount, code: 'USD' },
totalAmountCollected: 0,
} satisfies TransactionDetails
}, [depositResult, network])

if (needsNetworkChoice && !showSuccessView) {
return (
<ChooseNetworkView
// push so browser back returns from the deposit view to this step
onSelect={(value) => setNetworkParam(value, { history: 'push' })}
onBack={onBack}
/>
)
}

if (showSuccessView && depositResult) {
return (
<PaymentSuccessView
type="DEPOSIT"
headerTitle={t('crypto.depositedCrypto')}
usdAmount={depositResult.amount?.toString()}
amount={depositResult.tokenAmount}
transactionDetails={depositTransactionDetails}
replaceOnDone
onComplete={() => {
setShowSuccessView(false)
setDepositResult(null)
}}
/>
)
}

return (
<CryptoDepositView
network={network}
depositAddressData={depositAddressData}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
onSuccess={handleSuccess}
onBack={onBack}
/>
)
const AddMoneyCryptoRoute = () => {
return <AddMoneyCryptoPage />
}

export default AddMoneyCryptoPage
export default AddMoneyCryptoRoute
128 changes: 6 additions & 122 deletions src/app/(mobile-ui)/add-money/page.tsx
Original file line number Diff line number Diff line change
@@ -1,126 +1,20 @@
'use client'

import { PageStack } from '@/components/0_Bruddle/PageStack'
import AddWithdrawCountriesList from '@/components/AddWithdraw/AddWithdrawCountriesList'
import { useAddMoneyFlow } from '@/features/add-money/useAddMoneyFlow'
import { AddMoneyBankCountryListView } from '@/features/add-money/views/AddMoneyBankCountryListView'
import dynamic from 'next/dynamic'

// stubs exist for web build; real components are injected by native build script.
// these dynamic imports must stay route-local: scripts/native-build.js copies the
// real pages over the sibling _onramp-* stub files.
const OnrampBankPage = dynamic(() => import('./_onramp-bank'), { ssr: false })
const OnrampMantecaPage = dynamic(() => import('./_onramp-manteca'), { ssr: false })
import { CountryList } from '@/components/Common/CountryList'
import type { CountryData } from '@/components/AddMoney/consts'
import NavHeader from '@/components/Global/NavHeader'
import { useOnrampFlow } from '@/context/OnrampFlowContext'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
import { useQueryState, parseAsStringEnum } from 'nuqs'
import { getRedirectUrl, clearRedirectUrl, getFromLocalStorage } from '@/utils/general.utils'
import { readReturnTo, RETURN_TO_PARAM } from '@/utils/return-to.utils'
import { isBridgeSupportedCountry } from '@/utils/regions.utils'
import { isMantecaSupportedCountryCode } from '@/constants/manteca.consts'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { addMoneyCountryUrl, rewriteMethodPath } from '@/utils/native-routes'
import { useTranslations } from 'next-intl'

export default function AddMoneyPage() {
const router = useRouter()
const searchParams = useSearchParams()
const t = useTranslations('addMoney')
const { resetOnrampFlow } = useOnrampFlow()
const [method] = useQueryState('method', parseAsStringEnum(['bank']))

// native app passes country as query param instead of path segment
const countryFromQuery = searchParams.get('country')

// clear stale onramp state on the root list (no country in the URL); reruns
// on back-nav from a ?country=… sub-view, not just on mount. resetOnrampFlow
// is a stable useCallback.
useEffect(() => {
if (!countryFromQuery) resetOnrampFlow()
}, [countryFromQuery, resetOnrampFlow])

const handleBack = () => {
// if viewing country-specific form, go back to country list. Keep the
// returnTo origin alive: dropping it here would strand the later backs
// on /home instead of the caller (the bug returnTo exists to fix).
if (countryFromQuery) {
const params = new URLSearchParams()
// sanitized for the same open-redirect reason as the bare-root hop
const origin = readReturnTo(searchParams, '/add-money')
if (origin) params.set(RETURN_TO_PARAM, origin)
params.set('method', 'bank')
router.push(`/add-money?${params.toString()}`)
return
}

// an explicit origin (e.g. the exchange-rate widget's "Try it!" CTA) wins over
// the /home reset below — that reset is only right for tab-bar entries
const returnTo = readReturnTo(searchParams, '/add-money')
if (returnTo) {
router.push(returnTo)
return
}

// check if we have a saved redirect url (from request fulfillment or similar flows)
const redirectUrl = getRedirectUrl()
const fromRequestFulfillment = getFromLocalStorage('fromRequestFulfillment')

if (redirectUrl && fromRequestFulfillment) {
clearRedirectUrl()
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('fromRequestFulfillment')
}
router.push(redirectUrl)
return
}

// always navigate to /home from root add-money page — router.back() causes
// loops because sub-pages (crypto, country) are in the history stack
router.push('/home')
}

const handleCountryClick = (country: CountryData) => {
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, {
method_type: 'bank',
country: country.path,
})

// The user already chose "Bank" — skip the redundant per-country method
// list and go straight to the deposit screen. AR/BR deposit via Manteca
// (which surfaces Pix / Mercado Pago itself); every other bank-supported
// country goes to the Bridge bank flow. Countries where bank isn't live
// yet keep the per-country screen, which is still useful there: it shows
// the "coming soon" bank state and the crypto fallback.
if (isMantecaSupportedCountryCode(country.id)) {
router.push(rewriteMethodPath(`/add-money/${country.path}/manteca`))
} else if (isBridgeSupportedCountry(country.id)) {
router.push(rewriteMethodPath(`/add-money/${country.path}/bank`))
} else {
router.push(addMoneyCountryUrl(country.path))
}
}

// Bare /add-money (no method, no country) is not a screen of its own any
// more: it opens the home page's Add drawer through its nuqs url state
// (?drawer=add), so direct links and generic entries (checklists, CTAs,
// lifecycle emails) land on a surface that offers crypto AND bank. The
// country list lives on the explicit ?method=bank.
const isBareRoot = !method && !searchParams.get('country')
useEffect(() => {
if (!isBareRoot) return
// carry the caller's origin through the drawer hop — dropping it here
// strands the exchange-rate widget's tested back contract on /home.
// readReturnTo, not the raw param: forwarding an unvalidated value
// from a trusted deep link is an open redirect (chip P16)
const params = new URLSearchParams({ drawer: 'add' })
const origin = readReturnTo(searchParams, '/add-money')
if (origin) params.set(RETURN_TO_PARAM, origin)
router.replace(`/home?${params.toString()}`)
}, [isBareRoot, router, searchParams])
const { countryFromQuery, viewFromQuery, isBareRoot, handleBack, handleCountryClick } = useAddMoneyFlow()

// native app: render sub-views based on query params
const viewFromQuery = searchParams.get('view')
if (countryFromQuery && viewFromQuery === 'bank') {
return <OnrampBankPage />
}
Expand All @@ -136,15 +30,5 @@ export default function AddMoneyPage() {
if (isBareRoot) return null

// ?method=bank: the bank country list (board Page/Add/Bank 17830:77534)
return (
<PageStack>
<NavHeader title={t('methods.bankTransfer')} onPrev={handleBack} />
<CountryList
inputTitle={t('selectYourCountry')}
viewMode="add-withdraw"
flow="add"
onCountryClick={handleCountryClick}
/>
</PageStack>
)
return <AddMoneyBankCountryListView onBack={handleBack} onCountryClick={handleCountryClick} />
}
Loading
Loading