diff --git a/src/apps/wallet/send/success/wallet-transfer-success.module.scss b/src/apps/wallet/send/success/wallet-transfer-success.module.scss
index da4cfe31b..de2fa2ec5 100644
--- a/src/apps/wallet/send/success/wallet-transfer-success.module.scss
+++ b/src/apps/wallet/send/success/wallet-transfer-success.module.scss
@@ -158,3 +158,64 @@
font-weight: 400;
color: var(--text-secondary);
}
+
+.nftHero {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ height: 30vh;
+}
+
+.nftSuccessImageContainer {
+ display: grid;
+ place-items: center;
+ margin-bottom: 16px;
+
+ & > * {
+ grid-column: 1;
+ grid-row: 1;
+ }
+}
+
+.nftSuccessGlow {
+ background-color: rgb(172, 253, 90, 0.15);
+ filter: blur(20px);
+ border-radius: 12px;
+ width: 140px;
+ height: 140px;
+}
+
+.nftSuccessImage {
+ width: 120px;
+ height: 120px;
+ object-fit: cover;
+ border-radius: 8px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+}
+
+.nftSuccessImagePlaceholder {
+ width: 120px;
+ height: 120px;
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ display: grid;
+ place-items: center;
+ color: var(--text-secondary);
+}
+
+.nftSuccessName {
+ color: #fff;
+ filter: drop-shadow(0px 0px 4px rgba(255, 255, 255, 0.4));
+ font-family: IBM Plex Sans;
+ font-weight: 600;
+ font-size: 24px;
+ text-align: center;
+}
+
+.nftSuccessCollection {
+ font-size: 16px;
+ color: var(--text-secondary);
+ font-weight: 400;
+ margin-top: 4px;
+}
diff --git a/src/apps/wallet/send/success/wallet-transfer-success.tsx b/src/apps/wallet/send/success/wallet-transfer-success.tsx
index 6eb68dbee..4a5fa05ad 100644
--- a/src/apps/wallet/send/success/wallet-transfer-success.tsx
+++ b/src/apps/wallet/send/success/wallet-transfer-success.tsx
@@ -1,7 +1,7 @@
import { IconButton } from '@zero-tech/zui/components';
import { SendHeader } from '../components/send-header';
import styles from './wallet-transfer-success.module.scss';
-import { IconCheck, IconChevronRightDouble, IconXClose } from '@zero-tech/zui/icons';
+import { IconCheck, IconChevronRightDouble, IconXClose, IconPackageMinus } from '@zero-tech/zui/icons';
import { useDispatch, useSelector } from 'react-redux';
import { reset } from '../../../../store/wallet';
import { getHistory } from '../../../../lib/browser';
@@ -11,6 +11,7 @@ import {
recipientSelector,
selectedWalletSelector,
tokenSelector,
+ nftSelector,
txReceiptSelector,
} from '../../../../store/wallet/selectors';
import { FormattedNumber } from '../../components/formatted-number/formatted-number';
@@ -31,6 +32,9 @@ export const WalletTransferSuccess = () => {
const currentUser = useSelector(currentUserSelector);
const selectedWallet = useSelector(selectedWalletSelector);
const txReceipt = useSelector(txReceiptSelector);
+ const nft = useSelector(nftSelector);
+
+ const isNftTransfer = nft !== null;
const handleClose = () => {
dispatch(reset());
@@ -42,25 +46,42 @@ export const WalletTransferSuccess = () => {
};
const dollarAmount = useMemo(() => {
- if (!token.price) return '--';
+ if (!token?.price) return '--';
return formatDollars(Number(amount) * Number(token.price));
- }, [token.price, amount]);
+ }, [token?.price, amount]);
return (
} />
-
-
-
-
-
+ {isNftTransfer ? (
+
+
+
+ {nft.imageUrl ? (
+

+ ) : (
+
+
+
+ )}
+
+
{nft.metadata?.name || 'NFT'}
+
{nft.collectionName}
-
{dollarAmount}
-
-
{token.symbol}
+ ) : (
+
+
+
{dollarAmount}
+
+ {token?.symbol}
+
-
+ )}
diff --git a/src/apps/wallet/send/token-select/wallet-token-select.module.scss b/src/apps/wallet/send/token-select/wallet-token-select.module.scss
index 7a8159d44..2078d9a4e 100644
--- a/src/apps/wallet/send/token-select/wallet-token-select.module.scss
+++ b/src/apps/wallet/send/token-select/wallet-token-select.module.scss
@@ -9,6 +9,37 @@
flex-direction: column;
gap: var(--l-spacing);
flex: 1;
+ overflow: hidden;
+}
+
+.tabSwitcher {
+ display: flex;
+ gap: 8px;
+ padding: 0 var(--l-spacing);
+}
+
+.tab {
+ flex: 1;
+ padding: 10px 16px;
+ border: 1px solid var(--border-primary);
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-radius: 8px;
+ transition: all 0.2s;
+ font-family: IBM Plex Sans;
+ font-weight: 500;
+ font-size: 14px;
+
+ &:hover {
+ background: var(--bg-hover);
+ }
+}
+
+.tabActive {
+ background: var(--bg-accent-dark-blue);
+ color: var(--text-primary);
+ border-color: var(--bg-accent-dark-blue);
}
.inputContainer {
@@ -17,6 +48,11 @@
padding: 0 var(--l-spacing);
}
+.resultsContainer {
+ flex: 1;
+ overflow-y: auto;
+}
+
.resultsHeaderLabel {
padding: 0 var(--l-spacing);
margin-bottom: 8px;
@@ -72,3 +108,23 @@
color: var(--text-secondary);
}
+
+.nftGrid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ padding: 0 var(--l-spacing);
+}
+
+.nftSkeleton {
+ aspect-ratio: 1;
+ border-radius: 8px;
+}
+
+.emptyState {
+ padding: 24px var(--l-spacing);
+ text-align: center;
+ color: var(--text-secondary);
+ font-family: IBM Plex Sans;
+ font-size: 14px;
+}
diff --git a/src/apps/wallet/send/token-select/wallet-token-select.tsx b/src/apps/wallet/send/token-select/wallet-token-select.tsx
index ec3abb332..93fc63730 100644
--- a/src/apps/wallet/send/token-select/wallet-token-select.tsx
+++ b/src/apps/wallet/send/token-select/wallet-token-select.tsx
@@ -1,34 +1,68 @@
import { useDispatch, useSelector } from 'react-redux';
-import { nextStage, previousStage, setToken } from '../../../../store/wallet';
+import { nextStage, previousStage, setToken, setNft } from '../../../../store/wallet';
import { SendHeader } from '../components/send-header';
-import { Input } from '@zero-tech/zui/components';
+import { Input, Skeleton } from '@zero-tech/zui/components';
import { IconSearchMd } from '@zero-tech/zui/icons';
-import { useState } from 'react';
+import { useCallback, useState } from 'react';
import { useBalancesQuery } from '../../queries/useBalancesQuery';
+import { useNFTsQuery } from '../../queries/useNFTsQuery';
import { Token } from '../../tokens/token';
-import { TokenBalance } from '../../types';
+import { NFTTile } from '../../nfts/nft/nft-tile';
+import { NFT, TokenBalance } from '../../types';
import { recipientSelector, selectedWalletSelector } from '../../../../store/wallet/selectors';
import { truncateAddress } from '../../utils/address';
+import { WalletEmptyState } from '../../components/empty-state/wallet-empty-state';
+import { Waypoint } from '../../../leaderboard/components/waypoint';
+import cn from 'classnames';
import styles from './wallet-token-select.module.scss';
+type AssetTab = 'tokens' | 'nfts';
+
export const WalletTokenSelect = () => {
const dispatch = useDispatch();
const [assetQuery, setAssetQuery] = useState('');
+ const [activeTab, setActiveTab] = useState
('tokens');
const selectedWallet = useSelector(selectedWalletSelector);
const recipient = useSelector(recipientSelector);
const hasZid = recipient?.primaryZid !== null;
const hasName = recipient?.name !== null;
const { data } = useBalancesQuery(selectedWallet.address);
- // TODO: Add search functionality to token balances endpoint
- const assets = data?.tokens?.filter((asset) => asset.name.toLowerCase().includes(assetQuery.toLowerCase()));
+ const {
+ nfts,
+ isLoading: isLoadingNfts,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ } = useNFTsQuery(selectedWallet.address);
+
+ const filteredTokens = data?.tokens?.filter((asset) => asset.name.toLowerCase().includes(assetQuery.toLowerCase()));
+
+ const filteredNfts = nfts?.filter(
+ (nft) =>
+ nft.metadata?.name?.toLowerCase().includes(assetQuery.toLowerCase()) ||
+ nft.collectionName?.toLowerCase().includes(assetQuery.toLowerCase())
+ );
+
+ const handleLoadMore = useCallback(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleTokenClick = (token: TokenBalance) => {
+ dispatch(setNft(null));
dispatch(setToken(token));
dispatch(nextStage());
};
+ const handleNftClick = (nft: NFT) => {
+ dispatch(setToken(null));
+ dispatch(setNft(nft));
+ dispatch(nextStage());
+ };
+
const handleBack = () => {
dispatch(previousStage());
};
@@ -38,6 +72,21 @@ export const WalletTokenSelect = () => {
+
+
+
+
+
{
-
-
Results
-
- {assets?.map((asset) => (
-
handleTokenClick(asset)} />
- ))}
+ {activeTab === 'tokens' && (
+
+
Tokens
+
+ {filteredTokens?.map((asset) => (
+
handleTokenClick(asset)}
+ />
+ ))}
+ {filteredTokens?.length === 0 && No tokens found
}
+
-
+ )}
+
+ {activeTab === 'nfts' && (
+
+
NFTs
+
+ {filteredNfts?.map((nft) => (
+
+ ))}
+ {(isLoadingNfts || isFetchingNextPage) &&
+ Array.from({ length: 4 }).map((_, i) => )}
+
+ {hasNextPage && !isFetchingNextPage &&
}
+ {!isLoadingNfts && filteredNfts?.length === 0 &&
}
+
+ )}
diff --git a/src/store/wallet/index.ts b/src/store/wallet/index.ts
index af1ae7509..9549c0bd9 100644
--- a/src/store/wallet/index.ts
+++ b/src/store/wallet/index.ts
@@ -1,5 +1,5 @@
import { createSlice, PayloadAction, createAction } from '@reduxjs/toolkit';
-import { Recipient, TokenBalance } from '../../apps/wallet/types';
+import { NFT, Recipient, TokenBalance } from '../../apps/wallet/types';
import { TxReceiptResponse } from '../../apps/wallet/queries/txReceiptQueryOptions';
export enum SendStage {
@@ -17,6 +17,7 @@ export enum SagaActionTypes {
NextStage = 'wallet/saga/nextStage',
PreviousStage = 'wallet/saga/previousStage',
TransferToken = 'wallet/saga/transferToken',
+ TransferNft = 'wallet/saga/transferNft',
}
export type WalletState = {
@@ -27,6 +28,7 @@ export type WalletState = {
recipient: Recipient | null;
sendStage: SendStage;
token: TokenBalance | null;
+ nft: NFT | null;
amount: string | null;
txReceipt: TxReceiptResponse | null;
error: boolean;
@@ -41,6 +43,7 @@ const initialState: WalletState = {
recipient: null,
sendStage: SendStage.Search,
token: null,
+ nft: null,
amount: null,
txReceipt: null,
error: false,
@@ -57,10 +60,13 @@ const slice = createSlice({
setRecipient: (state, action: PayloadAction
) => {
state.recipient = action.payload;
},
- setToken: (state, action: PayloadAction) => {
+ setToken: (state, action: PayloadAction) => {
state.token = action.payload;
},
- setAmount: (state, action: PayloadAction) => {
+ setNft: (state, action: PayloadAction) => {
+ state.nft = action.payload;
+ },
+ setAmount: (state, action: PayloadAction) => {
state.amount = action.payload;
},
setSendStage: (state, action: PayloadAction) => {
@@ -79,6 +85,7 @@ const slice = createSlice({
state.recipient = initialState.recipient;
state.sendStage = initialState.sendStage;
state.token = initialState.token;
+ state.nft = initialState.nft;
state.amount = initialState.amount;
state.txReceipt = initialState.txReceipt;
state.error = initialState.error;
@@ -89,11 +96,13 @@ const slice = createSlice({
export const nextStage = createAction(SagaActionTypes.NextStage);
export const previousStage = createAction(SagaActionTypes.PreviousStage);
export const transferToken = createAction(SagaActionTypes.TransferToken);
+export const transferNft = createAction(SagaActionTypes.TransferNft);
export const {
setSelectedWallet,
setRecipient,
setToken,
+ setNft,
setAmount,
setSendStage,
setTxReceipt,
diff --git a/src/store/wallet/saga.test.ts b/src/store/wallet/saga.test.ts
index a2dd4eaa3..4fded1816 100644
--- a/src/store/wallet/saga.test.ts
+++ b/src/store/wallet/saga.test.ts
@@ -2,12 +2,13 @@ import { expectSaga } from 'redux-saga-test-plan';
import { select } from 'redux-saga/effects';
import { saga } from './saga';
-import { nextStage, previousStage, SendStage, setSendStage, setRecipient, setToken, setAmount } from '.';
+import { nextStage, previousStage, SendStage, setSendStage, setRecipient, setToken, setNft, setAmount } from '.';
import {
recipientSelector,
sendStageSelector,
amountSelector,
tokenSelector,
+ nftSelector,
walletSelector,
selectedWalletAddressSelector,
} from './selectors';
@@ -19,6 +20,7 @@ describe('wallet saga', () => {
.provide([
[select(sendStageSelector), SendStage.Search],
[select(recipientSelector), '0x1234567890'],
+ [select(nftSelector), null],
[
select(walletSelector),
{
@@ -40,6 +42,7 @@ describe('wallet saga', () => {
.provide([
[select(sendStageSelector), SendStage.Search],
[select(recipientSelector), null],
+ [select(nftSelector), null],
[
select(walletSelector),
{
@@ -56,10 +59,11 @@ describe('wallet saga', () => {
.run();
});
- it('moves from token to amount if token is set', () => {
+ it('moves from token to amount if token is set (no NFT)', () => {
return expectSaga(saga)
.provide([
[select(sendStageSelector), SendStage.Token],
+ [select(nftSelector), null],
[
select(tokenSelector),
{
@@ -86,11 +90,43 @@ describe('wallet saga', () => {
.run();
});
+ it('moves from token to confirm if NFT is set (skips amount)', () => {
+ return expectSaga(saga)
+ .provide([
+ [select(sendStageSelector), SendStage.Token],
+ [
+ select(nftSelector),
+ {
+ id: '123',
+ collectionAddress: '0xNFT1234',
+ collectionName: 'Test Collection',
+ imageUrl: 'https://example.com/nft.png',
+ metadata: { name: 'Test NFT', description: '', attributes: [] },
+ },
+ ],
+ [select(tokenSelector), null],
+ [
+ select(walletSelector),
+ {
+ selectedWallet: {
+ address: '0x1234567890',
+ label: null,
+ },
+ },
+ ],
+ [select(selectedWalletAddressSelector), '0x1234567890'],
+ ])
+ .put(setSendStage(SendStage.Confirm))
+ .dispatch(nextStage())
+ .run();
+ });
+
it('moves from amount to confirm if amount is set', () => {
return expectSaga(saga)
.provide([
[select(sendStageSelector), SendStage.Amount],
[select(amountSelector), '123'],
+ [select(nftSelector), null],
[
select(walletSelector),
{
@@ -109,10 +145,11 @@ describe('wallet saga', () => {
});
describe('previous', () => {
- it('moves from confirm to amount and clears amount', () => {
+ it('moves from confirm to amount and clears amount (token flow)', () => {
return expectSaga(saga)
.provide([
[select(sendStageSelector), SendStage.Confirm],
+ [select(nftSelector), null],
[
select(walletSelector),
{
@@ -130,10 +167,42 @@ describe('wallet saga', () => {
.run();
});
+ it('moves from confirm to token and clears NFT (NFT flow)', () => {
+ return expectSaga(saga)
+ .provide([
+ [select(sendStageSelector), SendStage.Confirm],
+ [
+ select(nftSelector),
+ {
+ id: '123',
+ collectionAddress: '0xNFT1234',
+ collectionName: 'Test Collection',
+ imageUrl: 'https://example.com/nft.png',
+ metadata: { name: 'Test NFT', description: '', attributes: [] },
+ },
+ ],
+ [
+ select(walletSelector),
+ {
+ selectedWallet: {
+ address: '0x1234567890',
+ label: null,
+ },
+ },
+ ],
+ [select(selectedWalletAddressSelector), '0x1234567890'],
+ ])
+ .put(setNft(null))
+ .put(setSendStage(SendStage.Token))
+ .dispatch(previousStage())
+ .run();
+ });
+
it('moves from amount to token and clears token info', () => {
return expectSaga(saga)
.provide([
[select(sendStageSelector), SendStage.Amount],
+ [select(nftSelector), null],
[
select(walletSelector),
{
@@ -151,10 +220,11 @@ describe('wallet saga', () => {
.run();
});
- it('moves from token to search and clears recipient', () => {
+ it('moves from token to search and clears recipient, nft, and token', () => {
return expectSaga(saga)
.provide([
[select(sendStageSelector), SendStage.Token],
+ [select(nftSelector), null],
[
select(walletSelector),
{
@@ -167,6 +237,8 @@ describe('wallet saga', () => {
[select(selectedWalletAddressSelector), '0x1234567890'],
])
.put(setRecipient(null))
+ .put(setNft(null))
+ .put(setToken(null))
.put(setSendStage(SendStage.Search))
.dispatch(previousStage())
.run();
@@ -176,6 +248,7 @@ describe('wallet saga', () => {
return expectSaga(saga)
.provide([
[select(sendStageSelector), SendStage.Search],
+ [select(nftSelector), null],
[
select(walletSelector),
{
diff --git a/src/store/wallet/saga.ts b/src/store/wallet/saga.ts
index 2894302af..eaceb13a4 100644
--- a/src/store/wallet/saga.ts
+++ b/src/store/wallet/saga.ts
@@ -7,8 +7,10 @@ import {
setRecipient,
setAmount,
setToken,
+ setNft,
setTxReceipt,
transferToken,
+ transferNft,
setSelectedWallet,
setError,
reset,
@@ -19,18 +21,21 @@ import {
sendStageSelector,
amountSelector,
tokenSelector,
+ nftSelector,
selectedWalletAddressSelector,
} from './selectors';
import { queryClient } from '../../lib/web3/rainbowkit/provider';
import { transferTokenRequest, TransferTokenResponse } from '../../apps/wallet/queries/transferTokenRequest';
import { txReceiptQueryOptions, TxReceiptResponse } from '../../apps/wallet/queries/txReceiptQueryOptions';
import { setUser } from '../authentication';
-import { Recipient, TokenBalance } from '../../apps/wallet/types';
+import { NFT, Recipient, TokenBalance } from '../../apps/wallet/types';
+import { transferNFTRequest, TransferNFTResponse } from '../../apps/wallet/queries/transferNFTRequest';
import {
transferNativeAssetRequest,
TransferNativeAssetResponse,
} from '../../apps/wallet/queries/transferNativeAssetRequest';
import { isWalletAPIError } from './utils';
+import { config } from '../../config';
/**
* Loads the user's ThirdWeb wallet address into the store once the user has been fetched from the API
@@ -103,6 +108,7 @@ function* handleTransferToken() {
function* handleNext() {
const stage: SendStage = yield select(sendStageSelector);
+ const nft: NFT | null = yield select(nftSelector);
switch (stage) {
case SendStage.Search: {
@@ -114,7 +120,11 @@ function* handleNext() {
}
case SendStage.Token: {
const token = yield select(tokenSelector);
- if (token) {
+ if (nft) {
+ // NFT selected - skip Amount, go straight to Confirm
+ yield put(setSendStage(SendStage.Confirm));
+ } else if (token) {
+ // Token selected - go to Amount
yield put(setSendStage(SendStage.Amount));
}
break;
@@ -131,11 +141,19 @@ function* handleNext() {
function* handlePrevious() {
const stage: SendStage = yield select(sendStageSelector);
+ const nft: NFT | null = yield select(nftSelector);
switch (stage) {
case SendStage.Confirm:
- yield put(setAmount(null));
- yield put(setSendStage(SendStage.Amount));
+ if (nft) {
+ // NFT flow - go back to Token selection (skip Amount)
+ yield put(setNft(null));
+ yield put(setSendStage(SendStage.Token));
+ } else {
+ // Token flow - go back to Amount
+ yield put(setAmount(null));
+ yield put(setSendStage(SendStage.Amount));
+ }
break;
case SendStage.Amount:
yield put(setToken(null));
@@ -143,6 +161,8 @@ function* handlePrevious() {
break;
case SendStage.Token:
yield put(setRecipient(null));
+ yield put(setNft(null));
+ yield put(setToken(null));
yield put(setSendStage(SendStage.Search));
break;
case SendStage.Search:
@@ -151,6 +171,47 @@ function* handlePrevious() {
}
}
+function* handleTransferNft() {
+ const stage: SendStage = yield select(sendStageSelector);
+
+ try {
+ if (stage === SendStage.Confirm) {
+ const recipient: Recipient = yield select(recipientSelector);
+ const selectedWallet: string | undefined = yield select(selectedWalletAddressSelector);
+ const nft: NFT = yield select(nftSelector);
+
+ if (recipient && nft && selectedWallet) {
+ yield put(setSendStage(SendStage.Processing));
+
+ const result: TransferNFTResponse = yield call(() =>
+ transferNFTRequest(selectedWallet, recipient.publicAddress, nft.id, nft.collectionAddress)
+ );
+
+ if (result.transactionHash) {
+ yield put(setSendStage(SendStage.Broadcasting));
+ const receipt: TxReceiptResponse = yield call(() =>
+ queryClient.fetchQuery(txReceiptQueryOptions(result.transactionHash, Number(config.zChainId)))
+ );
+ yield put(setTxReceipt(receipt));
+ if (receipt.status === 'confirmed') {
+ yield put(setSendStage(SendStage.Success));
+ } else {
+ yield put(setSendStage(SendStage.Error));
+ }
+ } else {
+ yield put(setSendStage(SendStage.Error));
+ }
+ }
+ }
+ } catch (e) {
+ if (isWalletAPIError(e)) {
+ yield put(setErrorCode(e.response.body.code));
+ }
+ yield put(setError(true));
+ yield put(setSendStage(SendStage.Error));
+ }
+}
+
export function* clearWallet() {
yield put(setSelectedWallet({ address: '', label: null }));
yield put(reset());
@@ -161,4 +222,5 @@ export function* saga() {
yield takeLatest(nextStage.type, handleNext);
yield takeLatest(previousStage.type, handlePrevious);
yield takeLatest(transferToken.type, handleTransferToken);
+ yield takeLatest(transferNft.type, handleTransferNft);
}
diff --git a/src/store/wallet/selectors.ts b/src/store/wallet/selectors.ts
index a36d07424..70ebcae74 100644
--- a/src/store/wallet/selectors.ts
+++ b/src/store/wallet/selectors.ts
@@ -4,6 +4,7 @@ export const walletSelector = (state: RootState) => state.wallet;
export const recipientSelector = (state: RootState) => state.wallet.recipient;
export const sendStageSelector = (state: RootState) => state.wallet.sendStage;
export const tokenSelector = (state: RootState) => state.wallet.token;
+export const nftSelector = (state: RootState) => state.wallet.nft;
export const amountSelector = (state: RootState) => state.wallet.amount;
export const selectedWalletSelector = (state: RootState) => state.wallet.selectedWallet;
export const txReceiptSelector = (state: RootState) => state.wallet.txReceipt;