diff --git a/packages/common/src/store/ui/modals/createModal.ts b/packages/common/src/store/ui/modals/createModal.ts index 9274f2d6b6..91dc331b90 100644 --- a/packages/common/src/store/ui/modals/createModal.ts +++ b/packages/common/src/store/ui/modals/createModal.ts @@ -30,6 +30,9 @@ export const createModal = ({ open: (_, action: PayloadAction) => { return { ...action.payload, isOpen: true } }, + set: (state, action: PayloadAction) => { + return { ...state, ...action.payload } + }, close: (state) => { state.isOpen = 'closing' }, @@ -59,7 +62,7 @@ export const createModal = ({ state: T & BaseModalState ) => PayloadAction - const { close, closed } = slice.actions + const { close, closed, set } = slice.actions /** * A hook that returns the state of the modal, * an open callback that opens the modal, @@ -88,9 +91,17 @@ export const createModal = ({ dispatch(closed()) }, [dispatch]) + const setData = useCallback( + (state?: Partial) => { + dispatch(set({ ...state })) + }, + [dispatch] + ) + return { isOpen: isOpen === true, data, + setData, onOpen, onClose, onClosed diff --git a/packages/common/src/store/ui/modals/withdraw-usdc-modal/index.ts b/packages/common/src/store/ui/modals/withdraw-usdc-modal/index.ts index 295540771d..7f48ca1390 100644 --- a/packages/common/src/store/ui/modals/withdraw-usdc-modal/index.ts +++ b/packages/common/src/store/ui/modals/withdraw-usdc-modal/index.ts @@ -9,6 +9,8 @@ export enum WithdrawUSDCModalPages { export type WithdrawUSDCModalState = { page: WithdrawUSDCModalPages + // Completed transaction signature + signature?: string } const withdrawUSDCModal = createModal({ diff --git a/packages/web/src/components/withdraw-usdc-modal/WithdrawUSDCModal.tsx b/packages/web/src/components/withdraw-usdc-modal/WithdrawUSDCModal.tsx index a7ae05526d..32799328d4 100644 --- a/packages/web/src/components/withdraw-usdc-modal/WithdrawUSDCModal.tsx +++ b/packages/web/src/components/withdraw-usdc-modal/WithdrawUSDCModal.tsx @@ -1,9 +1,18 @@ -import { useWithdrawUSDCModal, WithdrawUSDCModalPages } from '@audius/common' +import { + SolanaWalletAddress, + useUSDCBalance, + useWithdrawUSDCModal, + WithdrawUSDCModalPages +} from '@audius/common' import { Modal, ModalContent, ModalHeader } from '@audius/stems' +import { Formik } from 'formik' +import { z } from 'zod' +import { toFormikValidationSchema } from 'zod-formik-adapter' import { ReactComponent as IconTransaction } from 'assets/img/iconTransaction.svg' import { Icon } from 'components/Icon' import { Text } from 'components/typography' +import { isValidSolAddress } from 'services/solana/solana' import styles from './WithdrawUSDCModal.module.css' import { ConfirmTransferDetails } from './components/ConfirmTransferDetails' @@ -12,12 +21,53 @@ import { TransferInProgress } from './components/TransferInProgress' import { TransferSuccessful } from './components/TransferSuccessful' const messages = { - title: 'Withdraw Funds' + title: 'Withdraw Funds', + errors: { + insufficientBalance: + 'Your USDC wallet does not have enough funds to cover this transaction.', + invalidAddress: 'A valid Solana USDC wallet address is required.', + pleaseConfirm: + 'Please confirm you have reviewed the details and accept responsibility for any errors resulting in lost funds.' + } +} + +export const AMOUNT = 'amount' +export const ADDRESS = 'address' +export const CONFIRM = 'confirm' + +const WithdrawUSDCFormSchema = (userBalance: number) => { + return z.object({ + [AMOUNT]: z.number().lte(userBalance, messages.errors.insufficientBalance), + [ADDRESS]: z + .string() + .refine( + (value) => isValidSolAddress(value as SolanaWalletAddress), + messages.errors.invalidAddress + ), + [CONFIRM]: z.literal(true) + }) } export const WithdrawUSDCModal = () => { const { isOpen, onClose, onClosed, data } = useWithdrawUSDCModal() const { page } = data + const { data: balance } = useUSDCBalance() + + let formPage + switch (page) { + case WithdrawUSDCModalPages.ENTER_TRANSFER_DETAILS: + formPage = + break + case WithdrawUSDCModalPages.CONFIRM_TRANSFER_DETAILS: + formPage = + break + case WithdrawUSDCModalPages.TRANSFER_IN_PROGRESS: + formPage = + break + case WithdrawUSDCModalPages.TRANSFER_SUCCESSFUL: + formPage = + break + } return ( { - {page === WithdrawUSDCModalPages.ENTER_TRANSFER_DETAILS ? ( - - ) : null} - {page === WithdrawUSDCModalPages.CONFIRM_TRANSFER_DETAILS ? ( - - ) : null} - {page === WithdrawUSDCModalPages.TRANSFER_IN_PROGRESS ? ( - - ) : null} - {page === WithdrawUSDCModalPages.TRANSFER_SUCCESSFUL ? ( - - ) : null} + { + console.info(values) + }} + > + {formPage} + ) diff --git a/packages/web/src/components/withdraw-usdc-modal/components/ConfirmTransferDetails.tsx b/packages/web/src/components/withdraw-usdc-modal/components/ConfirmTransferDetails.tsx index 82408c796a..ad31a1d5ec 100644 --- a/packages/web/src/components/withdraw-usdc-modal/components/ConfirmTransferDetails.tsx +++ b/packages/web/src/components/withdraw-usdc-modal/components/ConfirmTransferDetails.tsx @@ -1,3 +1,6 @@ +import { useCallback } from 'react' + +import { WithdrawUSDCModalPages, useWithdrawUSDCModal } from '@audius/common' import { HarmonyButton, HarmonyButtonSize, @@ -5,10 +8,16 @@ import { IconQuestionCircle, Switch } from '@audius/stems' +import { useField, useFormikContext } from 'formik' import { ReactComponent as IconCaretLeft } from 'assets/img/iconCaretLeft.svg' import { Divider } from 'components/divider' import { Text } from 'components/typography' +import { + ADDRESS, + AMOUNT, + CONFIRM +} from 'components/withdraw-usdc-modal/WithdrawUSDCModal' import styles from './ConfirmTransferDetails.module.css' import { Hint } from './Hint' @@ -29,18 +38,31 @@ const messages = { } export const ConfirmTransferDetails = () => { - const wallet = '72pepj' - const amount = '200' + const { submitForm } = useFormikContext() + const { setData } = useWithdrawUSDCModal() + const [{ value: amountValue }] = useField(AMOUNT) + const [{ value: addressValue }] = useField(ADDRESS) + const [confirmField, { error: confirmError }] = useField(CONFIRM) + + const handleGoBack = useCallback(() => { + setData({ page: WithdrawUSDCModalPages.ENTER_TRANSFER_DETAILS }) + }, [setData]) + + const handleContinue = useCallback(() => { + setData({ page: WithdrawUSDCModalPages.TRANSFER_IN_PROGRESS }) + submitForm() + }, [setData, submitForm]) + return (
- +
- {wallet} + {addressValue}
@@ -51,7 +73,7 @@ export const ConfirmTransferDetails = () => { {messages.byProceeding}
- {}} /> + {messages.haveCarefully} @@ -63,11 +85,14 @@ export const ConfirmTransferDetails = () => { variant={HarmonyButtonType.SECONDARY} size={HarmonyButtonSize.DEFAULT} text={messages.goBack} + onClick={handleGoBack} />
{ const { data: balance } = useUSDCBalance() + const { setData } = useWithdrawUSDCModal() + const balanceNumber = formatUSDCWeiToNumber((balance ?? new BN(0)) as BNUSDC) const balanceFormatted = formatCurrencyBalance(balanceNumber) + + const [ + { value }, + { error: amountError }, + { setValue: setAmount, setTouched: setAmountTouched } + ] = useField(AMOUNT) + const [humanizedValue, setHumanizedValue] = useState( + ((value || balanceNumber) / 100).toFixed(PRECISION) + ) + const handleAmountChange: ChangeEventHandler = useCallback( + (e) => { + const { human, value } = onTokenInputChange(e) + setHumanizedValue(human) + setAmount(value) + }, + [setAmount, setHumanizedValue] + ) + const handleAmountBlur: FocusEventHandler = useCallback( + (e) => { + setHumanizedValue(onTokenInputBlur(e)) + setAmountTouched(true) + }, + [setHumanizedValue, setAmountTouched] + ) + + const [, { error: addressError }] = useField(ADDRESS) + + const handleContinue = useCallback(() => { + setData({ page: WithdrawUSDCModalPages.CONFIRM_TRANSFER_DETAILS }) + }, [setData]) + return (
@@ -49,7 +102,17 @@ export const EnterTransferDetails = () => { {messages.specify}
- +
@@ -59,9 +122,12 @@ export const EnterTransferDetails = () => { {messages.destinationDetails}
-
{ size={HarmonyButtonSize.DEFAULT} fullWidth text={messages.continue} + disabled={amountError || addressError} + onClick={handleContinue} /> { const { data: balance } = useUSDCBalance() const balanceNumber = formatUSDCWeiToNumber((balance ?? new BN(0)) as BNUSDC) const balanceFormatted = formatCurrencyBalance(balanceNumber) - const wallet = '72pepj' - const amount = '200' + + const [{ value: amountValue }] = useField(AMOUNT) + const [{ value: addressValue }] = useField(ADDRESS) return (
@@ -34,13 +40,13 @@ export const TransferInProgress = () => { left={messages.currentBalance} right={`$${balanceFormatted}`} /> - +
- {wallet} + {addressValue}
diff --git a/packages/web/src/components/withdraw-usdc-modal/components/TransferSuccessful.tsx b/packages/web/src/components/withdraw-usdc-modal/components/TransferSuccessful.tsx index 1319dcd9b4..9f22d13157 100644 --- a/packages/web/src/components/withdraw-usdc-modal/components/TransferSuccessful.tsx +++ b/packages/web/src/components/withdraw-usdc-modal/components/TransferSuccessful.tsx @@ -2,7 +2,8 @@ import { useUSDCBalance, formatUSDCWeiToNumber, formatCurrencyBalance, - BNUSDC + BNUSDC, + useWithdrawUSDCModal } from '@audius/common' import { HarmonyPlainButton, @@ -11,11 +12,16 @@ import { IconCheck } from '@audius/stems' import BN from 'bn.js' +import { useField } from 'formik' import { ReactComponent as IconExternalLink } from 'assets/img/iconExternalLink.svg' import { Icon } from 'components/Icon' import { Divider } from 'components/divider' import { Text } from 'components/typography' +import { + ADDRESS, + AMOUNT +} from 'components/withdraw-usdc-modal/WithdrawUSDCModal' import { TextRow } from './TextRow' import styles from './TransferSuccessful.module.css' @@ -38,17 +44,19 @@ const openExplorer = (signature: string) => { export const TransferSuccessful = () => { const { data: balance } = useUSDCBalance() + const { data: modalData } = useWithdrawUSDCModal() const balanceNumber = formatUSDCWeiToNumber((balance ?? new BN(0)) as BNUSDC) const balanceFormatted = formatCurrencyBalance(balanceNumber) - const wallet = '72pepj' - const amount = '200' - const signature = - '1qKTUXAdN5Li9DJt78g9qsazvo8SPXooea6GsjGQKFZvt98YCPFCBsujfxPWLmAcsSvQGnuMScSt6Mngu6hxPYu' + + const [{ value: amountValue }] = useField(AMOUNT) + const [{ value: addressValue }] = useField(ADDRESS) + + const { signature } = modalData return (
- +
@@ -57,11 +65,11 @@ export const TransferSuccessful = () => {
- {wallet} + {addressValue} openExplorer(signature)} + onClick={() => openExplorer(signature ?? '')} iconRight={IconExternalLink} variant={HarmonyPlainButtonType.SUBDUED} size={HarmonyPlainButtonSize.DEFAULT} diff --git a/packages/web/src/pages/upload-page/fields/availability/UsdcPurchaseFields.tsx b/packages/web/src/pages/upload-page/fields/availability/UsdcPurchaseFields.tsx index 5bf5600e24..b27df06727 100644 --- a/packages/web/src/pages/upload-page/fields/availability/UsdcPurchaseFields.tsx +++ b/packages/web/src/pages/upload-page/fields/availability/UsdcPurchaseFields.tsx @@ -11,6 +11,11 @@ import { useField } from 'formik' import { TextField, TextFieldProps } from 'components/form-fields' import layoutStyles from 'components/layout/layout.module.css' import { Text } from 'components/typography' +import { + PRECISION, + onTokenInputBlur, + onTokenInputChange +} from 'utils/tokenInput' import { PREVIEW, PRICE } from '../AccessAndSaleField' @@ -40,8 +45,6 @@ export enum UsdcPurchaseType { FOLLOW = 'follow' } -const PRECISION = 2 - type TrackAvailabilityFieldsProps = { disabled?: boolean } @@ -96,30 +99,16 @@ const PriceField = (props: TrackAvailabilityFieldsProps) => { const handlePriceChange: ChangeEventHandler = useCallback( (e) => { - const input = e.target.value.replace(/[^0-9.]+/g, '') - // Regex to grab the whole and decimal parts of the number, stripping duplicate '.' characters - const match = input.match(/^(?\d*)(?.)?(?\d*)/) - const { whole, decimal, dot } = match?.groups || {} - - // Conditionally render the decimal part, and only for the number of decimals specified - const stringAmount = dot - ? `${whole}.${(decimal ?? '').substring(0, PRECISION)}` - : whole - setHumanizedValue(stringAmount) - setPrice(Number(stringAmount) * 100) + const { human, value } = onTokenInputChange(e) + setHumanizedValue(human) + setPrice(value) }, - [setPrice] + [setPrice, setHumanizedValue] ) const handlePriceBlur: FocusEventHandler = useCallback( (e) => { - const precision = 2 - const [whole, decimal] = e.target.value.split('.') - - const paddedDecimal = (decimal ?? '') - .substring(0, precision) - .padEnd(precision, '0') - setHumanizedValue(`${whole.length > 0 ? whole : '0'}.${paddedDecimal}`) + setHumanizedValue(onTokenInputBlur(e)) }, [] ) diff --git a/packages/web/src/utils/tokenInput.ts b/packages/web/src/utils/tokenInput.ts new file mode 100644 index 0000000000..89e442d0d3 --- /dev/null +++ b/packages/web/src/utils/tokenInput.ts @@ -0,0 +1,32 @@ +import { ChangeEvent, FocusEvent } from 'react' + +export const PRECISION = 2 + +/** + * Helper to parse change events on a token input and give numeric values and human readable values out + * @param e HTMLInputElement change event + */ +export const onTokenInputChange = (e: ChangeEvent) => { + const input = e.target.value.replace(/[^0-9.]+/g, '') + // Regex to grab the whole and decimal parts of the number, stripping duplicate '.' characters + const match = input.match(/^(?\d*)(?.)?(?\d*)/) + const { whole, decimal, dot } = match?.groups || {} + + // Conditionally render the decimal part, and only for the number of decimals specified + const stringAmount = dot + ? `${whole}.${(decimal ?? '').substring(0, PRECISION)}` + : whole + return { human: stringAmount, value: Number(stringAmount) * 100 } +} + +/** + * Helper to parse blur/focus events on a token input and pad the value to precision + */ +export const onTokenInputBlur = (e: FocusEvent) => { + const [whole, decimal] = e.target.value.split('.') + + const paddedDecimal = (decimal ?? '') + .substring(0, PRECISION) + .padEnd(PRECISION, '0') + return `${whole.length > 0 ? whole : '0'}.${paddedDecimal}` +}