diff --git a/.env b/.env
index 07d7fd7d..e967dd87 100644
--- a/.env
+++ b/.env
@@ -6,9 +6,15 @@ VITE_JAM_JM_SESSION_REFRESH_INTERVAL=30000
VITE_JAM_RESCAN_PROGRESS_INTERVAL=10000
VITE_JAM_SEED_MODAL_TIMEOUT=30000
+VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=3
+VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
+
VITE_JM_WEBSOCKET_ENDPOINT_PATH=/jmws
VITE_JM_WEBSOCKET_CONNECTION_HEALTHY_DURATION=1000
VITE_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DURATION=3000
VITE_JM_WEBSOCKET_KEEPALIVE_MESSAGE_INTERVAL=30000
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN=5000
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=60000
+
+VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT=10
+VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT=90
diff --git a/.env.development b/.env.development
index ea09cf5e..3a37e0c6 100644
--- a/.env.development
+++ b/.env.development
@@ -5,3 +5,11 @@ VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN=3000
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=30000
VITE_JAM_SEED_MODAL_TIMEOUT=21000
+
+VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=59
+VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=59
+
+VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1
+VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
+
+VITE_JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS=1
diff --git a/.env.test b/.env.test
new file mode 100644
index 00000000..f379760f
--- /dev/null
+++ b/.env.test
@@ -0,0 +1,5 @@
+VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1
+VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
+
+VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=1
+VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=1
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 862ef55f..01fa7889 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -50,7 +50,7 @@ jobs:
run: npm run format:check
- name: Test
- run: npm test -- --coverage.enabled
+ run: npm run test:coverage
- name: Build
run: npm run build
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index ad3798d6..d4363bf0 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -6,7 +6,7 @@ import { initialize, mswLoader } from 'msw-storybook-addon'
import { ThemeProvider } from 'next-themes'
import { I18nextProvider } from 'react-i18next'
import { MemoryRouter } from 'react-router-dom'
-import { CoreTypes, GlobalTypes } from 'storybook/internal/csf'
+import type { CoreTypes, GlobalTypes } from 'storybook/internal/csf'
import { DEFAULT_VIEWPORT, MINIMAL_VIEWPORTS, INITIAL_VIEWPORTS } from 'storybook/viewport'
import { JamDisplayContextProvider } from '../src/context/JamDisplayContextProvider'
import { JamWalletInfoContextProvider } from '../src/context/JamWalletInfoContextProvider'
diff --git a/docker/regtest/init-setup.sh b/docker/regtest/init-setup.sh
index a5093503..a5cd53e1 100755
--- a/docker/regtest/init-setup.sh
+++ b/docker/regtest/init-setup.sh
@@ -35,10 +35,14 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
dummy_wallet_address1='bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' # 1st address of jar A (m/84'/1'/0'/0/0)
dummy_wallet_address2='bcrt1qt5yxk3xzrx66q9wd5sdyynklqynqcyf7uh74j3' # 8th address of jar C (m/84'/1'/2'/0/7)
dummy_wallet_address3='bcrt1qn8804dw5fahuc5cwqteuq5j4xlhk2cnkq7a8kw' # 21st change address of jar E (m/84'/1'/4'/1/21)
+dummy_wallet_address_fb0='bcrt1q7war3lusq3ez633rzzfkp75unfcgrqqcu80sgp0ellhrdvkzxh5swspygk' # 1st fb address (m/84'/1'/0'/2/0)
+dummy_wallet_address_fb959='bcrt1q6rlcknptmqhf4w20928tpz6px4y70960gyupd029c7vx7kdjwt0q46nk9c' # 960th fb address (m/84'/1'/0'/2/959)
# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address1" &>/dev/null
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address2" &>/dev/null
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address3" &>/dev/null
+. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb0" &>/dev/null
+. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb959" &>/dev/null
. "$script_dir/mine-block.sh" 100 &>/dev/null
start_maker() {
diff --git a/package-lock.json b/package-lock.json
index dcce415e..af7b65d8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"@radix-ui/react-radio-group": "1.4.3",
"@radix-ui/react-select": "2.3.3",
"@radix-ui/react-separator": "1.1.11",
+ "@radix-ui/react-slider": "1.4.3",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-switch": "1.3.3",
"@radix-ui/react-tabs": "1.1.17",
diff --git a/package.json b/package.json
index a6e06df8..52eb1eaa 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"test": "NODE_OPTIONS=--no-webstorage vitest run",
"test:unit": "NODE_OPTIONS=--no-webstorage vitest run --project unit",
"test:storybook": "NODE_OPTIONS=--no-webstorage vitest run --project storybook",
+ "test:coverage": "npm test -- --coverage.enabled",
"check": "npm run format && npm run test && npm run build",
"regtest:build": "npm run regtest:clear && npm run regtest:pull && docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml build --pull",
"regtest:pull": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml pull",
@@ -83,6 +84,7 @@
"@radix-ui/react-radio-group": "1.4.3",
"@radix-ui/react-select": "2.3.3",
"@radix-ui/react-separator": "1.1.11",
+ "@radix-ui/react-slider": "1.4.3",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-switch": "1.3.3",
"@radix-ui/react-tabs": "1.1.17",
diff --git a/src/components/earn/EarnPage.tsx b/src/components/earn/EarnPage.tsx
index 9c9bcab7..52edb04b 100644
--- a/src/components/earn/EarnPage.tsx
+++ b/src/components/earn/EarnPage.tsx
@@ -220,13 +220,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
-
-
-
-
{feeConfigValidation.maxFeesConfigMissing && (
setShowFeeConfigDialog(true)} className="mb-4" />
)}
@@ -234,7 +227,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
{jmSession.coinjoin_in_process === true && (
- {t('send.text_coinjoin_already_running')}
+ {t('send.text_coinjoin_already_running')}
)}
@@ -263,6 +256,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
)}
+
+
+
+
{jmSession.offer_list && jmSession.offer_list.length > 0 && (
state.state)
const { t } = useTranslation()
- const token = authState?.auth?.token
+ const token = useStore(authStore, (state) => state.state?.auth?.token)
const {
refetch: logQueryRefetch,
diff --git a/src/components/receive/ReceivePage.tsx b/src/components/receive/ReceivePage.tsx
index 79c63b1b..3ade31eb 100644
--- a/src/components/receive/ReceivePage.tsx
+++ b/src/components/receive/ReceivePage.tsx
@@ -236,9 +236,10 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
{t('receive.button_settings')}
-
+
errors.numCollaborators === undefined && errors.txFee === undefined,
+ () => [errors.numCollaborators, errors.txFee].every((it) => it === undefined),
[errors.numCollaborators, errors.txFee],
)
@@ -212,7 +212,9 @@ export function SendForm({
const coinjoinPreconditionSummary = useMemo(() => {
if (!sourceJar) return undefined
- return buildSweepPreconditionSummary(sourceJar.utxos)
+ return buildSweepPreconditionSummary(sourceJar.utxos, {
+ minConfirmations: JM_TAKER_UTXO_AGE,
+ })
}, [sourceJar])
const hasCoinjoinPreconditionWarning = isCoinJoin && coinjoinPreconditionSummary?.isFulfilled === false
@@ -253,9 +255,7 @@ export function SendForm({
const handleAddressPaste = useCallback(
(event: React.ClipboardEvent) => {
- const pasted = event.clipboardData.getData('text')
- if (!pasted.toLowerCase().startsWith('bitcoin:')) return
-
+ const pasted = event.clipboardData.getData('text') ?? ''
const parsed = parseBip21Uri(pasted)
if (!parsed) return
@@ -561,7 +561,7 @@ export function SendForm({
({
startCoinjoin: vi.fn(),
stopCoinjoinRefetch: vi.fn(),
takerRunning: false,
+ schedulerRunning: false,
toastError: vi.fn(),
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
@@ -160,6 +161,9 @@ vi.mock('@/context/JamSessionInfoContext', () => ({
takerInfo: {
currentPaymentAttempt: mocks.takerRunning || mocks.currentPaymentAttemptPresent ? collaborativeValues : undefined,
running: mocks.takerRunning,
+ scheduler: {
+ running: mocks.schedulerRunning,
+ },
},
}),
}))
@@ -318,6 +322,7 @@ describe('SendPage', () => {
mocks.stopCoinjoinRefetch.mockReset()
mocks.stopCoinjoinRefetch.mockResolvedValue({ data: {} })
mocks.takerRunning = false
+ mocks.schedulerRunning = false
mocks.toastError.mockReset()
mocks.toastInfo.mockReset()
mocks.toastSuccess.mockReset()
@@ -384,6 +389,7 @@ describe('SendPage', () => {
it('shows the running CoinJoin state and confirms abort', async () => {
const user = userEvent.setup()
mocks.takerRunning = true
+ mocks.schedulerRunning = false
render()
@@ -397,6 +403,17 @@ describe('SendPage', () => {
expect(mocks.stopCoinjoinRefetch).toHaveBeenCalledWith({ throwOnError: true })
})
+ it('shows the running CoinJoin state with abort button not present', () => {
+ mocks.takerRunning = true
+ mocks.schedulerRunning = true
+
+ render()
+
+ expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
+ expect(screen.getByText('send-form:true')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'global.abort' })).not.toBeInTheDocument()
+ })
+
it('confirms and starts a collaborative transaction', async () => {
const user = userEvent.setup()
mocks.startCoinjoin.mockResolvedValue({})
diff --git a/src/components/send/SendPage.tsx b/src/components/send/SendPage.tsx
index 6f5fc1ee..751cad10 100644
--- a/src/components/send/SendPage.tsx
+++ b/src/components/send/SendPage.tsx
@@ -83,7 +83,11 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
} = useJamWalletInfoContext()
const jmSession = useStore(jmSessionStore, (state) => state.state)
const {
- takerInfo: { running: takerRunning, currentPaymentAttempt },
+ takerInfo: {
+ running: takerRunning,
+ currentPaymentAttempt,
+ scheduler: { running: schedulerRunning },
+ },
rescanInfo,
setCurrentPaymentAttempt,
clearCurrentPaymentAttempt,
@@ -494,7 +498,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
}
{takerRunning && !isWaitingCoinjoinStop && (
-
+
{t('send.text_coinjoin_already_running')}
{currentPaymentAttempt && (
@@ -516,15 +520,17 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
)}
)}
-
-
-
+ {takerRunning && !schedulerRunning ? (
+
+
+
+ ) : null}
)}
diff --git a/src/components/sweep/ScheduleEntryItem.tsx b/src/components/sweep/ScheduleEntryItem.tsx
new file mode 100644
index 00000000..4ab9f829
--- /dev/null
+++ b/src/components/sweep/ScheduleEntryItem.tsx
@@ -0,0 +1,233 @@
+import {
+ AlertTriangleIcon,
+ CalendarCheck2Icon,
+ CalendarClockIcon,
+ ClockFadingIcon,
+ CopyCheckIcon,
+ CopyIcon,
+ ExternalLinkIcon,
+ FingerprintIcon,
+ MilkIcon,
+ TimerResetIcon,
+ UsersIcon,
+} from 'lucide-react'
+import { Trans, useTranslation } from 'react-i18next'
+import { cn } from '@/lib/utils'
+import { Badge } from '../ui/badge'
+import { jarBadgeVariant, type BadgeVariant } from '../ui/badge-variants'
+import { buttonVariants } from '../ui/button-variants'
+import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from '../ui/item'
+import { Address } from '../ui/jam/Address'
+import { CopyButton } from '../ui/jam/CopyButton'
+import { Label } from '../ui/label'
+import { formatDuration, type ScheduleEntry } from './scheduleUtils'
+
+const EntryStatusBadge = ({ active, status }: { active: boolean; status: ScheduleEntry['status'] }) => {
+ const variant: BadgeVariant = (() => {
+ if (active) return 'outline'
+ switch (status.value) {
+ case 'completed':
+ return 'success'
+ case 'failed':
+ return 'destructive'
+ case 'cancelled':
+ return 'warning'
+ case 'skipped':
+ return 'outline'
+ default:
+ return 'muted'
+ }
+ })()
+ return (
+
+ {/* TODO: i18n */ status.value}
+
+ )
+}
+
+export const ScheduleEntryItem = ({ value, active }: { value: ScheduleEntry; active: boolean }) => {
+ const { t } = useTranslation()
+
+ return (
+ -
+
+
+
+ {t('scheduler.progress_entry_label', { index: (value.index + 1).toLocaleString() })}
+
+
+ {
+ /* TODO: i18n */
+ value.kind === 'taker_coinjoin' ? (
+ ,
+ }}
+ />
+ ) : value.kind === 'maker_session' ? (
+ ,
+ }}
+ />
+ ) : null
+ }
+
+
+
+
+ {value.kind === 'maker_session' ? {/* TODO: i18n */ 'Earn'} : null}
+ {value.kind === 'taker_coinjoin' ? {/* TODO: i18n */ 'Send'} : null}
+ {value.isSweep ? {/* TODO: i18n */ 'Sweep'} : null}
+
+
+
+ {value.__raw?.error ? (
+
+
+
+
+ {value.__raw?.error ?? t('global.errors.reason_unknown')}
+
+
+ ) : null}
+ {value.startedAt ? (
+
+
+
+
+ {value.startedAt.toLocaleString()}
+
+
+ ) : null}
+ {value.startedAt || value.finishedAt ? (
+
+
+
+
+ {value.finishedAt === undefined ? (
+ '-'
+ ) : (
+ {value.finishedAt.toLocaleString()}
+ )}
+
+
+ ) : null}
+
+ {value.jar ? (
+
+
+
+
+
+ {value.jar.name} #{value.jar.jarIndex.toLocaleString()}
+
+
+
+ ) : null}
+
+ {value.numberOfRequestedCounterparties ? (
+
+
+
+
+ {value.numberOfRequestedCounterparties.toLocaleString()}
+
+
+ ) : null}
+
+ {/*value.__raw.durationSeconds ? (
+
+
+
+
+ {value.__raw.durationSeconds}
+
+
+ ) : null*/}
+
+ {value.idleTimeoutSeconds ? (
+
+
+
+
+ {formatDuration(value.idleTimeoutSeconds, t)}
+
+
+ ) : null}
+
+ {value.waitTimeInSeconds ? (
+
+
+
+
+ {value.waitTimeInSeconds <= 0 ? '-' : formatDuration(value.waitTimeInSeconds, t)}
+
+
+ ) : null}
+
+ {value.externalDestinationAddress ? (
+
+
+
+
+
+
+
+ ) : null}
+
+ {value.transactionId ? (
+
+
+
+
+
+
+ {value.transactionId}
+
+
+ {t('global.button_copy_text')}
+ >
+ }
+ successText={
+ <>
+
+ {t('global.button_copy_text_confirmed')}
+ >
+ }
+ className={cn(buttonVariants({ variant: 'outline', size: 'xs' }), 'shrink-0')}
+ />
+
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/sweep/SweepDestinationInputs.test.tsx b/src/components/sweep/SweepDestinationInputs.test.tsx
index 9852c74d..cfe7d400 100644
--- a/src/components/sweep/SweepDestinationInputs.test.tsx
+++ b/src/components/sweep/SweepDestinationInputs.test.tsx
@@ -1,4 +1,5 @@
-import { render, screen } from '@testing-library/react'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form'
import { describe, expect, it, vi } from 'vitest'
import { SweepDestinationInputs } from './SweepDestinationInputs'
@@ -20,6 +21,7 @@ describe('SweepDestinationInputs', () => {
isSubmitted: false,
touchedFields: {},
},
+ setValue: vi.fn(),
register: vi.fn((name: string) => ({ name, onBlur: vi.fn(), onChange: vi.fn(), ref: vi.fn() })),
} as unknown as SweepForm
@@ -28,8 +30,17 @@ describe('SweepDestinationInputs', () => {
{ id: '2', address: '' },
] as unknown as SweepFields
- it('renders input fields correctly', () => {
- render()
+ it('renders input fields correctly', async () => {
+ render(
+ ,
+ )
// Should render 2 inputs
const inputs = screen.getAllByRole('textbox')
@@ -42,16 +53,159 @@ describe('SweepDestinationInputs', () => {
// Inputs should not be disabled
expect(inputs[0]).not.toBeDisabled()
expect(inputs[1]).not.toBeDisabled()
+
+ // QR-Code scan button is present
+ const qrCodeButtons = await screen.findAllByRole('button', { name: 'send.qr_scan_title' })
+ expect(qrCodeButtons.length).toBe(fields.length)
+ })
+
+ it('qr-code scanner opens', () => {
+ render(
+ ,
+ )
+
+ // QR-Code scan button is present
+ const qrCodeButtons = screen.queryAllByRole('button', { name: 'send.qr_scan_title' })
+ expect(qrCodeButtons.length).toBe(fields.length)
+
+ // CR-Code scanner not active yet
+ expect(screen.queryByText('send.qr_paste_button')).not.toBeInTheDocument()
+ expect(screen.queryByText('send.qr_scan_file_button')).not.toBeInTheDocument()
+ expect(screen.queryByText('send.confirm_button_reject')).not.toBeInTheDocument()
+
+ fireEvent.click(qrCodeButtons[0])
+
+ expect(screen.queryByText('send.qr_paste_button')).toBeInTheDocument()
+ expect(screen.queryByText('send.qr_scan_file_button')).toBeInTheDocument()
+ expect(screen.queryByText('modal.confirm_button_reject')).toBeInTheDocument()
})
it('renders disabled input fields', () => {
- render()
+ render(
+ ,
+ )
const inputs = screen.getAllByRole('textbox')
expect(inputs[0]).toBeDisabled()
expect(inputs[1]).toBeDisabled()
})
+ it('handles bip21 format from paste events', async () => {
+ render(
+ ,
+ )
+
+ const inputs = screen.getAllByRole('textbox')
+ await act(async () => {
+ await userEvent.click(inputs[0])
+ await userEvent.paste('anything')
+ })
+ expect(defaultFormMock.setValue).not.toHaveBeenCalled()
+
+ await act(async () => {
+ await userEvent.click(inputs[0])
+ await userEvent.paste('bitcoin:')
+ })
+ expect(defaultFormMock.setValue).not.toHaveBeenCalled()
+
+ await act(async () => {
+ await userEvent.click(inputs[1])
+ await userEvent.paste(
+ 'bitcoin:bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk?amount=0.0021&label=order%20123&message=regtest',
+ )
+ })
+ expect(defaultFormMock.setValue).toHaveBeenCalledWith(
+ `destinations.1.address`,
+ 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk',
+ { shouldValidate: true },
+ )
+ })
+
+ it('adds additional destination inputs', () => {
+ const maxNumberOfFields = 3
+ const { rerender } = render(
+ {
+ const id = fields.map((it) => it.id).reduce((acc, i) => acc + Number(i), 0) + 1
+ fields.push({ id: String(id), address: '' })
+ }}
+ />,
+ )
+
+ const inputs = screen.getAllByRole('textbox')
+ expect(inputs.length).toBe(2)
+
+ expect(screen.getByRole('button', { name: 'Add additional destination' })).not.toBeDisabled()
+ fireEvent.click(screen.getByRole('button', { name: 'Add additional destination' }))
+
+ rerender(
+ fields.pop()}
+ onClickAppend={() => {
+ /* empty on purpose; wont be called*/
+ }}
+ />,
+ )
+
+ const inputsAfterAdd = screen.getAllByRole('textbox')
+ expect(inputsAfterAdd.length).toBe(maxNumberOfFields)
+
+ expect(screen.getByRole('button', { name: 'Add additional destination' })).toBeDisabled()
+
+ const clearButtons = screen.queryAllByRole('button', { name: 'global.clear' })
+ expect(clearButtons.length).toBe(fields.length - 1)
+
+ fireEvent.click(clearButtons[0])
+
+ rerender(
+ ,
+ )
+
+ const inputsAfterClear = screen.getAllByRole('textbox')
+ expect(inputsAfterClear.length).toBe(2)
+ })
+
it('shows error messages when submitted and there is an error', () => {
const errorFormMock = {
...defaultFormMock,
@@ -67,7 +221,16 @@ describe('SweepDestinationInputs', () => {
},
} as unknown as SweepForm
- render()
+ render(
+ ,
+ )
expect(screen.getByText('Invalid address 1')).toBeInTheDocument()
diff --git a/src/components/sweep/SweepDestinationInputs.tsx b/src/components/sweep/SweepDestinationInputs.tsx
index 11d2fb71..fa8b32bc 100644
--- a/src/components/sweep/SweepDestinationInputs.tsx
+++ b/src/components/sweep/SweepDestinationInputs.tsx
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react'
-import { ScanQrCodeIcon } from 'lucide-react'
-import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form'
+import { PlusCircleIcon, ScanQrCodeIcon, XIcon } from 'lucide-react'
+import type { UseFieldArrayReturn, UseFormReturn } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import QrScannerDialog from '@/components/ui/QrScannerDialog'
@@ -11,19 +11,32 @@ import { Input } from '@/components/ui/input'
import { parseBip21Uri, type Bip21ParseResult } from '@/lib/bip21'
import type { SweepFormValues } from './SweepFormSchema'
+type ParentForm = UseFormReturn
+
interface SweepDestinationInputsProps {
- form: UseFormReturn
- fields: Array>
- disabled: boolean
+ minNumberOfFields: number
+ maxNumberOfFields: number
+ register: ParentForm['register']
+ setValue: ParentForm['setValue']
+ formState: ParentForm['formState']
+ fields: UseFieldArrayReturn['fields']
+ disabled?: boolean
+ onClickAppend?: () => void
+ onClickRemove?: (index: number) => void
}
-export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinationInputsProps) => {
+export const SweepDestinationInputs = ({
+ minNumberOfFields,
+ maxNumberOfFields,
+ setValue,
+ register,
+ formState: { errors, isSubmitted, touchedFields },
+ fields,
+ disabled,
+ onClickAppend,
+ onClickRemove,
+}: SweepDestinationInputsProps) => {
const { t } = useTranslation()
- const {
- formState: { errors, isSubmitted, touchedFields },
- register,
- setValue,
- } = form
const [qrScannerIndex, setQrScannerIndex] = useState()
@@ -36,9 +49,7 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat
const handleAddressPaste = useCallback(
(event: React.ClipboardEvent, index: number) => {
- const pasted = event.clipboardData.getData('text')
- if (!pasted.toLowerCase().startsWith('bitcoin:')) return
-
+ const pasted = event.clipboardData.getData('text') ?? ''
const parsed = parseBip21Uri(pasted)
if (!parsed) return
@@ -75,7 +86,7 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat
handleAddressPaste(event, index)}
/>
+ {index >= minNumberOfFields && onClickRemove ? (
+
+ ) : null}