Created
May 21, 2026 21:36
-
-
Save fayimora/4c5cd1061932804fa5d7d6eb286e69ea to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. | |
| // SPDX-License-Identifier: Apache-2.0 | |
| import { useCallback, useEffect, useMemo, useState } from 'react' | |
| import CloseIcon from '@mui/icons-material/Close' | |
| import { | |
| Alert, | |
| Box, | |
| CircularProgress, | |
| IconButton, | |
| InputAdornment, | |
| TextField, | |
| Typography, | |
| type SxProps, | |
| type Theme, | |
| } from '@mui/material' | |
| import { DateTimePicker } from '@mui/x-date-pickers' | |
| import { useForm } from '@tanstack/react-form' | |
| import type { AnyFieldApi } from '@tanstack/react-form' | |
| import Decimal from 'decimal.js' | |
| import { z } from 'zod' | |
| import { CopyIconButton } from '@components/ui/CopyIconButton' | |
| import { PillButton } from '@components/ui/PillButton' | |
| import { usePrimaryAccount } from '@hooks/useAccounts' | |
| import { useCreateTransfer } from '@hooks/useCreateTransfer' | |
| import { useInstrumentAvailableBalance } from '@hooks/useInstrumentAvailableBalance' | |
| import { InstrumentSelect } from './instrument-select' | |
| import type { | |
| SelectableInstrument, | |
| SubmittedTransfer, | |
| TransferFormData, | |
| } from './transfer-types' | |
| interface TransferFormProps { | |
| initialValues?: TransferFormData | |
| onClose: () => void | |
| onPendingChange: (pending: boolean) => void | |
| onSubmitted: (transfer: SubmittedTransfer) => void | |
| } | |
| const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 | |
| const DESCRIPTION_LIMIT = 280 | |
| const createDefaultTransferValues = (): TransferFormData => ({ | |
| instrumentId: null, | |
| amount: '', | |
| recipient: '', | |
| memo: '', | |
| expiry: new Date(Date.now() + SEVEN_DAYS_MS), | |
| }) | |
| const instrumentValidator = z | |
| .object({ | |
| admin: z.string(), | |
| id: z.string(), | |
| }) | |
| .nullable() | |
| .refine((value) => value !== null, 'Please select an asset') | |
| const recipientValidator = z | |
| .string() | |
| .trim() | |
| .min(1, 'Recipient address is required') | |
| const memoValidator = z | |
| .string() | |
| .max(DESCRIPTION_LIMIT, `${DESCRIPTION_LIMIT} characters maximum`) | |
| const expiryValidator = z | |
| .date() | |
| .nullable() | |
| .refine((value) => value !== null, 'Expiration date is required') | |
| .refine( | |
| (value) => value === null || !Number.isNaN(value.getTime()), | |
| 'Expiration date must be valid' | |
| ) | |
| export const TransferForm: React.FC<TransferFormProps> = ({ | |
| initialValues, | |
| onClose, | |
| onPendingChange, | |
| onSubmitted, | |
| }) => { | |
| const primaryAccount = usePrimaryAccount() | |
| const primaryParty = primaryAccount?.partyId | |
| const createTransferMutation = useCreateTransfer() | |
| const [selectedInstrument, setSelectedInstrument] = | |
| useState<SelectableInstrument | null>(null) | |
| useEffect(() => { | |
| onPendingChange(createTransferMutation.isPending) | |
| }, [createTransferMutation.isPending, onPendingChange]) | |
| const defaultValues = useMemo( | |
| () => initialValues ?? createDefaultTransferValues(), | |
| [initialValues] | |
| ) | |
| const form = useForm({ | |
| defaultValues, | |
| onSubmit: async ({ value: formData }) => { | |
| if (!primaryParty || !formData.instrumentId || !formData.expiry) { | |
| return | |
| } | |
| const instrumentId = formData.instrumentId | |
| const expiry = formData.expiry | |
| createTransferMutation.mutate( | |
| { | |
| sender: primaryParty, | |
| receiver: formData.recipient.trim(), | |
| instrumentId, | |
| amount: formData.amount, | |
| expiry, | |
| memo: formData.memo.trim() || undefined, | |
| }, | |
| { | |
| onSuccess: () => { | |
| onSubmitted({ | |
| sender: primaryParty, | |
| instrumentId, | |
| instrumentSymbol: selectedInstrument?.symbol, | |
| instrumentName: selectedInstrument?.name, | |
| amount: formData.amount, | |
| recipient: formData.recipient.trim(), | |
| memo: formData.memo.trim(), | |
| expiry, | |
| submittedAt: new Date(), | |
| }) | |
| }, | |
| } | |
| ) | |
| }, | |
| }) | |
| const mutationError = createTransferMutation.error | |
| const disabled = createTransferMutation.isPending | |
| const handleInstrumentChange = ( | |
| instrument: SelectableInstrument | null | |
| ) => { | |
| form.setFieldValue('instrumentId', instrument?.instrumentId ?? null) | |
| } | |
| const handleSelectedInstrumentChange = useCallback( | |
| (instrument: SelectableInstrument | null) => { | |
| setSelectedInstrument(instrument) | |
| }, | |
| [] | |
| ) | |
| return ( | |
| <Box | |
| component="form" | |
| onSubmit={(event) => { | |
| event.preventDefault() | |
| event.stopPropagation() | |
| form.handleSubmit() | |
| }} | |
| sx={{ px: 3, pt: 3, pb: 3 }} | |
| > | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| alignItems: 'flex-start', | |
| justifyContent: 'space-between', | |
| gap: 3, | |
| mb: 3, | |
| }} | |
| > | |
| <Typography variant="h4" component="h2"> | |
| Make a transfer | |
| </Typography> | |
| <IconButton | |
| aria-label="Close transfer dialog" | |
| onClick={onClose} | |
| disabled={disabled} | |
| sx={{ | |
| color: 'secondary.main', | |
| mt: 0.25, | |
| '&:active': { transform: 'scale(0.97)' }, | |
| }} | |
| > | |
| <CloseIcon /> | |
| </IconButton> | |
| </Box> | |
| <Box sx={{ display: 'grid', gap: 3 }}> | |
| {mutationError && ( | |
| <Alert severity="error"> | |
| Transfer failed:{' '} | |
| {mutationError instanceof Error | |
| ? mutationError.message | |
| : 'Unknown error'} | |
| </Alert> | |
| )} | |
| <FieldBlock label="Wallet address"> | |
| {primaryParty && ( | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 1.25, | |
| color: 'text.secondary', | |
| }} | |
| > | |
| <Typography | |
| component="span" | |
| sx={{ | |
| minWidth: 0, | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| whiteSpace: 'nowrap', | |
| fontSize: 'inherit', | |
| }} | |
| > | |
| {formatWalletAddress(primaryParty)} | |
| </Typography> | |
| <CopyIconButton | |
| value={primaryParty} | |
| sx={{ | |
| color: 'secondary.main', | |
| p: 0.5, | |
| '&:hover': { color: 'secondary.light' }, | |
| }} | |
| /> | |
| </Box> | |
| )} | |
| </FieldBlock> | |
| <form.Field | |
| name="recipient" | |
| validators={{ | |
| onChange: recipientValidator, | |
| onSubmit: recipientValidator, | |
| }} | |
| > | |
| {(field) => ( | |
| <FieldBlock label="Recipient Address"> | |
| <TextField | |
| value={field.state.value} | |
| onChange={(event) => | |
| field.handleChange(event.target.value) | |
| } | |
| onBlur={field.handleBlur} | |
| disabled={disabled} | |
| error={hasFieldError(field)} | |
| helperText={getFieldError(field)} | |
| fullWidth | |
| sx={darkTextFieldSx} | |
| /> | |
| </FieldBlock> | |
| )} | |
| </form.Field> | |
| <form.Field | |
| name="instrumentId" | |
| validators={{ | |
| onChange: instrumentValidator, | |
| onSubmit: instrumentValidator, | |
| }} | |
| > | |
| {(field) => ( | |
| <FieldBlock label="Select asset"> | |
| <InstrumentSelect | |
| partyId={primaryParty} | |
| value={field.state.value} | |
| onChange={handleInstrumentChange} | |
| onSelectedInstrumentChange={ | |
| handleSelectedInstrumentChange | |
| } | |
| disabled={disabled} | |
| error={hasFieldError(field)} | |
| helperText={getFieldError(field)} | |
| /> | |
| <Typography | |
| variant="body2" | |
| sx={{ mt: 0.75, color: 'text.secondary' }} | |
| > | |
| Available balance:{' '} | |
| {selectedInstrument | |
| ? `${selectedInstrument.availableAmount} ${selectedInstrument.symbol}` | |
| : '—'} | |
| </Typography> | |
| </FieldBlock> | |
| )} | |
| </form.Field> | |
| <form.Subscribe selector={(state) => state.values.instrumentId}> | |
| {(selectedInstrumentId) => ( | |
| <AmountField | |
| form={form} | |
| primaryParty={primaryParty} | |
| selectedInstrumentId={selectedInstrumentId} | |
| selectedInstrument={selectedInstrument} | |
| disabled={disabled} | |
| /> | |
| )} | |
| </form.Subscribe> | |
| <form.Field | |
| name="expiry" | |
| validators={{ | |
| onChange: expiryValidator, | |
| onSubmit: expiryValidator, | |
| }} | |
| > | |
| {(field) => ( | |
| <FieldBlock label="Expiration date"> | |
| <DateTimePicker | |
| value={field.state.value} | |
| onChange={(date) => field.handleChange(date)} | |
| disabled={disabled} | |
| slotProps={{ | |
| textField: { | |
| fullWidth: true, | |
| error: hasFieldError(field), | |
| helperText: | |
| getFieldError(field) ?? | |
| 'Date the transfer offer expires', | |
| sx: darkTextFieldSx, | |
| }, | |
| openPickerButton: { | |
| sx: { color: 'text.primary' }, | |
| }, | |
| }} | |
| /> | |
| </FieldBlock> | |
| )} | |
| </form.Field> | |
| <form.Field | |
| name="memo" | |
| validators={{ | |
| onChange: memoValidator, | |
| onSubmit: memoValidator, | |
| }} | |
| > | |
| {(field) => { | |
| const charactersLeft = Math.max( | |
| 0, | |
| DESCRIPTION_LIMIT - field.state.value.length | |
| ) | |
| return ( | |
| <FieldBlock label="Description"> | |
| <TextField | |
| value={field.state.value} | |
| placeholder="Placeholder" | |
| onChange={(event) => | |
| field.handleChange(event.target.value) | |
| } | |
| disabled={disabled} | |
| error={hasFieldError(field)} | |
| helperText={getFieldError(field)} | |
| multiline | |
| rows={4} | |
| fullWidth | |
| sx={darkTextFieldSx} | |
| /> | |
| <Typography | |
| sx={{ | |
| mt: 1, | |
| color: 'text.secondary', | |
| }} | |
| > | |
| {charactersLeft} characters left | |
| </Typography> | |
| </FieldBlock> | |
| ) | |
| }} | |
| </form.Field> | |
| <form.Subscribe | |
| selector={(state) => ({ | |
| canSubmit: state.canSubmit, | |
| isSubmitting: state.isSubmitting, | |
| })} | |
| > | |
| {({ canSubmit, isSubmitting }) => ( | |
| <PillButton | |
| type="submit" | |
| fullWidth | |
| disabled={ | |
| !canSubmit || | |
| isSubmitting || | |
| disabled || | |
| !primaryParty | |
| } | |
| sx={{ mt: 0.5, minHeight: 48, fontWeight: 700 }} | |
| > | |
| {disabled ? ( | |
| <CircularProgress size={24} color="inherit" /> | |
| ) : ( | |
| 'Make Transfer' | |
| )} | |
| </PillButton> | |
| )} | |
| </form.Subscribe> | |
| </Box> | |
| </Box> | |
| ) | |
| } | |
| interface FieldBlockProps { | |
| label: string | |
| children: React.ReactNode | |
| } | |
| const FieldBlock: React.FC<FieldBlockProps> = ({ label, children }) => ( | |
| <Box> | |
| <Typography | |
| sx={{ | |
| mb: 0.75, | |
| color: 'text.primary', | |
| fontWeight: 800, | |
| textTransform: 'uppercase', | |
| }} | |
| > | |
| {label} | |
| </Typography> | |
| {children} | |
| </Box> | |
| ) | |
| interface AmountFieldProps { | |
| // TanStack Form's component API is difficult to name cleanly here; this | |
| // mirrors the existing transfer dialog's pragmatic nested field typing. | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| form: any | |
| primaryParty: string | undefined | |
| selectedInstrumentId: TransferFormData['instrumentId'] | |
| selectedInstrument: SelectableInstrument | null | |
| disabled: boolean | |
| } | |
| const AmountField: React.FC<AmountFieldProps> = ({ | |
| form, | |
| primaryParty, | |
| selectedInstrumentId, | |
| selectedInstrument, | |
| disabled, | |
| }) => { | |
| const availableHolding = useInstrumentAvailableBalance( | |
| primaryParty, | |
| selectedInstrumentId | |
| ) | |
| const availableBalance = availableHolding?.availableAmount ?? '0' | |
| const amountValidator = useMemo( | |
| () => | |
| z | |
| .string() | |
| .min(1, 'Amount is required') | |
| .refine((value) => { | |
| try { | |
| return new Decimal(value).isPositive() | |
| } catch { | |
| return false | |
| } | |
| }, 'Amount must be a positive number') | |
| .refine((value) => { | |
| if (!selectedInstrumentId) return true | |
| try { | |
| const requested = new Decimal(value || '0') | |
| const available = new Decimal(availableBalance) | |
| return requested.lte(available) | |
| } catch { | |
| return false | |
| } | |
| }, `Amount exceeds available balance of ${availableBalance}`), | |
| [availableBalance, selectedInstrumentId] | |
| ) | |
| return ( | |
| <form.Field | |
| name="amount" | |
| validators={{ | |
| onChange: amountValidator, | |
| onSubmit: amountValidator, | |
| }} | |
| > | |
| {(field: AnyFieldApi) => ( | |
| <FieldBlock label="Amount"> | |
| <TextField | |
| type="number" | |
| value={field.state.value} | |
| onChange={(event) => | |
| field.handleChange(event.target.value) | |
| } | |
| onBlur={field.handleBlur} | |
| disabled={disabled} | |
| error={hasFieldError(field)} | |
| helperText={getFieldError(field)} | |
| slotProps={{ | |
| htmlInput: { | |
| min: 0, | |
| step: 'any', | |
| }, | |
| input: { | |
| endAdornment: selectedInstrument ? ( | |
| <InputAdornment position="end"> | |
| <Typography | |
| sx={{ color: 'text.primary' }} | |
| > | |
| {selectedInstrument.symbol} | |
| </Typography> | |
| </InputAdornment> | |
| ) : undefined, | |
| }, | |
| }} | |
| fullWidth | |
| sx={darkTextFieldSx} | |
| /> | |
| </FieldBlock> | |
| )} | |
| </form.Field> | |
| ) | |
| } | |
| const darkTextFieldSx: SxProps<Theme> = { | |
| '& .MuiInputBase-root, & .MuiPickersInputBase-root': { | |
| minHeight: 48, | |
| bgcolor: (theme) => theme.portfolio.surface.required, | |
| color: 'text.primary', | |
| borderRadius: 1, | |
| '& textarea': { | |
| lineHeight: 1.35, | |
| }, | |
| '& fieldset, & .MuiPickersOutlinedInput-notchedOutline': { | |
| border: 'none', | |
| }, | |
| '&:hover fieldset, &:hover .MuiPickersOutlinedInput-notchedOutline': { | |
| border: 'none', | |
| }, | |
| '&.Mui-focused fieldset, &.Mui-focused .MuiPickersOutlinedInput-notchedOutline': | |
| (theme) => ({ | |
| border: `1px solid ${theme.palette.secondary.main}`, | |
| }), | |
| '&.Mui-disabled': { | |
| bgcolor: 'action.disabledBackground', | |
| color: 'text.disabled', | |
| }, | |
| }, | |
| '& .MuiInputBase-input, & .MuiPickersInputBase-input': { | |
| px: 2.5, | |
| py: 1.25, | |
| '&::placeholder': { | |
| color: 'text.disabled', | |
| opacity: 1, | |
| }, | |
| }, | |
| '& .MuiFormHelperText-root': { | |
| ml: 0, | |
| mt: 1, | |
| color: 'text.secondary', | |
| fontSize: 14, | |
| }, | |
| '& .MuiFormHelperText-root.Mui-error': { | |
| color: 'error.main', | |
| }, | |
| } | |
| const hasFieldError = (field: AnyFieldApi) => | |
| field.state.meta.isTouched && field.state.meta.errors.length > 0 | |
| const getFieldError = (field: AnyFieldApi) => { | |
| if (!hasFieldError(field)) return undefined | |
| return formatFieldError(field.state.meta.errors[0]) | |
| } | |
| const formatFieldError = (error: unknown): string | undefined => { | |
| if (!error) return undefined | |
| if (Array.isArray(error)) return formatFieldError(error[0]) | |
| if (error instanceof Error) return error.message | |
| if (typeof error === 'object' && 'message' in error) { | |
| return String(error.message) | |
| } | |
| return String(error) | |
| } | |
| const formatWalletAddress = (value: string) => { | |
| if (value.length <= 44) return value | |
| return `${value.slice(0, 16)}...${value.slice(-24)}` | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment