diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..e100f9dcc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,260 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ZERO (zOS) is a flexible application platform for interacting with the Zer0 ecosystem, built with React, TypeScript, and Redux Saga. It features a modular app architecture with integrated messaging (Matrix protocol), Web3 wallet functionality, and social features. + +## Development Commands + +### Setup + +```bash +# Install dependencies (requires GitHub Personal Access Token with read:package scope) +npm config set //npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN +npm install --legacy-peer-deps + +# Copy and configure environment variables +cp .env.example .env +``` + +### Running the Application + +```bash +npm start # Start development server (Vite) +npm run start:legacy # Start with legacy OpenSSL provider (if needed) +npm run electron:start # Start Electron app +``` + +### Build + +```bash +npm run build # Production build (requires up to 6GB memory) +npm run build:legacy # Build with legacy OpenSSL provider +``` + +### Testing + +```bash +npm test # Run Jest tests +npm run test:vitest # Run Vitest tests (files matching *.vitest.*) +``` + +### Code Quality + +```bash +npm run lint # Lint TypeScript/React code +npm run lint:fix # Auto-fix linting issues +npm run code-format:validate # Check code formatting +npm run code-format:fix # Auto-format code with Prettier +``` + +### Other Commands + +```bash +npm run generate-mocks # Generate mock data for testing +``` + +## Technology Stack + +- **Build System**: Vite (migrated from Create React App) +- **React**: v18 with TypeScript +- **State Management**: Redux Toolkit + Redux Saga +- **Routing**: React Router v5 +- **Matrix SDK**: Matrix-js-sdk for chat/messaging +- **Web3**: Ethers.js, Wagmi, RainbowKit for blockchain integration +- **UI**: Custom components with @zero-tech/zui library +- **Styling**: SCSS with BEM methodology +- **Testing**: Jest (legacy) and Vitest (new tests) + +## Architecture + +### Component, Redux, Saga, Normalizr Pattern + +The application follows a unidirectional data flow architecture: + +1. **Components** display data and handle user input (no business logic) +2. **Redux Store** (`src/store/`) manages global state with domain-specific slices +3. **Sagas** (`src/store/*/saga.ts`) handle business logic, async operations, and side effects +4. **Normalizr** manages relational data in a normalized state structure + +### Key Architectural Patterns + +**Normalized State**: Data (users, channels, messages) is stored in a normalized format to avoid duplication: + +- `state.normalized.users` - User entities keyed by ID +- `state.normalized.channels` - Channel entities keyed by ID +- `state.normalized.messages` - Message entities keyed by ID + +**Redux Saga**: All business logic and async operations run in sagas, not thunks or components. Sagas provide better testability and handle complex concurrency scenarios. + +**Store Structure**: Each domain has its own folder in `src/store/` containing: + +- `index.ts` - Redux slice with actions and reducers +- `saga.ts` - Business logic and side effects +- `selectors.ts` - Reusable state selectors (if needed) + +### Directory Structure + +``` +src/ +├── apps/ # Application modules (Feed, Messenger, Wallet, etc.) +│ ├── app-router.tsx # Main app routing +│ └── */index.tsx # Individual app components +├── components/ # Reusable UI components +├── store/ # Redux slices and sagas +│ ├── index.ts # Store configuration +│ ├── saga.ts # Root saga +│ ├── reducer.ts # Root reducer +│ └── */ # Domain-specific state modules +├── lib/ # Utility functions and helpers +├── authentication/ # Auth-related components +└── pages/ # Top-level page components +``` + +### Apps vs Components + +- **Apps** (`src/apps/`) are route-based features (Messenger, Feed, Wallet, Profile, etc.) +- **Components** (`src/components/`) are reusable UI elements shared across apps +- Apps are registered in `src/apps/app-router.tsx` and mapped to routes + +### Styling with BEM + +All styles use SCSS with BEM (Block Element Modifier) naming: + +```tsx +import { bem } from '../../lib/bem'; +const c = bem('component-name'); + +
+

Title

