diff --git a/package-lock.json b/package-lock.json index 983f776e2..2c4107a4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@table-library/react-table-library": "^4.0.23", "bootstrap": "^5.3.2", "classnames": "^2.3.2", - "formik": "^2.2.9", + "formik": "^2.4.5", "i18next": "^22.0.4", "i18next-browser-languagedetector": "^7.0.1", "qrcode": "^1.5.1", @@ -4625,6 +4625,15 @@ "@types/node": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -9868,9 +9877,9 @@ } }, "node_modules/formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz", + "integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==", "funding": [ { "type": "individual", @@ -9878,18 +9887,24 @@ } ], "dependencies": { + "@types/hoist-non-react-statics": "^3.3.1", "deepmerge": "^2.1.1", "hoist-non-react-statics": "^3.3.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "react-fast-compare": "^2.0.1", "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" + "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, + "node_modules/formik/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -21114,7 +21129,8 @@ "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/tsutils": { "version": "3.21.0", @@ -25834,6 +25850,15 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -29832,17 +29857,25 @@ } }, "formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz", + "integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==", "requires": { + "@types/hoist-non-react-statics": "^3.3.1", "deepmerge": "^2.1.1", "hoist-non-react-statics": "^3.3.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "react-fast-compare": "^2.0.1", "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + } } }, "forwarded": { @@ -38040,7 +38073,8 @@ "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index 3b5b11c72..b3e6a063f 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@table-library/react-table-library": "^4.0.23", "bootstrap": "^5.3.2", "classnames": "^2.3.2", - "formik": "^2.2.9", + "formik": "^2.4.5", "i18next": "^22.0.4", "i18next-browser-languagedetector": "^7.0.1", "qrcode": "^1.5.1", diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx index 5e5ede36a..0a60c98e0 100644 --- a/src/components/Accordion.tsx +++ b/src/components/Accordion.tsx @@ -25,7 +25,7 @@ const Accordion = ({
setIsOpen((current) => !current)} disabled={disabled} > @@ -50,7 +50,7 @@ const Accordion = ({
-
+
{children}
diff --git a/src/components/Earn.module.css b/src/components/Earn.module.css index f42076758..44c842f4f 100644 --- a/src/components/Earn.module.css +++ b/src/components/Earn.module.css @@ -20,16 +20,6 @@ font-size: 1.2rem; } -.earn hr { - border-color: rgba(222, 222, 222, 1); - opacity: 100%; -} - -:root[data-theme='dark'] .earn hr { - border-color: var(--bs-gray-800); - opacity: 100%; -} - .offerLoader { height: 10rem; border-radius: 0.25rem; diff --git a/src/components/MnemonicWordInput.tsx b/src/components/MnemonicWordInput.tsx index fcbf93a23..eb61abc8b 100644 --- a/src/components/MnemonicWordInput.tsx +++ b/src/components/MnemonicWordInput.tsx @@ -15,7 +15,7 @@ const MnemonicWordInput = ({ index, value, setValue, isValid, disabled }: Mnemon return ( {index + 1}. - [number, number] = (feeValues) => { - const feeTargetInSatsPerVByte = feeValues.tx_fees! / 1_000 +const feeRange: (txFee: TxFee, txFeeFactor: number) => [number, number] = (txFee, txFeeFactor) => { + if (txFee.unit !== 'sats/kilo-vbyte') { + throw new Error('This function can only be used with unit `sats/kilo-vbyte`') + } + const feeTargetInSatsPerVByte = txFee.value! / 1_000 const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte) - const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + feeValues.tx_fees_factor!) + const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + txFeeFactor) return [minFeeSatsPerVByte, maxFeeSatsPerVByte] } @@ -23,15 +26,17 @@ const useMiningFeeText = ({ feeConfigValues }: { feeConfigValues?: FeeValues }) const miningFeeText = useMemo(() => { if (!feeConfigValues) return null - if (!isValidNumber(feeConfigValues.tx_fees) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null + if (!isValidNumber(feeConfigValues.tx_fees?.value) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null - const unit = toTxFeeValueUnit(feeConfigValues.tx_fees) - if (!unit) { + if (!feeConfigValues.tx_fees?.unit) { return null - } else if (unit === 'blocks') { - return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees }) + } else if (feeConfigValues.tx_fees.unit === 'blocks') { + return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees.value }) } else { - const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange(feeConfigValues) + const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange( + feeConfigValues.tx_fees, + feeConfigValues.tx_fees_factor!, + ) const fractionDigits = 2 if (minFeeSatsPerVByte.toFixed(fractionDigits) === maxFeeSatsPerVByte.toFixed(fractionDigits)) { diff --git a/src/components/Receive.module.css b/src/components/Receive.module.css index 31adaff6f..23b573eea 100644 --- a/src/components/Receive.module.css +++ b/src/components/Receive.module.css @@ -1,16 +1,5 @@ -.receive hr { - border-color: rgba(222, 222, 222, 1); - opacity: 100%; -} - -:root[data-theme='dark'] .receive hr { - border-color: var(--bs-gray-800); - opacity: 100%; -} - .receive button { font-weight: 500; - border-color: rgba(222, 222, 222, 1); } .receive form input { diff --git a/src/components/Send/AmountInputField.module.css b/src/components/Send/AmountInputField.module.css new file mode 100644 index 000000000..281596345 --- /dev/null +++ b/src/components/Send/AmountInputField.module.css @@ -0,0 +1,13 @@ +.inputLoader { + height: 3.5rem; + border-radius: 0.25rem; +} + +.input { + height: 3.5rem; + width: 100%; +} + +.button { + font-size: 0.875rem !important; +} diff --git a/src/components/Send/AmountInputField.tsx b/src/components/Send/AmountInputField.tsx new file mode 100644 index 000000000..b21fb135e --- /dev/null +++ b/src/components/Send/AmountInputField.tsx @@ -0,0 +1,143 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import * as rb from 'react-bootstrap' +import classNames from 'classnames' +import { useField, useFormikContext } from 'formik' +import * as Api from '../../libs/JmWalletApi' +import Sprite from '../Sprite' +import { AccountBalanceSummary } from '../../context/BalanceSummary' +import { noop } from '../../utils' +import styles from './AmountInputField.module.css' + +export type AmountValue = { + value: Api.AmountSats | null + isSweep: boolean +} + +export type AmountInputFieldProps = { + name: string + label: string + className?: string + sourceJarBalance?: AccountBalanceSummary + isLoading: boolean + disabled?: boolean +} + +export const AmountInputField = ({ + name, + label, + className, + sourceJarBalance, + isLoading, + disabled = false, +}: AmountInputFieldProps) => { + const { t } = useTranslation() + const [field] = useField(name) + const form = useFormikContext() + + const amountFieldValue = useMemo(() => { + if (field.value?.isSweep) { + if (!sourceJarBalance) return '' + return `${sourceJarBalance.calculatedAvailableBalanceInSats}` + } + + return field.value?.value ?? '' + }, [sourceJarBalance, field]) + + return ( + <> + + {label} + + {isLoading ? ( + + + + ) : ( + <> + {field.value?.isSweep === true ? ( + + + { + form.setFieldValue(field.name, form.initialValues[field.name], true) + }} + disabled={disabled} + > +
+ + <>{t('send.button_clear_sweep')} +
+
+
+ ) : ( +
+ + { + const value = e.target.value + form.setFieldValue( + field.name, + { + value: value === '' ? null : parseInt(value, 10), + fromJar: null, + }, + true, + ) + }} + isInvalid={form.touched[field.name] && !!form.errors[field.name]} + disabled={disabled} + /> + { + if (!sourceJarBalance) return + form.setFieldValue( + field.name, + { + value: 0, + isSweep: true, + }, + true, + ) + }} + disabled={disabled || !sourceJarBalance} + > +
+ + {t('send.button_sweep')} +
+
+ {form.errors[field.name]} +
+
+ )} + + )} +
+ + ) +} diff --git a/src/components/Send/CollaboratorsSelector.tsx b/src/components/Send/CollaboratorsSelector.tsx index e9f93c817..a3a0755b2 100644 --- a/src/components/Send/CollaboratorsSelector.tsx +++ b/src/components/Send/CollaboratorsSelector.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useField, useFormikContext } from 'formik' import { useSettings } from '../../context/SettingsContext' import * as rb from 'react-bootstrap' import classNames from 'classnames' @@ -7,45 +8,54 @@ import styles from './CollaboratorsSelector.module.css' import { isValidNumCollaborators } from './helpers' type CollaboratorsSelectorProps = { - numCollaborators: number | null - setNumCollaborators: (val: number | null) => void + name: string minNumCollaborators: number + maxNumCollaborators: number disabled?: boolean } const CollaboratorsSelector = ({ - numCollaborators, - setNumCollaborators, + name, minNumCollaborators, + maxNumCollaborators, disabled = false, }: CollaboratorsSelectorProps) => { const { t } = useTranslation() const settings = useSettings() - const [usesCustomNumCollaborators, setUsesCustomNumCollaborators] = useState(false) + const [field] = useField(name) + const form = useFormikContext() + + const [customNumCollaboratorsInput, setCustomNumCollaboratorsInput] = useState() const defaultCollaboratorsSelection = useMemo(() => { const start = Math.max(minNumCollaborators, 8) return [start, start + 1, start + 2] }, [minNumCollaborators]) + const usesCustomNumCollaborators = useMemo(() => { + return field.value === undefined || String(field.value) === customNumCollaboratorsInput + }, [field.value, customNumCollaboratorsInput]) + const validateAndSetCustomNumCollaborators = (candidate: string) => { const parsed = parseInt(candidate, 10) if (isValidNumCollaborators(parsed, minNumCollaborators)) { - setNumCollaborators(parsed) + form.setFieldValue(field.name, parsed, true) } else { - setNumCollaborators(null) + form.setFieldValue(field.name, undefined, true) } } return ( - {t('send.label_num_collaborators', { numCollaborators })} + + {t('send.label_num_collaborators', { numCollaborators: field.value })} +
{t('send.description_num_collaborators')}
{defaultCollaboratorsSelection.map((number) => { - const isSelected = !usesCustomNumCollaborators && numCollaborators === number + const isSelected = !usesCustomNumCollaborators && field.value === number return ( { - setUsesCustomNumCollaborators(false) - setNumCollaborators(number) + validateAndSetCustomNumCollaborators(String(number)) }} disabled={disabled} > @@ -66,32 +75,27 @@ const CollaboratorsSelector = ({ { - setUsesCustomNumCollaborators(true) + setCustomNumCollaboratorsInput(e.target.value) validateAndSetCustomNumCollaborators(e.target.value) }} - onClick={(e) => { - // @ts-ignore - FIXME: "Property 'value' does not exist on type 'EventTarget'" - if (e.target.value !== '') { - setUsesCustomNumCollaborators(true) - // @ts-ignore - FIXME: "Property 'value' does not exist on type 'EventTarget'" - validateAndSetCustomNumCollaborators(e.target.value) + onClick={(e: any) => { + const val = e.target?.value + if (val !== undefined && val !== '') { + setCustomNumCollaboratorsInput(val) + validateAndSetCustomNumCollaborators(val) } }} disabled={disabled} /> - {usesCustomNumCollaborators && ( - - {t('send.error_invalid_num_collaborators', { minNumCollaborators, maxNumCollaborators: 99 })} - - )} + {form.errors[field.name]}
) diff --git a/src/components/Send/DestinationInputField.module.css b/src/components/Send/DestinationInputField.module.css new file mode 100644 index 000000000..419e00af4 --- /dev/null +++ b/src/components/Send/DestinationInputField.module.css @@ -0,0 +1,9 @@ +.inputLoader { + height: 3.5rem; + border-radius: 0.25rem; +} + +.input { + height: 3.5rem; + width: 100%; +} diff --git a/src/components/Send/DestinationInputField.tsx b/src/components/Send/DestinationInputField.tsx new file mode 100644 index 000000000..c83dc69f4 --- /dev/null +++ b/src/components/Send/DestinationInputField.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import * as rb from 'react-bootstrap' +import classNames from 'classnames' +import { useField, useFormikContext } from 'formik' +import * as Api from '../../libs/JmWalletApi' +import Sprite from '../Sprite' +import { jarName } from '../jars/Jar' +import JarSelectorModal from '../JarSelectorModal' + +import { WalletInfo } from '../../context/WalletContext' +import { noop } from '../../utils' +import styles from './DestinationInputField.module.css' + +export type DestinationValue = { + value: Api.BitcoinAddress | null + fromJar: JarIndex | null +} + +export type DestinationInputFieldProps = { + name: string + label: string + className?: string + walletInfo?: WalletInfo + sourceJarIndex?: JarIndex + loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise + isLoading: boolean + disabled?: boolean +} + +export const DestinationInputField = ({ + name, + label, + className, + walletInfo, + sourceJarIndex, + loadNewWalletAddress, + isLoading, + disabled = false, +}: DestinationInputFieldProps) => { + const { t } = useTranslation() + const [field] = useField(name) + const form = useFormikContext() + + const [destinationJarPickerShown, setDestinationJarPickerShown] = useState(false) + + return ( + <> + {!isLoading && walletInfo && ( + <> + setDestinationJarPickerShown(false)} + onConfirm={(selectedJar) => { + const abortCtrl = new AbortController() + + return loadNewWalletAddress({ + signal: abortCtrl.signal, + jarIndex: selectedJar, + }) + .then((address) => { + if (abortCtrl.signal.aborted) return + form.setFieldValue( + field.name, + { + value: address, + fromJar: selectedJar, + }, + true, + ) + + setDestinationJarPickerShown(false) + }) + .catch((err) => { + if (abortCtrl.signal.aborted) return + setDestinationJarPickerShown(false) + }) + }} + /> + + )} + + {label} + {isLoading ? ( + + + + ) : ( + <> + {field.value.fromJar !== null ? ( + + + { + form.setFieldValue(field.name, form.initialValues[field.name], true) + }} + disabled={disabled} + > +
+ +
+
+
+ ) : ( +
+ + { + const value = e.target.value + form.setFieldValue( + field.name, + { + value: value === '' ? null : value, + fromJar: null, + }, + true, + ) + }} + isInvalid={form.touched[field.name] && !!form.errors[field.name]} + disabled={disabled} + /> + setDestinationJarPickerShown(true)} + disabled={disabled || !walletInfo} + > +
+ +
+
+ {form.errors[field.name]} +
+
+ )} + + )} +
+ + ) +} diff --git a/src/components/Send/Send.module.css b/src/components/Send/Send.module.css deleted file mode 100644 index e4539b2f1..000000000 --- a/src/components/Send/Send.module.css +++ /dev/null @@ -1,158 +0,0 @@ -.input { - height: 3.5rem; - width: 100%; -} - -.jarInput { - padding-right: 5rem; -} - -/* Chrome, Safari, Edge, Opera */ -input::-webkit-outer-spin-button, -input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -/* Firefox */ -input[type='number'] { - -moz-appearance: textfield; -} - -.buttonJarSelector { - position: absolute; - top: 50%; - right: 0.75rem; - transform: translate(0%, -50%); - padding: 0.2rem 0.5rem !important; - border: none !important; -} - -:root[data-theme='dark'] .buttonJarSelector { - color: var(--bs-gray-800) !important; -} -:root[data-theme='dark'] .buttonJarSelector:hover { - color: var(--bs-white) !important; -} - -.buttonSweep { - width: 6rem; - height: 2rem; - position: absolute; - top: 50%; - right: 0.75rem; - transform: translate(0%, -50%); - padding: 0.2rem 0.5rem !important; -} - -:global .position-relative .form-control.is-invalid + button { - right: 2.1875rem !important; -} - -:root[data-theme='dark'] .buttonSweep { - color: var(--bs-gray-800) !important; -} -:root[data-theme='dark'] .buttonSweep:hover { - color: var(--bs-white) !important; -} - -.buttonSweepItem { - display: flex; - align-items: center; - justify-content: center; - gap: 0.2rem; - padding-right: 0.2rem; - font-size: 0.9rem; -} - -.sweepBreakdown { - font-size: 0.8rem; -} - -.sweepBreakdownTable { - color: var(--bs-black); -} - -:root[data-theme='dark'] .sweepBreakdownTable { - color: var(--bs-white); -} - -.sweepBreakdownTable .balanceCol { - text-align: right; -} - -.accordionButton { - background-color: transparent; - color: var(--bs-black); - font-size: 0.8rem; - height: 1rem; - padding: 0; - box-shadow: none; -} - -.sweepBreakdownAnchor { - font-size: 0.8rem; - color: var(--bs-black); -} - -:root[data-theme='dark'] .sweepBreakdownAnchor { - color: var(--bs-white) !important; -} - -:root[data-theme='dark'] .sweepBreakdownParagraph { - color: var(--bs-white) !important; -} - -.inputLoader { - height: 3.5rem; - border-radius: 0.25rem; -} - -.serviceRunning .sendForm, -.serviceRunning .collaboratorsSelector, -.serviceRunning .sendButton { - filter: blur(2px); -} - -.accordionButton { - border: none; - width: 100%; - background-color: transparent; - color: var(--bs-black); - display: flex; - justify-content: flex-end; - font-size: 0.8rem; - height: 1rem; - padding: 0; - box-shadow: none; -} - -.accordionButton:hover { - text-decoration: underline; -} - -.accordionButton::after { - width: 0rem; - height: 0rem; - background-image: none; -} - -:root[data-theme='dark'] .accordionButton { - color: var(--bs-white); -} - -.sourceJarsContainer { - display: flex; - flex-wrap: wrap; - flex-direction: row; - justify-content: center; - align-items: flex-start; - gap: 2rem; - color: var(--bs-body-color); - margin-bottom: 1.5rem; -} - -.sourceJarsPlaceholder { - width: 100%; - height: 8rem; -} diff --git a/src/components/Send/SendForm.module.css b/src/components/Send/SendForm.module.css new file mode 100644 index 000000000..e9cc4d501 --- /dev/null +++ b/src/components/Send/SendForm.module.css @@ -0,0 +1,3 @@ +.blurred { + filter: blur(2px); +} diff --git a/src/components/Send/SendForm.tsx b/src/components/Send/SendForm.tsx new file mode 100644 index 000000000..0941999f4 --- /dev/null +++ b/src/components/Send/SendForm.tsx @@ -0,0 +1,412 @@ +import { useState, useMemo } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Formik, FormikErrors, FormikProps } from 'formik' +import * as rb from 'react-bootstrap' +import * as Api from '../../libs/JmWalletApi' +import ToggleSwitch from '../ToggleSwitch' +import Sprite from '../Sprite' +import { SourceJarSelector } from './SourceJarSelector' +import { CoinjoinPreconditionViolationAlert } from '../CoinjoinPreconditionViolationAlert' +import { DestinationInputField, DestinationValue } from './DestinationInputField' +import { AmountInputField, AmountValue } from './AmountInputField' +import CollaboratorsSelector from './CollaboratorsSelector' +import { SweepBreakdown } from './SweepBreakdown' +import FeeBreakdown from './FeeBreakdown' +import Accordion from '../Accordion' +import FeeConfigModal, { FeeConfigSectionKey } from '../settings/FeeConfigModal' +import { useEstimatedMaxCollaboratorFee, FeeValues } from '../../hooks/Fees' +import { buildCoinjoinRequirementSummary } from '../../hooks/CoinjoinRequirements' +import { formatSats } from '../../utils' +import { + MAX_NUM_COLLABORATORS, + isValidAddress, + isValidAmount, + isValidJarIndex, + isValidNumCollaborators, +} from './helpers' +import { AccountBalanceSummary } from '../../context/BalanceSummary' +import { WalletInfo } from '../../context/WalletContext' +import styles from './SendForm.module.css' + +type CollaborativeTransactionOptionsProps = { + selectedAmount?: AmountValue + selectedNumCollaborators?: number + sourceJarBalance?: AccountBalanceSummary + isLoading: boolean + disabled?: boolean + minNumCollaborators: number + numCollaborators: number | null + setNumCollaborators: (val: number | null) => void + feeConfigValues?: FeeValues + reloadFeeConfigValues: () => void +} + +function CollaborativeTransactionOptions({ + selectedAmount, + selectedNumCollaborators, + sourceJarBalance, + isLoading, + disabled, + minNumCollaborators, + feeConfigValues, + reloadFeeConfigValues, +}: CollaborativeTransactionOptionsProps) { + const { t } = useTranslation() + + const [activeFeeConfigModalSection, setActiveFeeConfigModalSection] = useState() + const [showFeeConfigModal, setShowFeeConfigModal] = useState(false) + + const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({ + feeConfigValues, + amount: + selectedAmount?.isSweep && sourceJarBalance + ? sourceJarBalance.calculatedAvailableBalanceInSats + : selectedAmount?.value ?? null, + numCollaborators: selectedNumCollaborators ?? null, + isCoinjoin: true, + }) + + return ( + <> + + + + + {t('send.fee_breakdown.title', { + maxCollaboratorFee: estimatedMaxCollaboratorFee + ? `≤${formatSats(estimatedMaxCollaboratorFee)} sats` + : '...', + })} + + + { + setActiveFeeConfigModalSection('cj_fee') + setShowFeeConfigModal(true) + }} + className="text-decoration-underline link-secondary" + /> + ), + }} + /> + + + + { + setActiveFeeConfigModalSection('tx_fee') + setShowFeeConfigModal(true) + }} + className="text-decoration-underline link-secondary" + /> + ), + }} + /> + + + {sourceJarBalance && ( + { + setActiveFeeConfigModalSection('cj_fee') + setShowFeeConfigModal(true) + }} + /> + )} + + {showFeeConfigModal && ( + reloadFeeConfigValues()} + onHide={() => setShowFeeConfigModal(false)} + defaultActiveSectionKey={activeFeeConfigModalSection} + /> + )} + + + ) +} + +type SubmitButtonProps = { + isLoading: boolean + isSubmitting: boolean + isCoinJoin: boolean + isPreconditionFulfilled: boolean + disabled?: boolean +} + +function SubmitButton({ isLoading, isSubmitting, isCoinJoin, isPreconditionFulfilled, disabled }: SubmitButtonProps) { + const { t } = useTranslation() + + const submitButtonOptions = useMemo(() => { + if (isSubmitting) { + return { + variant: 'dark', + element: ( + <> +