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
16 changes: 15 additions & 1 deletion components/Card/CardWithdrawForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import { withdrawFromCard, withdrawFromCardToSavings } from '@/lib/api';
import { EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID } from '@/lib/config';
import { CardProvider } from '@/lib/types';
import { cn, formatNumber, getCardDepositTokenSymbol } from '@/lib/utils';
import { assetLabel, toAmountInputValue } from '@/lib/utils/cardHelpers';
import {
assetLabel,
isDifferentCollateralAsset,
toAmountInputValue,
} from '@/lib/utils/cardHelpers';
import { CardDepositSource } from '@/store/useCardDepositStore';
import { useCardWithdrawStore } from '@/store/useCardWithdrawStore';

Expand Down Expand Up @@ -457,6 +461,16 @@ export default function CardWithdrawForm() {
assets={collateral?.tokens}
selectedTokenAddress={collateral?.tokenAddress}
onSelectAsset={asset => {
// Re-picking the asset already selected must leave the amount
// alone: the row the user taps is the one the trigger names, the
// cap has not moved, and wiping it there turned "press Max, then
// confirm the destination" into an empty field and a withdrawal
// that could not be submitted.
if (
!isDifferentCollateralAsset(asset, selectedTokenAddress ?? fundingTokenAddress)
) {
return;
}
setSelectedTokenAddress(asset.tokenAddress);
// The cap belongs to the old asset; clear it rather than
// validate the typed amount against a balance it never had.
Expand Down
96 changes: 11 additions & 85 deletions components/Card/ToDestinationSelector.native.tsx
Original file line number Diff line number Diff line change
@@ -1,92 +1,18 @@
import { useState } from 'react';
import { Pressable, View } from 'react-native';
import { ChevronDown, Wallet as WalletIcon } from 'lucide-react-native';

import { Text } from '@/components/ui/text';
import { formatNumber } from '@/lib/utils';
import { assetLabel } from '@/lib/utils/cardHelpers';
import { CardDepositSource } from '@/store/useCardDepositStore';

import type { ToDestinationProps } from './ToDestinationSelector.types';

export type { ToDestinationProps };

/**
* Re-exported so `import { assetLabel } from '.../ToDestinationSelector'`
* resolves on native too. Metro picks this `.native` file over the `.web` one,
* so anything the web module exports has to be exported here as well or it
* silently becomes `undefined` on device.
* Native and web share one picker now (`ToDestinationSelector.shared.tsx`): the
* in-flow list this file used to hold, which is the one that works inside the
* withdraw sheet on both platforms.
*/
export { assetLabel };

export default function ToDestinationSelector({
onChange,
tokenSymbol = 'USDC',
assets,
selectedTokenAddress,
onSelectAsset,
}: ToDestinationProps) {
const [isOpen, setIsOpen] = useState(false);
export { default } from './ToDestinationSelector.shared';

const withdrawable = assets?.filter(asset => !asset.unavailableReason) ?? [];
const selected = withdrawable.find(
asset => asset.tokenAddress.toLowerCase() === selectedTokenAddress?.toLowerCase(),
);
const triggerSymbol = selected ? assetLabel(selected) : tokenSymbol;

return (
<View>
<Pressable
className="flex-row items-center justify-between rounded-2xl bg-accent p-4"
onPress={() => setIsOpen(!isOpen)}
>
<View className="flex-row items-center gap-2">
<WalletIcon color="#A1A1A1" size={24} />
<Text className="text-lg font-semibold">Wallet</Text>
</View>
<View className="flex-row items-center gap-2">
<Text className="text-sm text-muted-foreground">{triggerSymbol}</Text>
<ChevronDown color="#A1A1A1" size={20} />
</View>
</Pressable>
{isOpen && (
<View className="mt-1 overflow-hidden rounded-2xl bg-accent">
{withdrawable.length ? (
withdrawable.map(asset => (
<Pressable
key={`${asset.chainId}-${asset.tokenAddress}`}
className="flex-row items-center justify-between px-4 py-3"
onPress={() => {
onChange(CardDepositSource.COLLATERAL);
onSelectAsset?.(asset);
setIsOpen(false);
}}
>
<View className="flex-row items-center gap-2">
<WalletIcon color="#A1A1A1" size={20} />
<Text className="text-lg">Wallet</Text>
<Text className="text-sm text-muted-foreground">{assetLabel(asset)}</Text>
</View>
<Text className="text-sm text-muted-foreground">
${formatNumber(asset.balanceUsd, 2, 2)}
</Text>
</Pressable>
))
) : (
<Pressable
className="flex-row items-center gap-2 px-4 py-3"
onPress={() => {
onChange(CardDepositSource.COLLATERAL);
setIsOpen(false);
}}
>
<WalletIcon color="#A1A1A1" size={20} />
<Text className="text-lg">Wallet</Text>
<Text className="text-sm text-muted-foreground">{tokenSymbol}</Text>
</Pressable>
)}
</View>
)}
</View>
);
}
/**
* Re-exported so `import { assetLabel } from '.../ToDestinationSelector'`
* resolves on native too. Metro picks this `.native` file over the `.web` one, so
* anything the web module exports has to be exported here as well or it silently
* becomes `undefined` on device.
*/
export { assetLabel } from '@/lib/utils/cardHelpers';
128 changes: 128 additions & 0 deletions components/Card/ToDestinationSelector.shared.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useState } from 'react';
import { Pressable, View } from 'react-native';
import { ChevronDown, Wallet as WalletIcon } from 'lucide-react-native';

import { Text } from '@/components/ui/text';
import { CHAIN_NAMES } from '@/constants/chains';
import { CardCollateralTokenBalanceDto } from '@/lib/types';
import { formatNumber } from '@/lib/utils';
import { assetLabel, withdrawableAssetOptions } from '@/lib/utils/cardHelpers';
import { CardDepositSource } from '@/store/useCardDepositStore';

import type { ToDestinationProps } from './ToDestinationSelector.types';

/**
* How an asset is named in the picker: its symbol, plus the chain when the same
* symbol appears more than once.
*
* A card funded on two chains holds two assets called "USDC", and two identical
* rows with different balances is a choice nobody can make. The chain is only
* added where it disambiguates — on the common single-chain card it would be
* noise on every row.
*/
const optionLabel = (
asset: CardCollateralTokenBalanceDto,
options: CardCollateralTokenBalanceDto[],
): string => {
const label = assetLabel(asset);
const isAmbiguous = options.some(other => other !== asset && assetLabel(other) === label);
if (!isAmbiguous) return label;
return `${label} · ${CHAIN_NAMES[asset.chainId] ?? `Chain ${asset.chainId}`}`;
};

/**
* "To" on the withdraw-from-card screen: the wallet, and which collateral asset
* the withdrawal draws from.
*
* ## Why this is one plain list rather than a dropdown menu
*
* The web build used to render this as a portalled dropdown menu over the sheet.
* Inside the withdraw modal on a phone browser that combination did not work: the
* menu covered the "Withdraw" button it was asking the user to press next, and
* taps on its rows mostly went nowhere — a portalled menu layered over a modal is
* two dismiss layers arguing over the same touch. A cardholder in the support
* recording spent nine seconds tapping the asset they wanted, got no response,
* and gave up with the screen still open. That is the whole of "withdraw is not
* working".
*
* An in-flow list has neither problem, and it is what native has always rendered.
* Both platforms now share this file, so a fix to the picker can no longer land on
* one of them only.
*/
export default function ToDestinationSelector({
onChange,
tokenSymbol = 'USDC',
assets,
selectedTokenAddress,
onSelectAsset,
}: ToDestinationProps) {
const [isOpen, setIsOpen] = useState(false);

const options = withdrawableAssetOptions(assets, selectedTokenAddress);
const selected = options.find(
asset => asset.tokenAddress.toLowerCase() === selectedTokenAddress?.toLowerCase(),
);
const triggerSymbol = selected ? optionLabel(selected, options) : tokenSymbol;

return (
<View>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Withdraw to Wallet in ${triggerSymbol}`}
className="flex-row items-center justify-between rounded-2xl bg-accent p-4"
onPress={() => setIsOpen(!isOpen)}
>
<View className="flex-row items-center gap-2">
<WalletIcon color="#A1A1A1" size={24} />
<Text className="text-lg font-semibold">Wallet</Text>
</View>
<View className="flex-row items-center gap-2">
<Text className="text-sm text-muted-foreground">{triggerSymbol}</Text>
<ChevronDown color="#A1A1A1" size={20} />
</View>
</Pressable>
{isOpen && (
<View className="mt-1 overflow-hidden rounded-2xl bg-accent">
{options.length ? (
options.map(asset => (
<Pressable
key={`${asset.chainId}-${asset.tokenAddress}`}
accessibilityRole="button"
className="flex-row items-center justify-between px-4 py-3 active:opacity-70 web:hover:bg-accent/50"
onPress={() => {
onChange(CardDepositSource.COLLATERAL);
onSelectAsset?.(asset);
setIsOpen(false);
}}
>
<View className="flex-row items-center gap-2">
<WalletIcon color="#A1A1A1" size={20} />
<Text className="text-lg">Wallet</Text>
<Text className="text-sm text-muted-foreground">
{optionLabel(asset, options)}
</Text>
</View>
<Text className="text-sm text-muted-foreground">
${formatNumber(asset.balanceUsd, 2, 2)}
</Text>
</Pressable>
))
) : (
<Pressable
accessibilityRole="button"
className="flex-row items-center gap-2 px-4 py-3 active:opacity-70"
onPress={() => {
onChange(CardDepositSource.COLLATERAL);
setIsOpen(false);
}}
>
<WalletIcon color="#A1A1A1" size={20} />
<Text className="text-lg">Wallet</Text>
<Text className="text-sm text-muted-foreground">{tokenSymbol}</Text>
</Pressable>
)}
</View>
)}
</View>
);
}
2 changes: 1 addition & 1 deletion components/Card/ToDestinationSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { default } from './ToDestinationSelector.shared';
export type { ToDestinationProps } from './ToDestinationSelector.types';
export { default } from './ToDestinationSelector.web';
export { assetLabel } from '@/lib/utils/cardHelpers';
91 changes: 12 additions & 79 deletions components/Card/ToDestinationSelector.web.tsx
Original file line number Diff line number Diff line change
@@ -1,86 +1,19 @@
import { Pressable, View } from 'react-native';
import { ChevronDown, Wallet as WalletIcon } from 'lucide-react-native';

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Text } from '@/components/ui/text';
import { formatNumber } from '@/lib/utils';
import { assetLabel } from '@/lib/utils/cardHelpers';
import { CardDepositSource } from '@/store/useCardDepositStore';

import type { ToDestinationProps } from './ToDestinationSelector.types';

export type { ToDestinationProps };

/**
* Re-exported so callers already importing it from the selector keep working.
* The implementation is platform-neutral on purpose — see `assetLabel`.
* Web renders the same in-flow picker as native — see
* `ToDestinationSelector.shared.tsx` for why the portalled dropdown menu that
* used to live here had to go.
*/
export { assetLabel };

export default function ToDestinationSelector({
onChange,
tokenSymbol = 'USDC',
assets,
selectedTokenAddress,
onSelectAsset,
}: ToDestinationProps) {
const withdrawable = assets?.filter(asset => !asset.unavailableReason) ?? [];
const selected = withdrawable.find(
asset => asset.tokenAddress.toLowerCase() === selectedTokenAddress?.toLowerCase(),
);
const triggerSymbol = selected ? assetLabel(selected) : tokenSymbol;
export { default } from './ToDestinationSelector.shared';

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable className="flex-row items-center justify-between rounded-2xl bg-accent p-4">
<View className="flex-row items-center gap-2">
<WalletIcon color="#A1A1A1" size={24} />
<Text className="text-lg font-semibold">Wallet</Text>
</View>
<View className="flex-row items-center gap-2">
<Text className="text-sm text-muted-foreground">{triggerSymbol}</Text>
<ChevronDown color="#A1A1A1" size={20} />
</View>
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent className="-mt-4 w-full min-w-[380px] rounded-b-2xl rounded-t-none border-0">
{withdrawable.length ? (
withdrawable.map(asset => (
<DropdownMenuItem
key={`${asset.chainId}-${asset.tokenAddress}`}
onPress={() => {
onChange(CardDepositSource.COLLATERAL);
onSelectAsset?.(asset);
}}
className="flex-row items-center justify-between px-4 py-3 web:cursor-pointer"
>
<View className="flex-row items-center gap-2">
<WalletIcon color="#A1A1A1" size={20} />
<Text className="text-lg">Wallet</Text>
<Text className="text-sm text-muted-foreground">{assetLabel(asset)}</Text>
</View>
<Text className="text-sm text-muted-foreground">
${formatNumber(asset.balanceUsd, 2, 2)}
</Text>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem
onPress={() => onChange(CardDepositSource.COLLATERAL)}
className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer"
>
<WalletIcon color="#A1A1A1" size={20} />
<Text className="text-lg">Wallet</Text>
<Text className="text-sm text-muted-foreground">{tokenSymbol}</Text>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* Re-exported so `import { assetLabel } from '.../ToDestinationSelector'` keeps
* resolving on web. The helper itself is platform-neutral and lives in
* `cardHelpers`; both platform variants must export the same names or the one
* that does not silently hands callers `undefined` (see
* `__tests__/toDestinationSelectorExports.test.ts`).
*/
export { assetLabel } from '@/lib/utils/cardHelpers';
Loading
Loading