+
...
+
; +``` + +SCSS files mirror the component structure: + +```scss +.component-name { + &__heading { ... } + &__content { ... } + &__content--active { ... } +} +``` + +Import theme variables from `@zero-tech/zui`: + +```scss +@use '~@zero-tech/zui/styles/theme' as theme; +``` + +## Component Guidelines + +### Function vs Class Components + +- **Function components**: Simple components with minimal logic (max 15-20 lines excluding markup) +- **Class components**: Complex components requiring significant logic or lifecycle methods + +### Hooks + +- Keep custom hooks under 15-20 lines +- Do not nest custom hooks (cannot call a custom hook from another hook) +- No business logic in hooks (only rendering/UI-related logic) +- Do not use hooks for external dependencies or global state interaction + +### Connected Components + +Use Redux `useSelector` and `useDispatch` hooks to connect components to the store. Keep mapStateToProps logic in selector functions when possible. + +## State Management + +### Creating a New Redux Slice + +1. Create folder in `src/store//` +2. Add `index.ts` with slice definition: + +```typescript +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +const slice = createSlice({ + name: 'domain', + initialState: { ... }, + reducers: { + receive: (state, action: PayloadAction) => { ... }, + }, +}); + +export const { receive } = slice.actions; +export const { reducer } = slice; +``` + +3. Add `saga.ts` for business logic: + +```typescript +import { takeEvery } from 'redux-saga/effects'; + +export function* saga() { + yield takeEvery(actionType, workerSaga); +} +``` + +4. Register in `src/store/reducer.ts` and `src/store/saga.ts` + +### Normalizr Usage + +For relational data (users, channels, messages), use the normalized store pattern. See `src/store/normalized/` for implementation details. + +## Testing + +- All PRs must include appropriate tests +- Tests should be isolated and not brittle +- Use `redux-saga-test-plan` for saga testing +- Component tests use Jest + Enzyme (legacy) or Vitest + Testing Library (new) +- Vitest tests: name files `*.vitest.tsx` or `*.vitest.ts` + +## Environment Variables + +All environment variables use `REACT_APP_` prefix (legacy from CRA, maintained for Vite compatibility). + +Key variables: + +- `REACT_APP_ETH_CHAIN` - Ethereum network (1 for mainnet, 11155111 for Sepolia) +- `REACT_APP_MATRIX_HOME_SERVER_URL` - Matrix chat server URL +- `REACT_APP_ZERO_API_URL` - Backend API URL +- Various Infura URLs for blockchain connectivity +- See `.env.example` for complete list + +## Git Workflow + +- Main branch: `development` (use this for PRs, not `main`) +- All changes require PR review +- Squash commits appropriately (no "WIP" commits in final PR) +- CI must pass before review +- Follow the [STYLE_GUIDE.md](STYLE_GUIDE.md) for code conventions + +## Matrix Chat Integration + +The app uses Matrix protocol for messaging. For local development, you can: + +- Use the development home server (default in `.env.example`) +- Run a local Matrix server: https://github.com/zer0-os/zOS-chat-server +- Set `REACT_APP_MATRIX_HOME_SERVER_URL=http://localhost:8008` to use local server + +## Adding a New App + +1. Create app folder in `src/apps//` +2. Implement app component with required interface +3. Add route in `src/apps/app-router.tsx` +4. Optionally add link in AppBar component +5. For external apps, use the `ExternalApp` wrapper component + +See [README.md](README.md) "Ways to Participate" section for detailed steps. + +## Code Principles + +- **No over-engineering**: Only implement what's requested, avoid unnecessary abstractions +- **Business logic in sagas**: Keep components focused on UI, move logic to sagas +- **Avoid hooks for business logic**: Use class components or sagas instead +- **BEM for all styles**: Maintain consistent naming conventions +- **Test isolation**: Write tests that are independent and maintainable diff --git a/NFT_TRANSFER_IMPLEMENTATION.md b/NFT_TRANSFER_IMPLEMENTATION.md new file mode 100644 index 000000000..7a7493047 --- /dev/null +++ b/NFT_TRANSFER_IMPLEMENTATION.md @@ -0,0 +1,705 @@ +# NFT Transfer Implementation Plan + +This document outlines the implementation plan for adding NFT transfer functionality to the zOS wallet app using the existing Send flow. + +## Overview + +**Goal:** Allow users to send NFTs (ERC-721 initially, ERC-1155 later) from the wallet app. + +**Approach:** Extend the existing Send flow with a Tokens/NFTs tab at the asset selection stage. + +**Current State:** + +- Backend endpoint exists: `POST /api/wallet/:address/transactions/transfer-nft` +- Frontend API call exists: `src/apps/wallet/queries/transferNFTRequest.ts` +- NFT types exist with `tokenType` and `quantity` fields +- Token transfer flow exists and will be extended + +--- + +## Flow Diagram + +``` + ┌─────────────────────────────────────┐ + │ Search Stage │ + │ (Select Recipient - reuse) │ + └─────────────────┬───────────────────┘ + │ + ┌─────────────────▼───────────────────┐ + │ Token Stage │ + │ ┌─────────────┬─────────────┐ │ + │ │ Tokens │ NFTs │ │ + │ │ Tab │ Tab │ │ + │ └──────┬──────┴──────┬──────┘ │ + └─────────┼─────────────┼─────────────┘ + │ │ + ┌───────────────▼──┐ ┌─────▼────────────────┐ + │ Amount Stage │ │ SKIP (ERC-721) │ + │ (Token only) │ │ or Amount (1155) │ + └────────┬─────────┘ └──────────┬───────────┘ + │ │ + └───────────┬─────────────┘ + │ + ┌──────────────▼──────────────┐ + │ Confirm Stage │ + │ (Adapt for Token vs NFT) │ + └──────────────┬──────────────┘ + │ + ┌──────────────▼──────────────┐ + │ Processing / Broadcasting │ + │ (reuse) │ + └──────────────┬──────────────┘ + │ + ┌──────────────▼──────────────┐ + │ Success / Error │ + │ (Adapt for Token vs NFT) │ + └─────────────────────────────┘ +``` + +--- + +## Phase 1: ERC-721 Implementation + +### Step 1: Redux State Changes + +**File:** `src/store/wallet/index.ts` + +```typescript +// Add to imports +import { NFT } from '../../apps/wallet/types'; + +// Update WalletState type +export type WalletState = { + // ... existing fields + nft: NFT | null; // NEW +}; + +// Update initialState +const initialState: WalletState = { + // ... existing fields + nft: null, +}; + +// Add to SagaActionTypes +export enum SagaActionTypes { + // ... existing + TransferNft = 'wallet/saga/transferNft', // NEW +} + +// Add new reducer in slice.reducers +setNft: (state, action: PayloadAction) => { + state.nft = action.payload; +}, + +// Update reset reducer +reset: (state) => { + // ... existing resets + state.nft = initialState.nft; +}, + +// Export new action +export const transferNft = createAction(SagaActionTypes.TransferNft); +export const { setNft } = slice.actions; // Add to exports +``` + +**File:** `src/store/wallet/selectors.ts` + +```typescript +// Add selector +export const nftSelector = (state: RootState) => state.wallet.nft; +``` + +--- + +### Step 2: Saga Changes + +**File:** `src/store/wallet/saga.ts` + +```typescript +// Add imports +import { transferNFTRequest } from '../../apps/wallet/queries/transferNFTRequest'; +import { nftSelector } from './selectors'; +import { setNft, transferNft } from '.'; +import { NFT } from '../../apps/wallet/types'; + +// Modify handleNext - skip Amount stage when NFT is selected +function* handleNext() { + const stage: SendStage = yield select(sendStageSelector); + const nft: NFT | null = yield select(nftSelector); + + switch (stage) { + case SendStage.Search: { + const recipient = yield select(recipientSelector); + if (recipient) { + yield put(setSendStage(SendStage.Token)); + } + break; + } + case SendStage.Token: { + const token = yield select(tokenSelector); + 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; + } + case SendStage.Amount: { + const amount = yield select(amountSelector); + if (amount) { + yield put(setSendStage(SendStage.Confirm)); + } + break; + } + } +} + +// Modify handlePrevious - handle NFT flow +function* handlePrevious() { + const stage: SendStage = yield select(sendStageSelector); + const nft: NFT | null = yield select(nftSelector); + + switch (stage) { + case SendStage.Confirm: + 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)); + yield put(setSendStage(SendStage.Token)); + break; + case SendStage.Token: + yield put(setRecipient(null)); + yield put(setNft(null)); // Clear NFT too + yield put(setToken(null)); + yield put(setSendStage(SendStage.Search)); + break; + case SendStage.Search: + break; + } +} + +// Add new saga for NFT transfer +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 = yield call(() => + transferNFTRequest(selectedWallet, recipient.publicAddress, nft.id, nft.collectionAddress) + ); + + if (result.transactionHash) { + yield put(setSendStage(SendStage.Broadcasting)); + // Note: NFT doesn't have chainId currently - may need to add or use default + const receipt: TxReceiptResponse = yield call(() => + queryClient.fetchQuery(txReceiptQueryOptions(result.transactionHash)) + ); + 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)); + } +} + +// Register in saga +export function* saga() { + yield fork(initializeWalletSaga); + yield takeLatest(nextStage.type, handleNext); + yield takeLatest(previousStage.type, handlePrevious); + yield takeLatest(transferToken.type, handleTransferToken); + yield takeLatest(transferNft.type, handleTransferNft); // NEW +} +``` + +--- + +### Step 3: Token Selection with Tabs + +**File:** `src/apps/wallet/send/token-select/wallet-token-select.tsx` + +```typescript +import { useDispatch, useSelector } from 'react-redux'; +import { nextStage, previousStage, setToken, setNft } from '../../../../store/wallet'; +import { SendHeader } from '../components/send-header'; +import { Input } from '@zero-tech/zui/components'; +import { IconSearchMd } from '@zero-tech/zui/icons'; +import { useState } from 'react'; +import { useBalancesQuery } from '../../queries/useBalancesQuery'; +import { useNFTsQuery } from '../../queries/useNFTsQuery'; +import { Token } from '../../tokens/token'; +import { NFTTile } from '../../nfts/nft/nft-tile'; // or create simplified version +import { TokenBalance, NFT } from '../../types'; +import { recipientSelector, selectedWalletSelector } from '../../../../store/wallet/selectors'; +import { truncateAddress } from '../../utils/address'; + +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 { data: balancesData } = useBalancesQuery(selectedWallet.address); + const { nfts } = useNFTsQuery(selectedWallet.address); + + const filteredTokens = balancesData?.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 handleTokenClick = (token: TokenBalance) => { + dispatch(setNft(null)); // Clear any selected NFT + dispatch(setToken(token)); + dispatch(nextStage()); + }; + + const handleNftClick = (nft: NFT) => { + dispatch(setToken(null)); // Clear any selected token + dispatch(setNft(nft)); + dispatch(nextStage()); // Saga will skip Amount stage + }; + + const handleBack = () => { + dispatch(previousStage()); + }; + + return ( +
+ + +
+ {/* Tab switcher */} +
+ + +
+ +
+ } + /> +
+ +
+ {activeTab === 'tokens' && ( +
+
Tokens
+
+ {filteredTokens?.map((asset) => ( + handleTokenClick(asset)} + /> + ))} + {filteredTokens?.length === 0 &&
No tokens found
} +
+
+ )} + + {activeTab === 'nfts' && ( +
+
NFTs
+
+ {filteredNfts?.map((nft) => ( + handleNftClick(nft)} + /> + ))} + {filteredNfts?.length === 0 &&
No NFTs found
} +
+
+ )} +
+
+ + {/* Footer showing recipient - same as before */} +
{/* ... existing footer code ... */}
+
+ ); +}; + +// Simple NFT item for selection list +const NFTSelectItem = ({ nft, onClick }: { nft: NFT; onClick: () => void }) => { + return ( + + ); +}; +``` + +**File:** `src/apps/wallet/send/token-select/wallet-token-select.module.scss` + +Add styles for tabs and NFT grid: + +```scss +.tabSwitcher { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.tab { + flex: 1; + padding: 12px; + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + border-radius: 8px; + transition: all 0.2s; + + &:hover { + background: var(--color-background-secondary); + } +} + +.tabActive { + background: var(--color-primary); + color: var(--color-text-primary); + border-color: var(--color-primary); +} + +.nftGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.nftSelectItem { + display: flex; + flex-direction: column; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: transparent; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-background-secondary); + } +} + +.nftImage { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 4px; +} + +.nftImagePlaceholder { + width: 100%; + aspect-ratio: 1; + background: var(--color-background-secondary); + border-radius: 4px; +} + +.nftInfo { + margin-top: 8px; +} + +.nftName { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nftCollection { + font-size: 12px; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.emptyState { + padding: 24px; + text-align: center; + color: var(--color-text-secondary); +} +``` + +--- + +### Step 4: Review Transfer Component + +**File:** `src/apps/wallet/send/review-transfer/wallet-review-transfer.tsx` + +Update to handle both Token and NFT: + +```typescript +import { useDispatch, useSelector } from 'react-redux'; +import { previousStage, transferToken, transferNft } from '../../../../store/wallet'; +import styles from './wallet-review-transfer.module.scss'; +import { SendHeader } from '../components/send-header'; +import { amountSelector, recipientSelector, tokenSelector, nftSelector } from '../../../../store/wallet/selectors'; +import { MatrixAvatar } from '../../../../components/matrix-avatar'; +import { TokenIcon } from '../../components/token-icon/token-icon'; +import { FormattedNumber } from '../../components/formatted-number/formatted-number'; +import { Button } from '../../components/button/button'; +import { IconChevronRightDouble } from '@zero-tech/zui/icons'; + +export const WalletReviewTransfer = () => { + const dispatch = useDispatch(); + const recipient = useSelector(recipientSelector); + const token = useSelector(tokenSelector); + const amount = useSelector(amountSelector); + const nft = useSelector(nftSelector); + + const isNftTransfer = nft !== null; + + const handleBack = () => { + dispatch(previousStage()); + }; + + const handleConfirm = () => { + if (isNftTransfer) { + dispatch(transferNft()); + } else { + dispatch(transferToken()); + } + }; + + return ( +
+ + +
+
+
Confirm transaction with
+ +
{recipient?.primaryZid || recipient?.name}
+
{recipient?.publicAddress}
+
+ + {isNftTransfer ? ( + // NFT Transfer Details +
+
+ {nft.imageUrl ? ( + {nft.metadata?.name} + ) : ( +
+ )} +
+
+
{nft.metadata?.name || 'Unnamed NFT'}
+
{nft.collectionName}
+
Token ID: {nft.id}
+
+
+ ) : ( + // Token Transfer Details (existing) +
+
+ +
{token.name}
+
+ +
+
+ +
+ +
+ +
+ +
{token.name}
+
+ +
+
+
+ )} +
+ +
+
Review the above before confirming.
+
Once made, your transaction is irreversible.
+ +
+
+ ); +}; +``` + +Add NFT-specific styles to `wallet-review-transfer.module.scss`. + +--- + +### Step 5: Success Screen + +**File:** `src/apps/wallet/send/success/wallet-transfer-success.tsx` + +Update to handle both Token and NFT: + +```typescript +// Add to imports +import { nftSelector } from '../../../../store/wallet/selectors'; + +// In component +const nft = useSelector(nftSelector); +const isNftTransfer = nft !== null; + +// In JSX, conditionally render token hero or NFT hero +{ + isNftTransfer ? ( +
+ {nft.imageUrl && {nft.metadata?.name}} +
{nft.metadata?.name || 'NFT'}
+
{nft.collectionName}
+
+ ) : ( + // Existing token hero +
{/* ... existing token code ... */}
+ ); +} +``` + +--- + +## Implementation Checklist + +### Phase 1: ERC-721 + +- [x] **Step 1: Redux State** ✅ + + - [x] Add `nft: NFT | null` to WalletState in `src/store/wallet/index.ts` + - [x] Add `setNft` reducer + - [x] Add `TransferNft` to SagaActionTypes + - [x] Add `transferNft` saga action + - [x] Update `reset` reducer to clear nft + - [x] Add `nftSelector` to `src/store/wallet/selectors.ts` + +- [x] **Step 2: Saga** ✅ + + - [x] Update `handleNext` to skip Amount stage for NFTs + - [x] Update `handlePrevious` to handle NFT flow + - [x] Add `handleTransferNft` saga + - [x] Register `transferNft` in root saga + +- [x] **Step 3: Token Selection with Tabs** ✅ + + - [x] Add tab state and switcher UI + - [x] Import and use `useNFTsQuery` + - [x] Add NFT grid with clickable items + - [x] Wire up `handleNftClick` to dispatch `setNft` and `nextStage` + - [x] Add SCSS for tabs and NFT grid + +- [x] **Step 4: Review Screen** ✅ + + - [x] Import `nftSelector` + - [x] Add conditional rendering for NFT vs Token + - [x] Wire up confirm button to dispatch `transferNft` for NFTs + - [x] Add NFT-specific styles + +- [x] **Step 5: Success Screen** ✅ + + - [x] Import `nftSelector` + - [x] Add conditional rendering for NFT success view + - [x] Add NFT-specific styles + +- [x] **Testing** ✅ + - [x] Test token flow still works + - [x] Test NFT selection skips Amount stage + - [x] Test back navigation works correctly (NFT flow goes Confirm → Token) + - [ ] Test NFT transfer calls correct API (requires E2E/integration test) + - [ ] Test reset clears NFT state (covered by existing reset logic) + +--- + +## Phase 2: ERC-1155 (Future) + +### Backend Changes Required + +- Create `send-erc-1155.ts` transaction builder using `safeTransferFrom(from, to, id, amount, data)` +- Update endpoint to accept `amount` parameter for ERC-1155 + +### Frontend Changes Required + +- Add `nftAmount: string | null` to Redux state +- Show Amount stage for ERC-1155 tokens (check `nft.tokenType === 'ERC-1155'`) +- Validate amount <= nft.quantity +- Update `transferNFTRequest` to include amount parameter +- Update saga to pass amount for ERC-1155 + +--- + +## Questions Resolved + +1. **Entry Point:** Use existing Send flow with tabs - unified experience +2. **Amount for ERC-721:** Skip Amount stage (always transfer 1) +3. **Amount for ERC-1155:** Show Amount stage, validate against quantity (Phase 2) + +## Open Questions + +1. **Chain ID for NFTs:** Currently NFT type doesn't have chainId - may need to add or use default +2. **NFT loading state:** Show skeleton while NFTs load? +3. **Empty states:** What to show if user has no NFTs? diff --git a/src/apps/wallet/queries/transferNFTRequest.ts b/src/apps/wallet/queries/transferNFTRequest.ts index bfeec3281..e593db36f 100644 --- a/src/apps/wallet/queries/transferNFTRequest.ts +++ b/src/apps/wallet/queries/transferNFTRequest.ts @@ -8,13 +8,19 @@ export const transferNFTRequest = async ( address: string, to: string, tokenId: string, - nftAddress: string + nftAddress: string, + amount?: string | null, + tokenType?: string ): Promise => { - const response = await post(`/api/wallet/${address}/transactions/transfer-nft`).send({ - to, - tokenId, - nftAddress, - }); + const body: Record = { to, tokenId, nftAddress }; + if (amount) { + body.amount = amount; + } + if (tokenType) { + body.tokenType = tokenType; + } + + const response = await post(`/api/wallet/${address}/transactions/transfer-nft`).send(body); return response.body as TransferNFTResponse; }; diff --git a/src/apps/wallet/send/nft-quantity/wallet-nft-quantity.module.scss b/src/apps/wallet/send/nft-quantity/wallet-nft-quantity.module.scss new file mode 100644 index 000000000..c47f53fc0 --- /dev/null +++ b/src/apps/wallet/send/nft-quantity/wallet-nft-quantity.module.scss @@ -0,0 +1,89 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; +} + +.content { + display: flex; + flex-direction: column; + gap: var(--l-spacing); + flex: 1; + padding: 0 var(--l-spacing); +} + +.nftPreview { + display: flex; + align-items: center; + gap: var(--l-spacing); + padding: var(--l-spacing); + border: 1px solid var(--border-primary); + border-radius: 12px; +} + +.nftImage { + width: 64px; + height: 64px; + object-fit: cover; + border-radius: 8px; + flex-shrink: 0; +} + +.nftImagePlaceholder { + width: 64px; + height: 64px; + background: var(--bg-secondary); + border-radius: 8px; + display: grid; + place-items: center; + color: var(--text-secondary); + flex-shrink: 0; +} + +.nftDetails { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.nftName { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nftCollection { + font-size: 14px; + color: var(--text-secondary); +} + +.quantityInput { + display: flex; + flex-direction: column; + gap: 8px; +} + +.quantityLabel { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; + color: var(--text-secondary); +} + +.maxButton { + background: none; + border: none; + color: var(--zero-green); + cursor: pointer; + font-size: 14px; + padding: 0; + + &:hover { + text-decoration: underline; + } +} diff --git a/src/apps/wallet/send/nft-quantity/wallet-nft-quantity.tsx b/src/apps/wallet/send/nft-quantity/wallet-nft-quantity.tsx new file mode 100644 index 000000000..7139707b2 --- /dev/null +++ b/src/apps/wallet/send/nft-quantity/wallet-nft-quantity.tsx @@ -0,0 +1,85 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { nextStage, previousStage, setAmount } from '../../../../store/wallet'; +import { SendHeader } from '../components/send-header'; +import { Button } from '../../components/button/button'; +import { Input } from '@zero-tech/zui/components'; +import { IconPackageMinus } from '@zero-tech/zui/icons'; +import { amountSelector, nftSelector } from '../../../../store/wallet/selectors'; +import { useMemo } from 'react'; + +import styles from './wallet-nft-quantity.module.scss'; + +export const WalletNftQuantity = () => { + const dispatch = useDispatch(); + const nft = useSelector(nftSelector); + const amount = useSelector(amountSelector); + + const maxQuantity = nft?.quantity ?? 1; + + const disabled = useMemo(() => { + if (!amount) return true; + const num = Number(amount); + return !Number.isInteger(num) || num < 1 || num > maxQuantity; + }, [amount, maxQuantity]); + + const handleAmountChange = (value: string) => { + const sanitized = value.replace(/[^0-9]/g, ''); + dispatch(setAmount(sanitized || null)); + }; + + const handleMax = () => { + dispatch(setAmount(String(maxQuantity))); + }; + + const handleBack = () => { + dispatch(previousStage()); + }; + + const handleContinue = () => { + dispatch(nextStage()); + }; + + if (!nft) return null; + + return ( +
+ + +
+
+ {nft.imageUrl ? ( + {nft.metadata?.name + ) : ( +
+ +
+ )} +
+
{nft.metadata?.name || 'Unnamed NFT'}
+
{nft.collectionName}
+
+
+ +
+
+ Quantity + +
+ +
+ + +
+
+ ); +}; diff --git a/src/apps/wallet/send/review-transfer/wallet-review-transfer.module.scss b/src/apps/wallet/send/review-transfer/wallet-review-transfer.module.scss index f73f8adcc..87ed22e46 100644 --- a/src/apps/wallet/send/review-transfer/wallet-review-transfer.module.scss +++ b/src/apps/wallet/send/review-transfer/wallet-review-transfer.module.scss @@ -167,3 +167,10 @@ text-overflow: ellipsis; white-space: nowrap; } + +.nftQuantity { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; + margin-top: 4px; +} diff --git a/src/apps/wallet/send/review-transfer/wallet-review-transfer.tsx b/src/apps/wallet/send/review-transfer/wallet-review-transfer.tsx index 38d9367a1..951491895 100644 --- a/src/apps/wallet/send/review-transfer/wallet-review-transfer.tsx +++ b/src/apps/wallet/send/review-transfer/wallet-review-transfer.tsx @@ -60,6 +60,7 @@ export const WalletReviewTransfer = () => {
{nft.metadata?.name || 'Unnamed NFT'}
{nft.collectionName}
Token ID: {nft.id}
+ {amount &&
Quantity: {amount}
}
) : ( 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 de2fa2ec5..6d753e52d 100644 --- a/src/apps/wallet/send/success/wallet-transfer-success.module.scss +++ b/src/apps/wallet/send/success/wallet-transfer-success.module.scss @@ -219,3 +219,10 @@ font-weight: 400; margin-top: 4px; } + +.nftSuccessQuantity { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; + margin-top: 8px; +} diff --git a/src/apps/wallet/send/success/wallet-transfer-success.tsx b/src/apps/wallet/send/success/wallet-transfer-success.tsx index 4a5fa05ad..d24c4391f 100644 --- a/src/apps/wallet/send/success/wallet-transfer-success.tsx +++ b/src/apps/wallet/send/success/wallet-transfer-success.tsx @@ -68,6 +68,7 @@ export const WalletTransferSuccess = () => {
{nft.metadata?.name || 'NFT'}
{nft.collectionName}
+ {amount &&
Quantity: {amount}
} ) : (
diff --git a/src/apps/wallet/send/wallet-send.tsx b/src/apps/wallet/send/wallet-send.tsx index adc5a2a37..127d8b5ce 100644 --- a/src/apps/wallet/send/wallet-send.tsx +++ b/src/apps/wallet/send/wallet-send.tsx @@ -1,10 +1,11 @@ import { PanelBody } from '../../../components/layout/panel'; import { WalletUserSearch } from './search/wallet-user-search'; import { useSelector } from 'react-redux'; -import { sendStageSelector } from '../../../store/wallet/selectors'; +import { nftSelector, sendStageSelector } from '../../../store/wallet/selectors'; import { SendStage } from '../../../store/wallet'; import { WalletTokenSelect } from './token-select/wallet-token-select'; import { WalletTransferAmount } from './transfer-amount/wallet-transfer-amount'; +import { WalletNftQuantity } from './nft-quantity/wallet-nft-quantity'; import { WalletReviewTransfer } from './review-transfer/wallet-review-transfer'; import { WalletProcessingTransaction } from './processing/wallet-processing-transaction'; import { WalletTransferSuccess } from './success/wallet-transfer-success'; @@ -14,11 +15,12 @@ import styles from './wallet-send.module.scss'; export const WalletSend = () => { const stage = useSelector(sendStageSelector); + const nft = useSelector(nftSelector); return ( {stage === SendStage.Search && } {stage === SendStage.Token && } - {stage === SendStage.Amount && } + {stage === SendStage.Amount && (nft ? : )} {stage === SendStage.Confirm && } {(stage === SendStage.Processing || stage === SendStage.Broadcasting) && } {stage === SendStage.Success && } diff --git a/src/store/wallet/saga.ts b/src/store/wallet/saga.ts index eaceb13a4..25b1ff898 100644 --- a/src/store/wallet/saga.ts +++ b/src/store/wallet/saga.ts @@ -120,8 +120,11 @@ function* handleNext() { } case SendStage.Token: { const token = yield select(tokenSelector); - if (nft) { - // NFT selected - skip Amount, go straight to Confirm + if (nft && nft.tokenType === 'ERC-1155') { + // ERC-1155 NFT - go to Amount for quantity input + yield put(setSendStage(SendStage.Amount)); + } else if (nft) { + // ERC-721 NFT - skip Amount, go straight to Confirm yield put(setSendStage(SendStage.Confirm)); } else if (token) { // Token selected - go to Amount @@ -145,8 +148,12 @@ function* handlePrevious() { switch (stage) { case SendStage.Confirm: - if (nft) { - // NFT flow - go back to Token selection (skip Amount) + if (nft && nft.tokenType === 'ERC-1155') { + // ERC-1155 NFT flow - go back to Amount (quantity input) + yield put(setAmount(null)); + yield put(setSendStage(SendStage.Amount)); + } else if (nft) { + // ERC-721 NFT flow - go back to Token selection (skip Amount) yield put(setNft(null)); yield put(setSendStage(SendStage.Token)); } else { @@ -156,6 +163,11 @@ function* handlePrevious() { } break; case SendStage.Amount: + if (nft) { + // ERC-1155 NFT flow - go back to Token selection + yield put(setNft(null)); + yield put(setAmount(null)); + } yield put(setToken(null)); yield put(setSendStage(SendStage.Token)); break; @@ -179,12 +191,20 @@ function* handleTransferNft() { const recipient: Recipient = yield select(recipientSelector); const selectedWallet: string | undefined = yield select(selectedWalletAddressSelector); const nft: NFT = yield select(nftSelector); + const amount: string | null = yield select(amountSelector); if (recipient && nft && selectedWallet) { yield put(setSendStage(SendStage.Processing)); const result: TransferNFTResponse = yield call(() => - transferNFTRequest(selectedWallet, recipient.publicAddress, nft.id, nft.collectionAddress) + transferNFTRequest( + selectedWallet, + recipient.publicAddress, + nft.id, + nft.collectionAddress, + amount, + nft.tokenType + ) ); if (result.transactionHash) {