mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
Compare commits
117 commits
v2.0.0-bet
...
devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1abd6c7e6 | ||
|
|
0b707f44fc | ||
|
|
69423b6b1d | ||
|
|
d406a18456 | ||
|
|
047a8ab779 | ||
|
|
d9918b30b0 | ||
|
|
04c62c12fd | ||
|
|
c832b80a2f | ||
|
|
1535a8148e | ||
|
|
6022ed3d41 | ||
|
|
8f9d8d5917 | ||
|
|
56541c5c29 | ||
|
|
b9f2730a72 | ||
|
|
037bf95784 | ||
|
|
152793a11b | ||
|
|
8eef1958a5 | ||
|
|
28888dbb6d | ||
|
|
d0326db3e7 | ||
|
|
9fb32af42f | ||
|
|
4c2a886c6b | ||
|
|
4a90f342ff | ||
|
|
c70a8892a6 | ||
|
|
51a23b4c5a | ||
|
|
1c23525da5 | ||
|
|
471442ad80 | ||
|
|
1a22e97cfb | ||
|
|
6d7b0362e3 | ||
|
|
25246e1690 | ||
|
|
edf97c30a7 | ||
|
|
f7f4de6ab3 | ||
|
|
0c77dc37aa | ||
|
|
7988595766 | ||
|
|
2b69ef3dbb | ||
|
|
150e880040 | ||
|
|
d8e6d6aa15 | ||
|
|
e0d7479711 | ||
|
|
02115e808a | ||
|
|
9346804bf8 | ||
|
|
306e6b83d1 | ||
|
|
59e959da97 | ||
|
|
bce1df33e9 | ||
|
|
d5b38740c7 | ||
|
|
864e976772 | ||
|
|
ff0a9f229e | ||
|
|
6490a96714 | ||
|
|
0e24250e3d | ||
|
|
01a952ff63 | ||
|
|
fc1a02cead | ||
|
|
82cc4762ff | ||
|
|
8789e63798 | ||
|
|
3e7f5ea41f | ||
|
|
c41b40bec9 | ||
|
|
fe43741a37 | ||
|
|
3f0d3bbc40 | ||
|
|
5f0818d734 | ||
|
|
6698c6d574 | ||
|
|
b4d7c9228c | ||
|
|
5ae6a18e35 | ||
|
|
848e7ef825 | ||
|
|
dfcea24b79 | ||
|
|
665889d856 | ||
|
|
07b8a4b12e | ||
|
|
b221f3a77a | ||
|
|
0eecb9444f | ||
|
|
c22586b9a4 | ||
|
|
c881f21fb5 | ||
|
|
af09c7b0b9 | ||
|
|
a020635089 | ||
|
|
aba28c0a6f | ||
|
|
1433698eb4 | ||
|
|
b90d5b2843 | ||
|
|
51d843c03a | ||
|
|
64ecd7ad77 | ||
|
|
f9523729ae | ||
|
|
4f8d7f497f | ||
|
|
a21857946b | ||
|
|
419cdf1db0 | ||
|
|
f7a1af9993 | ||
|
|
b0b3a7d284 | ||
|
|
2035dcf88e | ||
|
|
6c2bf12d39 | ||
|
|
0ae4c1d65f | ||
|
|
acf3208236 | ||
|
|
2b52d182da | ||
|
|
91901ae106 | ||
|
|
2ebee2e8ee | ||
|
|
59dc044423 | ||
|
|
bcabd99867 | ||
|
|
492ae14dc1 | ||
|
|
5041ce6af5 | ||
|
|
6c7d2878db | ||
|
|
8197587691 | ||
|
|
31cc13b08a | ||
|
|
ff6796f94c | ||
|
|
49f2c888c9 | ||
|
|
dfe7807689 | ||
|
|
ecc39aa15d | ||
|
|
7cf31890b2 | ||
|
|
a6ad27f743 | ||
|
|
7be8d4863a | ||
|
|
17c6a0d049 | ||
|
|
6a34868e2b | ||
|
|
1b056416b8 | ||
|
|
db1f7495ec | ||
|
|
2494cc84fa | ||
|
|
90a7d7203e | ||
|
|
290044e9d2 | ||
|
|
147d815f3b | ||
|
|
1b5d1cf6e6 | ||
|
|
e7f317aa49 | ||
|
|
ff739fcc07 | ||
|
|
a486002a71 | ||
|
|
d66f5e9c82 | ||
|
|
c7cb02ae86 | ||
|
|
eaadf56005 | ||
|
|
91d31c2639 | ||
|
|
50562ba897 |
411 changed files with 19944 additions and 9205 deletions
16
.env
16
.env
|
|
@ -1,14 +1,28 @@
|
|||
VITE_JAM_DEFAULT_THEME=dark
|
||||
VITE_JAM_REPO_URL=https://github.com/joinmarket-webui/jam
|
||||
VITE_JAM_DOCS_URL=https://jamdocs.org
|
||||
VITE_JAM_MATRIX_URL=https://matrix.to/#/%23jam:bitcoin.kyoto
|
||||
VITE_JAM_TELEGRAM_URL=https://t.me/JoinMarketWebUI
|
||||
VITE_JAM_JMNG_REPO_URL=https://github.com/joinmarket-ng/joinmarket-ng
|
||||
|
||||
VITE_JM_API_BASE_URL=/api/v1
|
||||
VITE_JM_API_BASE_URL=
|
||||
VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS=1800
|
||||
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
|
||||
|
||||
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS=true
|
||||
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1000
|
||||
|
|
|
|||
|
|
@ -5,3 +5,13 @@ 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
|
||||
|
||||
VITE_JAM_RUNNING_SCHEDULE_POLLING_INTERVAL=5000
|
||||
|
|
|
|||
7
.env.test
Normal file
7
.env.test
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
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
|
||||
|
||||
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1
|
||||
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
|
|
@ -11,14 +11,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ['v26.3.0']
|
||||
node-version: ['v26.5.0']
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup (Node.js ${{ matrix.node-version }})
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').packages['node_modules/@playwright/test'].version)")" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Playwright
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
|
|
@ -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
|
||||
|
|
@ -59,4 +59,4 @@ jobs:
|
|||
# Set if: always() to also generate the report if tests are failing
|
||||
# Only works if you set `reportOnFailure: true` in vite config
|
||||
if: ${{ always() && hashFiles('coverage/coverage-summary.json') != '' }}
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
uses: davelosert/vitest-coverage-report-action@8b157684c6a6b259b97d45e72b44242865c0f6a5 # v2.12.2
|
||||
|
|
|
|||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -41,7 +41,9 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
|
|
|||
6
.github/workflows/storybook-build.yml
vendored
6
.github/workflows/storybook-build.yml
vendored
|
|
@ -12,10 +12,12 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
|
|
|
|||
10
.github/workflows/storybook-pages.yml
vendored
10
.github/workflows/storybook-pages.yml
vendored
|
|
@ -24,10 +24,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
|
|
@ -38,13 +38,13 @@ jobs:
|
|||
run: npm run storybook:build-gh-pages
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v6
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: storybook-static
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
npm test
|
||||
npm run test -- --bail=1
|
||||
|
|
|
|||
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
|||
v26.3.0
|
||||
v26.5.0
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
import { Suspense, useEffect } from 'react'
|
||||
import type { Preview } from '@storybook/react-vite'
|
||||
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon'
|
||||
import { mswLoader } from 'msw-storybook-addon/csf3'
|
||||
import { setupWorker } from 'msw/browser'
|
||||
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'
|
||||
|
|
@ -17,15 +18,6 @@ import mswHandlers from './msw-handlers'
|
|||
// needed if you want to use msw on a subpath (e.g. github pages /<repo>)
|
||||
const mswServiceWorkerUrl = import.meta.env.STORYBOOK_MSW_SERVICE_WORKER_URL ?? '/mockServiceWorker.js'
|
||||
|
||||
// Initialize MSW (https://github.com/mswjs/msw-storybook-addon)
|
||||
initialize({
|
||||
onUnhandledRequest: 'bypass',
|
||||
serviceWorker: {
|
||||
url: mswServiceWorkerUrl,
|
||||
},
|
||||
quiet: true,
|
||||
})
|
||||
|
||||
const locales = [
|
||||
{ value: 'en', title: 'en' },
|
||||
{ value: 'fr', title: 'fr' },
|
||||
|
|
@ -85,12 +77,28 @@ const preview: Preview = {
|
|||
viewport: {
|
||||
options: { ...MINIMAL_VIEWPORTS, ...INITIAL_VIEWPORTS },
|
||||
},
|
||||
// this approach is deprecated as of v3.0.0
|
||||
// but it seems the new approach does not work as expected (404 response for our handlers)
|
||||
// revisit after next update (last checked 2026-07-30)
|
||||
// see: https://github.com/mswjs/msw-storybook-addon/blob/v3.0.0/MIGRATION.md#parametersmsw-is-deprecated-in-favor-of-beforeeach
|
||||
msw: {
|
||||
handlers: mswHandlers,
|
||||
},
|
||||
},
|
||||
// Provide the MSW addon loader globally
|
||||
loaders: [mswLoader],
|
||||
loaders: [
|
||||
mswLoader(async () => {
|
||||
const worker = setupWorker()
|
||||
await worker.start({
|
||||
onUnhandledRequest: 'bypass',
|
||||
serviceWorker: {
|
||||
url: mswServiceWorkerUrl,
|
||||
},
|
||||
quiet: true,
|
||||
})
|
||||
return worker
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
const withTheme = (Story: React.ComponentType, context: GlobalContext) => {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ The aim of this document is to help you get setup for participating in all areas
|
|||
|
||||
## Understanding the Components Involved
|
||||
|
||||
Jam is a web UI for [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver/) with focus on user-friendliness.
|
||||
The web UI's purpose is to be a rather lightweight front end for the JoinMarket API.
|
||||
To function, the web UI needs to connect to an instance of JoinMarket with [the API service](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/JSON-RPC-API-using-jmwalletd.md) running.
|
||||
Jam is a web UI for [JoinMarket NG](https://github.com/joinmarket-ng/joinmarket-ng) with focus on user-friendliness.
|
||||
The web UI's purpose is to be a rather lightweight front end for the JoinMarket NG API.
|
||||
To function, the web UI needs to connect to an instance of JoinMarket NG with [the API service](https://joinmarket-ng.github.io/joinmarket-ng/README-jmwalletd/) running.
|
||||
|
||||
You don't need to worry about that, though.
|
||||
To ease development and testing, we provide a Docker setup that runs JoinMarket and its API service in a regtest setup.
|
||||
To ease development and testing, we provide a Docker setup that runs JoinMarket NG and its API service in a regtest setup.
|
||||
|
||||
## Local setup (regtest and UI)
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ Install dependencies:
|
|||
npm install
|
||||
```
|
||||
|
||||
Start the JoinMarket HTTP API service in regtest.
|
||||
Start the JoinMarket NG HTTP API service in regtest.
|
||||
It isn't needed to dig deeper into how it works to use it.
|
||||
However, if you want to find out more about it, see [docker/regtest/readme.md](docker/regtest/readme.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
---
|
||||
|
||||
Jam is a web interface for [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver/) focusing on user-friendliness and ease-of-use.
|
||||
Jam is a web interface for [JoinMarket NG](https://github.com/joinmarket-ng/joinmarket-ng) focusing on user-friendliness and ease-of-use.
|
||||
It aims to provide sensible defaults and be easy to use for beginners while still having the features advanced users expect.
|
||||
|
||||
- 💬 Join our [Matrix room](https://matrix.to/#/%23jam:bitcoin.kyoto) to get help and [contribute](https://joinmarket-webui.github.io/jamdocs/contribute/)!
|
||||
|
|
@ -88,4 +88,4 @@ Want to get your hands dirty with code? Great! [CONTRIBUTING.md](CONTRIBUTING.md
|
|||
|
||||
This project builds upon the [jm-web-client](https://github.com/JoinMarket-Org/jm-web-client) which was developed by [Shobhitaa](https://github.com/shobhitaa), [Abhishek](https://github.com/abhishek0405), and [waxwing](https://github.com/AdamISZ) himself. Many people contributed over time, some of which are [listed here](https://github.com/joinmarket-webui/jam/graphs/contributors).
|
||||
|
||||
Jam and JoinMarket are separate projects. For more information on JoinMarket, see the [JoinMarket GitHub Page](https://github.com/JoinMarket-Org/joinmarket-clientserver).
|
||||
Jam and JoinMarket NG are separate projects. For more information on JoinMarket NG, see the [JoinMarket NG GitHub Page](https://github.com/joinmarket-ng/joinmarket-ng).
|
||||
|
|
|
|||
|
|
@ -12,10 +12,6 @@ services:
|
|||
jm_rpc_host: bitcoind
|
||||
jm_rpc_port: 43782
|
||||
jm_directory_nodes: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
|
||||
jm_socks5_host: tor
|
||||
jm_socks5_port: 9050
|
||||
jm_tor_control_host: tor
|
||||
jm_tor_control_port: 9051
|
||||
jm_minimum_makers: 1 # necessary to do coinjoins with this regtest setup; default is 4
|
||||
jm_taker_utxo_age: 1 # faster testing of scheduler runs; default is 5
|
||||
jm_maker_timeout_sec: 30 # easier testing of maker timeouts on regtest (and "Stall Monitor" retries); default is 60
|
||||
|
|
@ -45,10 +41,6 @@ services:
|
|||
jm_rpc_host: bitcoind
|
||||
jm_rpc_port: 43782
|
||||
jm_directory_nodes: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
|
||||
jm_socks5_host: tor
|
||||
jm_socks5_port: 9050
|
||||
jm_tor_control_host: tor
|
||||
jm_tor_control_port: 9051
|
||||
jm_minimum_makers: 1 # necessary to do coinjoins with this regtest setup; default is 4
|
||||
jm_taker_utxo_age: 1 # faster testing of scheduler runs; default is 5
|
||||
expose:
|
||||
|
|
@ -67,6 +59,16 @@ services:
|
|||
context: ./dockerfile-deps/joinmarket/webui-standalone-ng
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
JMWALLETD_HOST: 0.0.0.0
|
||||
BITCOIN__BACKEND_TYPE: descriptor_wallet
|
||||
BITCOIN__RPC_URL: http://bitcoind:43782
|
||||
BITCOIN__RPC_USER: regtest
|
||||
BITCOIN__RPC_PASSWORD: regtest
|
||||
LOGGING__LEVEL: DEBUG
|
||||
NETWORK_CONFIG__NETWORK: testnet
|
||||
NETWORK_CONFIG__BITCOIN_NETWORK: regtest
|
||||
NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
|
||||
expose:
|
||||
- 80 # nginx
|
||||
- 28183 # jmwalletd api
|
||||
|
|
@ -77,7 +79,6 @@ services:
|
|||
environment:
|
||||
JOINMARKET_DATA_DIR: /root/.joinmarket-ng
|
||||
JMWALLETD_HOST: 0.0.0.0
|
||||
JMWALLETD_NO_TLS: ${JMWALLETD_NO_TLS:-false}
|
||||
BITCOIN__BACKEND_TYPE: descriptor_wallet
|
||||
BITCOIN__RPC_URL: http://bitcoind:43782
|
||||
BITCOIN__RPC_USER: regtest
|
||||
|
|
@ -85,7 +86,8 @@ services:
|
|||
NETWORK_CONFIG__NETWORK: testnet
|
||||
NETWORK_CONFIG__BITCOIN_NETWORK: regtest
|
||||
NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
|
||||
DIRECTORY_NODES: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
|
||||
ORDERBOOK_WATCHER__HTTP_HOST: joinmarket_ng_orderbook_watcher
|
||||
ORDERBOOK_WATCHER__HTTP_PORT: 8000
|
||||
OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000
|
||||
TOR__SOCKS_HOST: tor
|
||||
TOR__SOCKS_PORT: 9050
|
||||
|
|
@ -97,7 +99,6 @@ services:
|
|||
expose:
|
||||
- 8000 # obwatch
|
||||
- 28183 # jmwalletd api
|
||||
- 28283 # jmwalletd websocket
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
|
|
@ -118,7 +119,6 @@ services:
|
|||
environment:
|
||||
JOINMARKET_DATA_DIR: /root/.joinmarket-ng
|
||||
JMWALLETD_HOST: 0.0.0.0
|
||||
JMWALLETD_NO_TLS: ${JMWALLETD_NO_TLS:-false}
|
||||
BITCOIN__BACKEND_TYPE: descriptor_wallet
|
||||
BITCOIN__RPC_URL: http://bitcoind:43782
|
||||
BITCOIN__RPC_USER: regtest
|
||||
|
|
@ -126,7 +126,8 @@ services:
|
|||
NETWORK_CONFIG__NETWORK: testnet
|
||||
NETWORK_CONFIG__BITCOIN_NETWORK: regtest
|
||||
NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
|
||||
DIRECTORY_NODES: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
|
||||
ORDERBOOK_WATCHER__HTTP_HOST: joinmarket_ng_orderbook_watcher
|
||||
ORDERBOOK_WATCHER__HTTP_PORT: 8000
|
||||
OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000
|
||||
TOR__SOCKS_HOST: tor
|
||||
TOR__SOCKS_PORT: 9050
|
||||
|
|
@ -137,7 +138,6 @@ services:
|
|||
expose:
|
||||
- 8000 # obwatch
|
||||
- 28183 # jmwalletd api
|
||||
- 28283 # jmwalletd websocket
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
|
|
|
|||
|
|
@ -6,12 +6,10 @@ services:
|
|||
service: joinmarket_ng_native
|
||||
environment:
|
||||
READY_FILE: /root/.regtest-initializer/btc_fully_synched
|
||||
BITCOIN__RPC_USER: regtest
|
||||
BITCOIN__RPC_PASSWORD: regtest
|
||||
BITCOIN__DESCRIPTOR_WALLET_NAME: jm_primary
|
||||
BITCOIN__RPC_USER: joinmarket
|
||||
BITCOIN__RPC_PASSWORD: joinmarket
|
||||
ports:
|
||||
- '28183:28183' # jmwalletd api
|
||||
- '28283:28283' # jmwalletd websocket
|
||||
- '28183:28183' # jmwalletd api (exposed for "init setup" routine)
|
||||
volumes:
|
||||
- 'joinmarket_datadir:/root/.joinmarket-ng'
|
||||
- 'initializer_datadir:/root/.regtest-initializer'
|
||||
|
|
@ -32,20 +30,13 @@ services:
|
|||
file: docker-compose-common.yml
|
||||
service: joinmarket_jam_standalone_ng
|
||||
environment:
|
||||
ENSURE_WALLET: 'true'
|
||||
BITCOIN__RPC_URL: http://bitcoind:43782
|
||||
BITCOIN__RPC_USER: regtest
|
||||
BITCOIN__RPC_PASSWORD: regtest
|
||||
BITCOIN__DESCRIPTOR_WALLET_NAME: jm_secondary
|
||||
LOGGING__LEVEL: DEBUG
|
||||
NETWORK_CONFIG__NETWORK: testnet
|
||||
NETWORK_CONFIG__BITCOIN_NETWORK: regtest
|
||||
NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
|
||||
BITCOIN__RPC_USER: joinmarket2
|
||||
BITCOIN__RPC_PASSWORD: joinmarket2
|
||||
TAKER__MINIMUM_MAKERS: 2 # necessary to do coinjoins with this regtest setup
|
||||
TAKER__MAKER_TIMEOUT_SEC: 30 # easier testing of maker timeouts on regtest
|
||||
ports:
|
||||
- '29080:80'
|
||||
- '29183:28183' # exposed for "init setup" routine
|
||||
- '29183:28183' # jmwalletd api (exposed for "init setup" routine)
|
||||
volumes:
|
||||
- 'joinmarket2_datadir:/root/.joinmarket-ng'
|
||||
depends_on:
|
||||
|
|
@ -64,10 +55,11 @@ services:
|
|||
jm_rpc_wallet_file: jm_tertiary
|
||||
jm_rpc_user: joinmarket3
|
||||
jm_rpc_password: joinmarket3
|
||||
CREATE_WALLET_PARAMS_DESCRIPTORS: 'false'
|
||||
ports:
|
||||
- '30601:62601' # obwatch
|
||||
- '30183:28183' # jmwalletd api
|
||||
- '30283:28283' # jmwalletd websocket
|
||||
- '30601:62601' # obwatch
|
||||
- '30183:28183' # jmwalletd api (exposed for "init setup" routine)
|
||||
- '30283:28283' # jmwalletd websocket
|
||||
volumes:
|
||||
- 'joinmarket3_datadir:/root/.joinmarket'
|
||||
- 'initializer_datadir:/root/.regtest-initializer'
|
||||
|
|
@ -93,7 +85,7 @@ services:
|
|||
jm_rpc_cookie_file: /root/.bitcoin_data/regtest/.cookie
|
||||
ports:
|
||||
- '31080:80'
|
||||
- '31183:28183' # exposed for "init setup" routine
|
||||
- '31183:28183' # jmwalletd api (exposed for "init setup" routine)
|
||||
volumes:
|
||||
- 'joinmarket4_datadir:/root/.joinmarket'
|
||||
- 'bitcoin_datadir:/root/.bitcoin_data' # mount bitcoind dir to access .cookie file
|
||||
|
|
@ -113,12 +105,8 @@ services:
|
|||
extends:
|
||||
file: docker-compose-common.yml
|
||||
service: joinmarket_ng_peer
|
||||
environment:
|
||||
BITCOIN__RPC_USER: regtest
|
||||
BITCOIN__RPC_PASSWORD: regtest
|
||||
BITCOIN__DESCRIPTOR_WALLET_NAME: jm_quinary
|
||||
ports:
|
||||
- '32183:28183'
|
||||
- '32183:28183' # jmwalletd api (exposed for "init setup" routine)
|
||||
volumes:
|
||||
- 'joinmarket5_datadir:/root/.joinmarket-ng'
|
||||
- 'tor_datadir:/var/lib/tor:ro'
|
||||
|
|
@ -132,8 +120,8 @@ services:
|
|||
tor:
|
||||
condition: service_healthy
|
||||
|
||||
joinmarket_directory_node:
|
||||
container_name: jm_regtest_joinmarket_directory_node
|
||||
joinmarket_clientserver_directory_node:
|
||||
container_name: jm_regtest_joinmarket_clientserver_directory_node
|
||||
build:
|
||||
context: ./dockerfile-deps/joinmarket/directory_node
|
||||
dockerfile: Dockerfile
|
||||
|
|
@ -141,15 +129,10 @@ services:
|
|||
environment:
|
||||
jm_hidden_service_dir: \/root\/.joinmarket\/hidden_service_dir
|
||||
jm_directory_nodes: ${JM_REF_DIRECTORY_NODES:?You must set the reference directory node address in generated env file}
|
||||
jm_socks5_host: localhost
|
||||
jm_socks5_port: 9050
|
||||
jm_tor_control_host: localhost
|
||||
jm_tor_control_port: 9051
|
||||
jm_daemon_host: 0.0.0.0
|
||||
jm_blockchain_source: regtest
|
||||
jm_network: testnet
|
||||
volumes:
|
||||
- 'joinmarket_directory_node_datadir:/root/.joinmarket'
|
||||
- 'joinmarket_clientserver_directory_node_datadir:/root/.joinmarket'
|
||||
- './out/hidden_service_dir:/root/.joinmarket/hidden_service_dir:z'
|
||||
healthcheck:
|
||||
test: ['CMD', 'supervisorctl', 'status']
|
||||
|
|
@ -161,9 +144,6 @@ services:
|
|||
depends_on:
|
||||
tor:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
default:
|
||||
ipv4_address: 172.30.0.20
|
||||
|
||||
joinmarket_ng_orderbook_watcher:
|
||||
container_name: jm_regtest_joinmarket_ng_orderbook_watcher
|
||||
|
|
@ -176,6 +156,8 @@ services:
|
|||
TOR__SOCKS_HOST: tor
|
||||
TOR__SOCKS_PORT: 9050
|
||||
LOGGING__LEVEL: DEBUG
|
||||
ORDERBOOK_WATCHER__HTTP_HOST: 0.0.0.0
|
||||
ORDERBOOK_WATCHER__HTTP_PORT: 8000
|
||||
expose:
|
||||
- 8000
|
||||
ports:
|
||||
|
|
@ -243,11 +225,10 @@ services:
|
|||
[
|
||||
'sh',
|
||||
'-c',
|
||||
'mkdir -p /var/lib/tor/ref-directory /var/lib/tor/ng-directory && cp -f /source/ref/* /var/lib/tor/ref-directory/ && cp -f /source/ng/* /var/lib/tor/ng-directory/ && chown -R 1000:1000 /var/lib/tor && chmod 700 /var/lib/tor/ref-directory /var/lib/tor/ng-directory && chmod 600 /var/lib/tor/ref-directory/* /var/lib/tor/ng-directory/*',
|
||||
'mkdir -p /var/lib/tor/ng-directory && cp -f /source/ng/* /var/lib/tor/ng-directory/ && chown -R 1000:1000 /var/lib/tor && chmod 700 /var/lib/tor/ng-directory && chmod 600 /var/lib/tor/ng-directory/*',
|
||||
]
|
||||
volumes:
|
||||
- 'tor_datadir:/var/lib/tor'
|
||||
- './out/hidden_service_dir:/source/ref:ro,z'
|
||||
- './out/ng_directory_hidden_service:/source/ng:ro,z'
|
||||
|
||||
irc:
|
||||
|
|
@ -262,7 +243,7 @@ services:
|
|||
bitcoind:
|
||||
container_name: jm_regtest_bitcoind
|
||||
restart: unless-stopped
|
||||
image: polarlightning/bitcoind:28.0@sha256:ff435d758c89f01823924e057d5d5e4206d350ae69dff5e0dc1bbc9c6a36d010
|
||||
image: polarlightning/bitcoind:29.0@sha256:4521294ac25fdbf1492a8cc5ea987463a5f244b82546d29bbe8451db39f36f3a
|
||||
command:
|
||||
# rpcauth (user=regtest; password=regtest)
|
||||
# rpcauth (user=joinmarket; password=joinmarket)
|
||||
|
|
@ -388,7 +369,7 @@ volumes:
|
|||
joinmarket3_datadir:
|
||||
joinmarket4_datadir:
|
||||
joinmarket5_datadir:
|
||||
joinmarket_directory_node_datadir:
|
||||
joinmarket_clientserver_directory_node_datadir:
|
||||
tor_datadir:
|
||||
irc_datadir:
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ and provide the correct onion hostname via `directory_nodes` yourself!
|
|||
e.g. in a docker-compose setup:
|
||||
|
||||
```yml
|
||||
joinmarket_directory_node:
|
||||
joinmarket_clientserver_directory_node:
|
||||
[...]
|
||||
environment:
|
||||
jm_hidden_service_dir: \/root\/.joinmarket\/hidden_service_dir
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ if [ "${ENSURE_WALLET}" = "true" ]; then
|
|||
\"method\":\"createwallet\",\
|
||||
\"params\":{\
|
||||
\"wallet_name\":\"${wallet_name}\",\
|
||||
\"descriptors\":false,\
|
||||
\"descriptors\":$( [[ "${CREATE_WALLET_PARAMS_DESCRIPTORS,,}" == "false" ]] && echo false || echo true ),\
|
||||
\"load_on_startup\":true\
|
||||
}\
|
||||
}"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Default JoinMarket Tor configuration
|
||||
# taken from v0.9.6 on 2022-07-18:
|
||||
# https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.6/install.sh#L388-L393
|
||||
Log warn stderr
|
||||
Log notice stdout
|
||||
SOCKSPort 9050 IsolateDestAddr IsolateDestPort
|
||||
ControlPort 9051
|
||||
CookieAuthentication 1
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ set -Eeuo pipefail
|
|||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
|
||||
|
||||
# fund wallet in primary JoinMarket NG container
|
||||
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --unmatured --blocks 3
|
||||
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 0 --unmatured --blocks 1
|
||||
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 1 --unmatured --blocks 2 # outputs should be flagged as "reused"
|
||||
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 4 --unmatured --blocks 1
|
||||
|
||||
# fund wallet in ng standalone secondary container
|
||||
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket2 --unmatured --blocks 50
|
||||
|
|
@ -35,10 +37,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)
|
||||
# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106
|
||||
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 = at least 106 blocks
|
||||
. "$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() {
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ Port mapping overview:
|
|||
|
||||
| Container | Ports |
|
||||
| --------------------------------------- | ---------------------------------------------- |
|
||||
| `joinmarket` (ng native) | `28183` (API), `28283` (WS), `62601` (obwatch) |
|
||||
| `joinmarket` (ng native) | `28183` (API) |
|
||||
| `joinmarket2` (ng standalone) | `29080` (UI), `29183` (API) |
|
||||
| `joinmarket3` (clientserver native) | `30183` (API), `30283` (WS), `30601` (obwatch) |
|
||||
| `joinmarket4` (clientserver standalone) | `31080` (UI, basic auth), `31183` (API) |
|
||||
|
|
|
|||
|
|
@ -9,11 +9,6 @@ ControlPort 0.0.0.0:9051
|
|||
CookieAuthentication 1
|
||||
CookieAuthFile /var/lib/tor/control_auth_cookie
|
||||
|
||||
# Hidden service for JoinMarket reference directory node
|
||||
HiddenServiceDir /var/lib/tor/ref-directory
|
||||
HiddenServicePort 5222 172.30.0.20:8080
|
||||
HiddenServiceVersion 3
|
||||
|
||||
# Hidden service for JoinMarket NG directory node
|
||||
HiddenServiceDir /var/lib/tor/ng-directory
|
||||
HiddenServicePort 5222 172.30.0.21:5223
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ A place to collect useful information for developers that doesn't really fit els
|
|||
|
||||
## JoinMarket Development Environment
|
||||
|
||||
For a complete development environment you need a local JoinMarket instance that the web UI can interact with. We provide a regtest environment that should give you everything needed to get started developing with JoinMarket. You can find details here: [docker/regtest/readme.md](../docker/regtest/readme.md).
|
||||
For a complete development environment you need a local JoinMarket NG instance that the web UI can interact with. We provide a regtest environment that should give you everything needed to get started developing with JoinMarket NG. You can find details here: [docker/regtest/readme.md](../docker/regtest/readme.md).
|
||||
|
||||
## Running Jam Against JoinMarket-NG
|
||||
## Running Jam Against JoinMarket NG
|
||||
|
||||
Jam v2 can talk directly to a separately running `jmwalletd` / orderbook watcher from `joinmarket-ng`. You do not need to run the Jam regtest compose or the reference implementation for this workflow.
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ If your separately running joinmarket-ng services use different ports, you can o
|
|||
JAM_BACKEND=joinmarket-ng \
|
||||
JMWALLETD_API_PORT=28183 \
|
||||
JMWALLETD_WEBSOCKET_PORT=28283 \
|
||||
JMOBWATCH_PORT=8080 \
|
||||
JMOBWATCH_PORT=8000 \
|
||||
npm run dev
|
||||
```
|
||||
|
||||
|
|
@ -86,17 +86,17 @@ By default, the hook will be installed automatically as an [NPM postinstall scri
|
|||
If you're having issues with Husky not using the correct `$PATH`, you may need to setup a `~/.huskyrc` which will let you set up your path before the hook is run.
|
||||
See [here](https://typicode.github.io/husky/#/?id=command-not-found) for more info.
|
||||
|
||||
## Running the Web UI Locally and Connecting to a Remote JoinMarket Instance
|
||||
## Running the Web UI Locally and Connecting to a Remote JoinMarket NG Instance
|
||||
|
||||
These instructions assume you want to run the web UI locally and connect it to a JoinMarket instance on your RaspiBlitz.
|
||||
These instructions assume you want to run the web UI locally and connect it to a JoinMarket NG instance on your RaspiBlitz.
|
||||
The process should be similar for other setups.
|
||||
If you run the Web UI and JoinMarket on the same system, simply skip the SSH tunnel step.
|
||||
If you run the Web UI and JoinMarket NG on the same system, simply skip the SSH tunnel step.
|
||||
|
||||
### 🚨 Prerequisite: JoinMarket
|
||||
### 🚨 Prerequisite: JoinMarket NG
|
||||
|
||||
To run the web UI locally you need to connect it to a running JoinMarket instance.
|
||||
To run the web UI locally you need to connect it to a running JoinMarket NG instance.
|
||||
|
||||
#### 1. Install JoinMarket
|
||||
#### 1. Install JoinMarket NG
|
||||
|
||||
Install [JoininBox](https://github.com/openoms/joininbox) on your [RaspiBlitz](https://github.com/rootzoll/raspiblitz):
|
||||
|
||||
|
|
@ -104,38 +104,38 @@ Install [JoininBox](https://github.com/openoms/joininbox) on your [RaspiBlitz](h
|
|||
Services > j [BTC JoinMarket+JoininBox menu]
|
||||
```
|
||||
|
||||
Or follow the JoinMarket [installation guide](https://github.com/JoinMarket-Org/joinmarket-clientserver#quickstart---recommended-installation-method-linux-and-macos-only) if you're on another system.
|
||||
Or follow the JoinMarket NG [installation guide](https://joinmarket-ng.github.io/joinmarket-ng/install/) if you're on another system.
|
||||
|
||||
### 🚨 Prerequisite: JoinMarket API Service
|
||||
### 🚨 Prerequisite: JoinMarket NG API Service
|
||||
|
||||
This app makes use of the JoinMarket RPC API. For this, you will need JoinMarket version 0.9.3 or higher. If needed you can upgrade JoinMarket to the latest commit via the JoininBox menu on your RaspiBlitz: Type `jm` in the command line and select `UPDATE > ADVANCED > JMCOMMIT`. This will install the latest development version from JoinMarket's master branch.
|
||||
This app makes use of the JoinMarket NG API.
|
||||
|
||||
#### 2. SSL Certificate
|
||||
|
||||
As the joinmarket user on your RaspiBlitz, generate a self-signed certificate for the JoinMarket API Service as described [here](https://linuxize.com/post/creating-a-self-signed-ssl-certificate/), and put the certificate and the key in the `~/.joinmarket/ssl/` directory.
|
||||
As the joinmarket user on your RaspiBlitz, generate a self-signed certificate for the JoinMarket NG API Service as described [here](https://linuxize.com/post/creating-a-self-signed-ssl-certificate/), and put the certificate and the key in the `~/.joinmarket-ng/ssl/` directory.
|
||||
|
||||
_Hint:_ To login as the JoinMarket user, you can ssh into your RaspiBlitz, type `jm`, and exit the JoininBox menu.
|
||||
|
||||
Create the SSL directory:
|
||||
|
||||
```bash
|
||||
(jmvenv) joinmarket@raspberrypi:~ $ mkdir ~/.joinmarket/ssl/
|
||||
(jmvenv) joinmarket@raspberrypi:~ $ mkdir ~/.joinmarket-ng/ssl/
|
||||
```
|
||||
|
||||
Generate the certificate and associated key:
|
||||
|
||||
```bash
|
||||
openssl req -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out ~/.joinmarket/ssl/cert.pem -keyout ~/.joinmarket/ssl/key.pem
|
||||
openssl req -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out ~/.joinmarket-ng/ssl/cert.pem -keyout ~/.joinmarket-ng/ssl/key.pem
|
||||
```
|
||||
|
||||
_Hint:_ You don't have to enter anything meaningful, you can just hit the return key a couple of times.
|
||||
|
||||
#### 3. API Service
|
||||
|
||||
Start the JoinMarket [API service](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/JSON-RPC-API-using-jmwalletd.md):
|
||||
Start the JoinMarket NG [API service](https://joinmarket-ng.github.io/joinmarket-ng/README-jmwalletd/):
|
||||
|
||||
```bash
|
||||
(jmvenv) joinmarket@raspberrypi:~/joinmarket-clientserver/scripts $ python jmwalletd.py
|
||||
(jmvenv) joinmarket@raspberrypi:~/joinmarket-ng $ jmwalletd serve
|
||||
```
|
||||
|
||||
You should see the following:
|
||||
|
|
|
|||
|
|
@ -125,6 +125,12 @@ export default defineConfig(
|
|||
},
|
||||
},
|
||||
],
|
||||
'unicorn/number-literal-case': [
|
||||
'error',
|
||||
{
|
||||
hexadecimalValue: 'lowercase',
|
||||
},
|
||||
],
|
||||
|
||||
'unicorn/switch-case-braces': ['off'], // TODO: enable
|
||||
'unicorn/no-null': ['off'], // TODO: enable
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self';">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#000000" />
|
||||
<link rel="manifest" href="/site.webmanifest" crossorigin="use-credentials" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Jam for JoinMarket</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
4659
package-lock.json
generated
4659
package-lock.json
generated
File diff suppressed because it is too large
Load diff
138
package.json
138
package.json
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "jam",
|
||||
"version": "2.0.0-beta.0",
|
||||
"version": "2.0.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Your sats. Your privacy. Your profit.",
|
||||
"repository": "git@github.com:joinmarket-webui/jam.git",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.11.1",
|
||||
"npm": ">=11.6.2"
|
||||
"node": ">=26.3.0",
|
||||
"npm": ">=11.16.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run dev:ng:native",
|
||||
|
|
@ -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",
|
||||
|
|
@ -41,14 +42,18 @@
|
|||
"regtest:down": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml down",
|
||||
"regtest:logs:all": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml logs --follow",
|
||||
"regtest:logs": "npm run regtest:logs:ng:native:jmwalletd",
|
||||
"regtest:logs:obwatch": "npm run regtest:logs:ng:obwatch",
|
||||
"regtest:logs:directory_node": "npm run regtest:logs:ng:directory_node",
|
||||
"regtest:logs:ng:native:jmwalletd": "docker logs -f jm_regtest_joinmarket",
|
||||
"regtest:logs:ng:obwatch": "docker logs -f jm_regtest_joinmarket_ng_orderbook_watcher",
|
||||
"regtest:logs:obwatch": "npm run regtest:logs:ng:obwatch",
|
||||
"regtest:logs:ng:directory_node": "docker exec -t jm_regtest_joinmarket_directory_node tail -f /var/log/jam/directory_node_stdout.log -n 200",
|
||||
"regtest:logs:ng:directory_node": "docker logs -f jm_regtest_joinmarket_ng_directory_node",
|
||||
"regtest:logs:clientserver:directory_node": "docker exec -t jm_regtest_joinmarket_clientserver_directory_node tail -f /var/log/jam/directory_node_stdout.log -n 200",
|
||||
"regtest:logs:clientserver:native:jmwalletd": "docker exec -t jm_regtest_joinmarket3 tail -f /var/log/jam/jmwalletd_stdout.log",
|
||||
"regtest:logs:clientserver:native:tor": "docker exec -t jm_regtest_joinmarket3 tail -f /var/log/jam/tor_stdout.log",
|
||||
"regtest:logs:clientserver:native:schedule": "docker exec -t jm_regtest_joinmarket3 tail -f /root/.joinmarket/logs/TUMBLE.schedule",
|
||||
"regtest:init": "./docker/regtest/init-setup.sh",
|
||||
"regtest:mine": "watch -n 11 ./docker/regtest/mine-block.sh"
|
||||
"regtest:mine": "watch -n 11 ./docker/regtest/mine-block.sh",
|
||||
"regtest:listwallets": "docker exec -t jm_regtest_bitcoind bitcoin-cli -datadir=/home/bitcoin/data -regtest -rpcport=43782 listwallets"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
|
|
@ -66,96 +71,97 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@joinmarket-webui/joinmarket-api-ts": "0.3.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@radix-ui/react-accordion": "1.2.14",
|
||||
"@radix-ui/react-avatar": "1.2.0",
|
||||
"@radix-ui/react-checkbox": "1.3.5",
|
||||
"@radix-ui/react-dialog": "1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.18",
|
||||
"@radix-ui/react-label": "2.1.10",
|
||||
"@radix-ui/react-popover": "1.1.17",
|
||||
"@radix-ui/react-radio-group": "1.4.1",
|
||||
"@radix-ui/react-select": "2.3.1",
|
||||
"@radix-ui/react-separator": "1.1.10",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-switch": "1.3.1",
|
||||
"@radix-ui/react-tabs": "1.1.15",
|
||||
"@radix-ui/react-tooltip": "1.2.10",
|
||||
"@hookform/resolvers": "5.7.1",
|
||||
"@joinmarket-webui/joinmarket-ng-api-ts": "1.0.0",
|
||||
"@noble/hashes": "2.3.0",
|
||||
"@radix-ui/react-accordion": "1.2.20",
|
||||
"@radix-ui/react-avatar": "1.2.6",
|
||||
"@radix-ui/react-checkbox": "1.3.11",
|
||||
"@radix-ui/react-dialog": "1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||
"@radix-ui/react-label": "2.1.15",
|
||||
"@radix-ui/react-popover": "1.1.23",
|
||||
"@radix-ui/react-radio-group": "1.4.7",
|
||||
"@radix-ui/react-select": "2.3.7",
|
||||
"@radix-ui/react-separator": "1.1.15",
|
||||
"@radix-ui/react-slider": "1.4.7",
|
||||
"@radix-ui/react-slot": "1.3.3",
|
||||
"@radix-ui/react-switch": "1.3.7",
|
||||
"@radix-ui/react-tabs": "1.1.21",
|
||||
"@radix-ui/react-tooltip": "1.2.16",
|
||||
"@scure/bip32": "2.2.0",
|
||||
"@scure/bip39": "2.2.0",
|
||||
"@tailwindcss/vite": "4.3.1",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@tanstack/match-sorter-utils": "8.19.4",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"bitcoin-address-validation": "3.0.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"html5-qrcode": "2.3.8",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"lucide-react": "1.20.0",
|
||||
"lucide-react": "1.30.0",
|
||||
"next-themes": "0.4.6",
|
||||
"qrcode": "1.5.4",
|
||||
"radix-ui": "1.6.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-hook-form": "7.79.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-router-dom": "7.18.0",
|
||||
"radix-ui": "1.6.7",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.84.0",
|
||||
"react-i18next": "17.0.11",
|
||||
"react-router-dom": "7.18.2",
|
||||
"react-use-websocket": "4.13.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwindcss": "4.3.1",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"yup": "1.7.1",
|
||||
"zustand": "5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "5.2.1",
|
||||
"@eslint/js": "9.39.4",
|
||||
"@playwright/test": "1.61.0",
|
||||
"@storybook/addon-a11y": "10.4.6",
|
||||
"@storybook/addon-docs": "10.4.6",
|
||||
"@storybook/addon-vitest": "10.4.6",
|
||||
"@storybook/react-vite": "10.4.6",
|
||||
"@tanstack/eslint-plugin-query": "5.101.0",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@chromatic-com/storybook": "5.3.0",
|
||||
"@eslint/js": "9.39.5",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@storybook/addon-a11y": "10.5.7",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-vitest": "10.5.7",
|
||||
"@storybook/react-vite": "10.5.7",
|
||||
"@tanstack/eslint-plugin-query": "5.101.4",
|
||||
"@testing-library/jest-dom": "7.0.0",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@testing-library/user-event": "14.6.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "6.0.2",
|
||||
"@types/node": "24.12.4",
|
||||
"@types/node": "26.1.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"@vitest/browser": "4.1.9",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"conventional-changelog": "7.2.0",
|
||||
"eslint": "9.39.4",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@vitejs/plugin-react": "6.0.5",
|
||||
"@vitest/browser": "4.1.10",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"conventional-changelog": "8.1.1",
|
||||
"eslint": "9.39.5",
|
||||
"eslint-plugin-compat": "7.0.2",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.3",
|
||||
"eslint-plugin-storybook": "10.4.6",
|
||||
"eslint-plugin-unicorn": "64.0.0",
|
||||
"globals": "17.6.0",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-unicorn": "65.0.1",
|
||||
"globals": "17.9.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"lint-staged": "17.0.7",
|
||||
"msw": "2.14.6",
|
||||
"msw-storybook-addon": "2.0.7",
|
||||
"prettier": "3.8.4",
|
||||
"prettier-plugin-tailwindcss": "0.8.0",
|
||||
"storybook": "10.4.6",
|
||||
"jsdom": "30.0.1",
|
||||
"lint-staged": "17.3.0",
|
||||
"msw": "2.15.0",
|
||||
"msw-storybook-addon": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-tailwindcss": "0.8.1",
|
||||
"storybook": "10.5.7",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.61.1",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "4.1.9"
|
||||
"typescript-eslint": "8.66.0",
|
||||
"vite": "8.2.1",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"storybook": "$storybook",
|
||||
"@joinmarket-webui/joinmarket-api-ts": {
|
||||
"@joinmarket-webui/joinmarket-ng-api-ts": {
|
||||
"@tanstack/react-query": "$@tanstack/react-query"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.6'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const PACKAGE_VERSION = '2.15.0'
|
||||
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
|
|
@ -98,7 +98,10 @@ addEventListener('fetch', function (event) {
|
|||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +124,12 @@ addEventListener('fetch', function (event) {
|
|||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(event, client, requestId, requestInterceptedAt)
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
|
|
@ -129,8 +137,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
|
|||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Omit the body of server-sent event stream responses.
|
||||
// Cloning such responses would prevent client-side stream cancelations
|
||||
// from reaching the original stream (a teed stream only cancels its
|
||||
// source once both of its branches cancel) and would buffer the
|
||||
// entire stream into the unconsumed clone indefinitely.
|
||||
const isEventStreamResponse = response.headers
|
||||
.get('content-type')
|
||||
?.toLowerCase()
|
||||
.startsWith('text/event-stream')
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
const responseClone = isEventStreamResponse ? null : response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
|
|
@ -143,15 +161,17 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
|
|||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
type: response.type,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: responseClone ? responseClone.body : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
responseClone && responseClone.body
|
||||
? [serializedRequest.body, responseClone.body]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +236,9 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
|
|||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter((value) => value !== 'msw/passthrough')
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
|
|
@ -287,7 +309,10 @@ function sendToClient(client, message, transferrables = []) {
|
|||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)])
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ const generateChangelog = (newVersion) => {
|
|||
})
|
||||
|
||||
changelogStream.on('end', () => {
|
||||
changelog = changelog.replaceAll(/###\s(\w+)/g, '#### $1').replaceAll('\n\n\n', '\n\n')
|
||||
changelog = changelog.replaceAll(/###\s(\w+)/g, '#### $1').replaceAll('\n'.repeat(3), '\n\n')
|
||||
const finalChangelog = header + '\n' + (changelog + oldContent).replaceAll(/\n+$/, '\n')
|
||||
fs.writeFileSync(file, finalChangelog, 'utf8')
|
||||
return resolve()
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ vi.mock('./store/jmSessionStore', () => ({
|
|||
jmSessionStore: { getState: () => ({ state: holders.jmSession }) },
|
||||
}))
|
||||
|
||||
vi.mock('./store/jmTxStore', () => ({
|
||||
jmTxStore: { getState: () => ({ state: {} }) },
|
||||
}))
|
||||
|
||||
vi.mock('@/store/jamSettingsStore', () => ({
|
||||
jamSettingsStore: { getState: () => ({ state: { developerMode: holders.developerMode } }) },
|
||||
useDeveloperMode: () => ({ enabled: holders.developerMode }),
|
||||
|
|
@ -82,11 +86,11 @@ vi.mock('@tanstack/react-query', () => ({
|
|||
useMutation: () => ({ mutateAsync }),
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
lockwalletOptions: () => ({ queryKey: ['lock'] }),
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({
|
||||
token: vi.fn().mockResolvedValue({ data: { token: 't', refresh_token: 'r' } }),
|
||||
}))
|
||||
|
||||
|
|
|
|||
55
src/App.tsx
55
src/App.tsx
|
|
@ -1,7 +1,7 @@
|
|||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { lockwalletOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
|
||||
import { token } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import { lockwalletOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import { token } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
|
|
@ -45,8 +45,10 @@ import { setIntervalDebounced, walletDisplayName, type WalletFileName } from '@/
|
|||
import { authStore } from '@/store/authStore'
|
||||
import { jamSettingsStore, useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import { EarnReportPage } from './components/earn/report/EarnReportPage'
|
||||
import { RootLayout } from './components/layout/RootLayout'
|
||||
import { LockWalletConfirmDialog } from './components/ui/jam/LockWalletConfirmDialog'
|
||||
import { Spinner } from './components/ui/spinner'
|
||||
import { TxHistoryPage } from './components/wallet/TxHistoryPage'
|
||||
import { WalletJarsDetailsPage } from './components/wallet/WalletJarsDetailsPage'
|
||||
import { useJamSessionInfoContext } from './context/JamSessionInfoContext'
|
||||
import { JamSessionInfoContextProvider } from './context/JamSessionInfoContextProvider'
|
||||
|
|
@ -54,6 +56,7 @@ import { useJamWalletInfoContext } from './context/JamWalletInfoContext'
|
|||
import { JmWebsocketContextProvider } from './context/JmWebsocketContextProvider'
|
||||
import { getErrorReason } from './lib/errorReason'
|
||||
import { jmSessionStore } from './store/jmSessionStore'
|
||||
import { jmTxStore, type JmTxInfos } from './store/jmTxStore'
|
||||
import type { Milliseconds } from './types/global'
|
||||
|
||||
const DevSetupPage = lazy(() => import('@/components/dev/DevSetupPage'))
|
||||
|
|
@ -153,7 +156,15 @@ function App() {
|
|||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route id="base" element={<Outlet />} errorElement={<ErrorPage />}>
|
||||
<Route
|
||||
id="base"
|
||||
element={
|
||||
<RootLayout>
|
||||
<Outlet />
|
||||
</RootLayout>
|
||||
}
|
||||
errorElement={<ErrorPage />}
|
||||
>
|
||||
<Route path={routes.login} element={authenticated ? <Navigate to={routes.home} replace /> : <LoginPage />} />
|
||||
<Route
|
||||
path={routes.createWallet}
|
||||
|
|
@ -222,11 +233,15 @@ function App() {
|
|||
/>
|
||||
<Route path={routes.orderbook} element={<OrderbookPage />} />
|
||||
<Route path={routes.logs} element={<LogsPage />} />
|
||||
<Route path={routes.rescan} element={<RescanChainPage walletFileName={walletFileName!} />} />
|
||||
<Route
|
||||
path={routes.rescan}
|
||||
element={<RescanChainPage walletFileName={walletFileName!} backLinkTarget="settings" />}
|
||||
/>
|
||||
<Route
|
||||
path={routes.walletJarsDetails}
|
||||
element={<WalletJarsDetailsPage walletFileName={walletFileName!} />}
|
||||
/>
|
||||
<Route path={routes.txHistory} element={<TxHistoryPage walletFileName={walletFileName!} />} />
|
||||
{isDeveloperMode && isDebugFeatureEnabled('devPage') && (
|
||||
<Route
|
||||
id="dev-page"
|
||||
|
|
@ -306,7 +321,7 @@ function RefreshApiToken() {
|
|||
clearAuthAndQueryCache()
|
||||
|
||||
if (isDevMode) {
|
||||
const message = response.error?.message || response.error?.error_description || 'Unknown error.'
|
||||
const message = getErrorReason(response.error, 'Unknown error.')
|
||||
toast.error(`[DEV] Error while renewing auth token: ${message}`, {
|
||||
id: 'token-renew-error',
|
||||
})
|
||||
|
|
@ -364,8 +379,7 @@ function LoadFeeConfigData({ walletFileName }: { walletFileName: WalletFileName
|
|||
})
|
||||
.catch((error) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
// TODO: i18n
|
||||
const errorMessage = t('Error while loading fee values.: {{ reason }}', { reason })
|
||||
const errorMessage = t('settings.fees.error_loading_fee_config_failed', { reason })
|
||||
toast.error(errorMessage)
|
||||
console.error(errorMessage)
|
||||
})
|
||||
|
|
@ -379,6 +393,7 @@ const RELOAD_WALLET_INFO_DELAY: {
|
|||
AFTER_UTXO_CHANGE: Milliseconds
|
||||
AFTER_BLOCK_HEIGHT_CHANGE: Milliseconds
|
||||
AFTER_TAKER_STOPPED: Milliseconds
|
||||
AFTER_TX_MESSAGE: Milliseconds
|
||||
} = {
|
||||
// After rescanning, it is necessary to give the JM backend some time to synchronize.
|
||||
// A couple of seconds should be enough, however, this depends on the user hardware
|
||||
|
|
@ -396,6 +411,9 @@ const RELOAD_WALLET_INFO_DELAY: {
|
|||
|
||||
// Small delay is sufficient after block height change
|
||||
AFTER_TAKER_STOPPED: 210,
|
||||
|
||||
// Small delay is sufficient after an incoming tx message via websocket
|
||||
AFTER_TX_MESSAGE: 210,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -417,6 +435,9 @@ const WalletInfoAutoReload = () => {
|
|||
const previousBlockHeightRef = useRef<number | undefined>(currentBlockHeight)
|
||||
const previousTakerRunningRef = useRef<boolean>(currentTakerRunning)
|
||||
|
||||
const jmTxs = useStore(jmTxStore, (state) => state.state)
|
||||
const previousJmTxsRef = useRef<JmTxInfos>(jmTxs)
|
||||
|
||||
const { refetch: refetchWalletBalance, utxosHashHex } = useJamWalletInfoContext()
|
||||
|
||||
useEffect(
|
||||
|
|
@ -486,6 +507,26 @@ const WalletInfoAutoReload = () => {
|
|||
[refetchWalletBalance, utxosHashHex],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
function refetchWalletInfoAfterTxMessage() {
|
||||
const txsChanged = previousJmTxsRef.current !== jmTxs
|
||||
previousJmTxsRef.current = jmTxs
|
||||
if (!txsChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
const delayBefore = RELOAD_WALLET_INFO_DELAY.AFTER_TX_MESSAGE
|
||||
console.debug('Trigger refetch looking for funds AFTER_TX_MESSAGE with delay %d...', delayBefore)
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
refetchWalletBalance({ delayBefore, signal: abortCtrl.signal }).catch((error: unknown) => {
|
||||
console.warn('Error while auto-reloading wallet info AFTER_TX_MESSAGE finished', error)
|
||||
})
|
||||
return () => abortCtrl.abort('useEffect(AFTER_TX_MESSAGE) ended')
|
||||
},
|
||||
[refetchWalletBalance, jmTxs],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
function refetchWalletInfoAfterTakerStopped() {
|
||||
const takerStopped = previousTakerRunningRef.current === true && currentTakerRunning === false
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function LogsOverlay({ open, onOpenChange }: LogsOverlayProps) {
|
|||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => onOpenChange(false)}>
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<PageTitle title={t('logs.title')} />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import type { Jar } from '@/context/JamWalletInfoContext'
|
||||
import type { WalletFileName } from '@/lib/utils'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
import MainWalletPage from './MainWalletPage'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
|
|
@ -32,23 +32,38 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
useJars: () => ({ jars }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
shortenStringMiddle: (s: string) => s,
|
||||
walletDisplayName: (s: string) => s,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/ClickableJar', () => ({
|
||||
ClickableJar: ({ name, onClick }: { name: string; onClick: () => void }) => <button onClick={onClick}>{name}</button>,
|
||||
vi.mock('@/store/jamSettingsStore', () => ({
|
||||
usePreviewFeatures: () => ({
|
||||
'tx-history': true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
|
||||
WalletJarsDetailsOverlay: ({ open }: { open: boolean }) => (
|
||||
<div data-testid="overlay">{open ? 'open' : 'closed'}</div>
|
||||
WalletJarsDetailsOverlay: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedJarIndex,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
selectedJarIndex?: JarIndex
|
||||
}) => (
|
||||
<div data-testid="WalletJarsDetailsOverlay">
|
||||
{open ? 'open' : 'closed'}
|
||||
<span data-testid="WalletJarsDetailsOverlay#open">{open ? 'open' : 'closed'}</span>
|
||||
<span data-testid="WalletJarsDetailsOverlay#selectedJarIndex">{selectedJarIndex ?? 'undefined'}</span>
|
||||
<button data-testid="WalletJarsDetailsOverlay#onOpenChange" onClick={() => onOpenChange?.(!open)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./ui/jam/Balance', () => ({
|
||||
vi.mock('./wallet/TxHistoryContent', () => ({
|
||||
TxHistoryContent: () => <div data-testid="TxHistoryContent" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
|
||||
<span onClick={onClick}>{valueString}</span>
|
||||
),
|
||||
|
|
@ -58,23 +73,6 @@ vi.mock('./ui/spinner', () => ({
|
|||
Spinner: () => <div data-testid="spinner" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/alert', () => ({
|
||||
Alert: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
|
||||
<button onClick={onClick}>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const walletFileName = 'wallet.jmdat' as WalletFileName
|
||||
|
||||
const makeJar = (jarIndex: number, name: string): Jar =>
|
||||
|
|
@ -86,6 +84,7 @@ const makeJar = (jarIndex: number, name: string): Jar =>
|
|||
calculatedTotalBalanceInSats: 100,
|
||||
calculatedAvailableBalanceInSats: 100,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
},
|
||||
}) as unknown as Jar
|
||||
|
||||
|
|
@ -105,10 +104,20 @@ describe('MainWalletPage', () => {
|
|||
it('renders balance and jars, and opens the jar overlay on click', () => {
|
||||
render(<MainWalletPage walletFileName={walletFileName} />)
|
||||
expect(screen.getByText('5000')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('overlay')).toHaveTextContent('closed')
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('closed')
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent('undefined')
|
||||
|
||||
fireEvent.click(screen.getByText('Jar 0'))
|
||||
expect(screen.getByTestId('overlay')).toHaveTextContent('open')
|
||||
fireEvent.click(screen.getByText(jars[0].name))
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('open')
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent(String(jars[0].jarIndex))
|
||||
|
||||
fireEvent.click(screen.getByTestId('WalletJarsDetailsOverlay#onOpenChange'))
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('closed')
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent('undefined')
|
||||
|
||||
fireEvent.click(screen.getByTestId('WalletJarsDetailsOverlay#onOpenChange'))
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('open')
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent('undefined')
|
||||
})
|
||||
|
||||
it('navigates to deposit and withdraw routes', () => {
|
||||
|
|
@ -126,10 +135,14 @@ describe('MainWalletPage', () => {
|
|||
expect(refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a jars spinner while fetching and refreshes on click', () => {
|
||||
it('keeps jars visible and clickable while fetching in the background', () => {
|
||||
walletInfo = { ...walletInfo, isFetching: true }
|
||||
render(<MainWalletPage walletFileName={walletFileName} />)
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument()
|
||||
expect(screen.getByText(jars[0].name)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(jars[0].name))
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('open')
|
||||
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent(String(jars[0].jarIndex))
|
||||
fireEvent.click(screen.getByText('global.refresh'))
|
||||
expect(refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -139,4 +152,17 @@ describe('MainWalletPage', () => {
|
|||
fireEvent.click(screen.getByText('5000'))
|
||||
expect(toggleDisplayMode).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('expands and collapses transaction history via accordion toggle button', () => {
|
||||
render(<MainWalletPage walletFileName={walletFileName} />)
|
||||
expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument()
|
||||
|
||||
const toggleButton = screen.getByTitle('tx_history.expand_history')
|
||||
fireEvent.click(toggleButton)
|
||||
expect(screen.getByTestId('TxHistoryContent')).toBeInTheDocument()
|
||||
|
||||
const collapseButton = screen.getByTitle('tx_history.collapse_history')
|
||||
fireEvent.click(collapseButton)
|
||||
expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react'
|
||||
import { ChevronDownIcon, DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
|
|
@ -14,10 +14,14 @@ import {
|
|||
useWalletBalanceSummary,
|
||||
type Jar as JarObject,
|
||||
} from '@/context/JamWalletInfoContext'
|
||||
import { getErrorReason } from '@/lib/errorReason'
|
||||
import type { WalletFileName } from '@/lib/utils'
|
||||
import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils'
|
||||
import { usePreviewFeatures } from '@/store/jamSettingsStore'
|
||||
import { Balance } from './ui/jam/Balance'
|
||||
import { Spinner } from './ui/spinner'
|
||||
import { TxHistoryContent } from './wallet/TxHistoryContent'
|
||||
import { TxHistoryOverlay } from './wallet/TxHistoryOverlay'
|
||||
import { WalletJarsDetailsOverlay } from './wallet/WalletJarsDetailsOverlay'
|
||||
|
||||
interface MainWalletPageProps {
|
||||
|
|
@ -27,8 +31,11 @@ interface MainWalletPageProps {
|
|||
export default function MainWalletPage({ walletFileName }: MainWalletPageProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const previewFeatures = usePreviewFeatures()
|
||||
const [selectedJar, setSelectedJar] = useState<JarObject>()
|
||||
const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false)
|
||||
const [isTxHistoryOpen, setIsTxHistoryOpen] = useState(false)
|
||||
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false)
|
||||
|
||||
const { toggleDisplayMode } = useJamDisplayContext()
|
||||
const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext()
|
||||
|
|
@ -54,6 +61,10 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
walletFileName={walletFileName}
|
||||
selectedJarIndex={selectedJar?.jarIndex}
|
||||
/>
|
||||
{previewFeatures?.['tx-history'] === true ? (
|
||||
<TxHistoryOverlay open={isTxHistoryOpen} onOpenChange={setIsTxHistoryOpen} walletFileName={walletFileName} />
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-8 px-4 py-12">
|
||||
<div className="flex w-full max-w-xl flex-col items-center justify-center gap-2">
|
||||
<p className="text-muted-foreground hover:text-foreground text-xl select-all" title={walletName}>
|
||||
|
|
@ -67,7 +78,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
{t('global.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-4xl tracking-wider">
|
||||
<div className="flex items-center text-3xl tracking-wider sm:text-4xl">
|
||||
<Balance
|
||||
onClick={() => toggleDisplayMode()}
|
||||
valueString={String(walletBalanceSummary.calculatedTotalBalanceInSats)}
|
||||
|
|
@ -76,7 +87,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex w-full justify-center gap-4" data-tour-id="wallet-actions">
|
||||
<div className="mt-2 flex w-full justify-center gap-2 sm:gap-4" data-tour-id="wallet-actions">
|
||||
<Button size="xxl" className="flex-1" variant="default" onClick={() => void navigate(routes.receive)}>
|
||||
<DownloadIcon />
|
||||
{t('current_wallet.button_deposit')}
|
||||
|
|
@ -86,21 +97,50 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
{t('current_wallet.button_withdraw')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{previewFeatures?.['tx-history'] === true ? (
|
||||
<div className="my-6 flex w-full max-w-4xl items-center gap-3">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="border-border bg-background size-8 shrink-0 rounded-full"
|
||||
onClick={() => setIsHistoryExpanded((previous) => !previous)}
|
||||
title={isHistoryExpanded ? t('tx_history.collapse_history') : t('tx_history.expand_history')}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn('size-4 transition-transform duration-200', isHistoryExpanded && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{isHistoryExpanded && (
|
||||
<TxHistoryContent
|
||||
walletFileName={walletFileName}
|
||||
compact={true}
|
||||
initialLimit={5}
|
||||
onViewAll={() => setIsTxHistoryOpen(true)}
|
||||
className="w-full max-w-6xl"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="mb-4 max-w-xl">
|
||||
<AlertDescription>
|
||||
{t('global.errors.error_loading_wallet_failed', {
|
||||
reason: error.message || t('global.errors.reason_unknown'),
|
||||
reason: getErrorReason(error, t('global.errors.reason_unknown')),
|
||||
})}
|
||||
<Button variant="outline" size="sm" onClick={() => void refetchWalletData()}>
|
||||
<Button variant="outline" onClick={() => void refetchWalletData()}>
|
||||
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': isFetching })} />
|
||||
{t('global.retry')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="light:text-black mt-8 mb-4 flex w-full flex-col gap-8 text-white" data-tour-id="wallet-jars">
|
||||
<div className="text-muted-foreground hover:text-foreground">
|
||||
|
|
@ -115,7 +155,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex min-h-[128px] items-center justify-center gap-4">
|
||||
{isFetching ? (
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 py-8">
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{t('global.loading')}
|
||||
|
|
@ -146,7 +186,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xl justify-end">
|
||||
<Button variant="ghost" size="xs" onClick={() => void refetchWalletData()}>
|
||||
<Button variant="ghost" onClick={() => void refetchWalletData()}>
|
||||
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': isFetching })} />
|
||||
{t('global.refresh')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -29,13 +29,7 @@ const createFormSchema = (t: TFunction) => {
|
|||
return yup
|
||||
.object({
|
||||
revealSensitiveInfo: yup.boolean().required(),
|
||||
backupConfirmed: yup
|
||||
.boolean()
|
||||
.isTrue(
|
||||
/* TODO: i18n */ t(
|
||||
'Please write down your seed phrase and password! Without this information you will not be able to access and recover your wallet!',
|
||||
),
|
||||
),
|
||||
backupConfirmed: yup.boolean().isTrue(t('create_wallet.subtitle_wallet_created')),
|
||||
})
|
||||
.required()
|
||||
}
|
||||
|
|
@ -77,10 +71,10 @@ export const CreateStepConfirm = ({
|
|||
useEffect(() => {
|
||||
if (backupConfirmed) return
|
||||
|
||||
const toastId = toast.info(/* TODO: i18n */ 'Save Your Seed Phrase', {
|
||||
const toastId = toast.info(t('create_wallet.alert_save_seed_title'), {
|
||||
id: 'save-seed-phrase-reminder',
|
||||
icon: <AlertCircleIcon />,
|
||||
description: /* TODO: change i18n key ("alert_description") */ t('create_wallet.subtitle_wallet_created'),
|
||||
description: t('create_wallet.subtitle_wallet_created'),
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
|
||||
|
|
@ -118,15 +112,22 @@ export const CreateStepConfirm = ({
|
|||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.revealSensitiveInfo !== undefined} orientation="horizontal">
|
||||
<Field
|
||||
data-invalid={errors.revealSensitiveInfo !== undefined}
|
||||
orientation="horizontal"
|
||||
className="items-start"
|
||||
>
|
||||
<Switch
|
||||
className="mt-0.5 shrink-0"
|
||||
id="switch-reveal-seed"
|
||||
checked={revealSensitiveInfo}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('revealSensitiveInfo', checked, { shouldValidate: true, shouldTouch: true })
|
||||
}
|
||||
/>
|
||||
<FieldLabel htmlFor="switch-reveal-seed">{t('create_wallet.confirmation_toggle_reveal_info')}</FieldLabel>
|
||||
<FieldLabel htmlFor="switch-reveal-seed" className="min-w-0 flex-1">
|
||||
{t('create_wallet.confirmation_toggle_reveal_info')}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
{errors.revealSensitiveInfo?.message && (
|
||||
<div className="text-destructive text-xs">{errors.revealSensitiveInfo.message}</div>
|
||||
|
|
@ -134,15 +135,16 @@ export const CreateStepConfirm = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.backupConfirmed !== undefined} orientation="horizontal">
|
||||
<Field data-invalid={errors.backupConfirmed !== undefined} orientation="horizontal" className="items-start">
|
||||
<Switch
|
||||
className="mt-0.5 shrink-0"
|
||||
id="switch-confirm-backup"
|
||||
required={true}
|
||||
disabled={!touchedFields.revealSensitiveInfo}
|
||||
checked={backupConfirmed}
|
||||
onCheckedChange={(checked) => setValue('backupConfirmed', checked, { shouldValidate: true })}
|
||||
/>
|
||||
<FieldLabel htmlFor="switch-confirm-backup">
|
||||
<FieldLabel htmlFor="switch-confirm-backup" className="min-w-0 flex-1">
|
||||
{t('create_wallet.confirmation_toggle_info_written_down')}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
<div className="bg-muted h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-300 ease-out', {
|
||||
'bg-green-300/50': isCorrect,
|
||||
'bg-brand-success/40': isCorrect,
|
||||
'bg-primary': !isCorrect,
|
||||
})}
|
||||
style={{ width: `${(progress / total) * 100}%` }}
|
||||
|
|
@ -100,10 +100,10 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
className={cn('rounded-lg border-2 border-dashed p-2.5 transition-colors', {
|
||||
'border-destructive/50': wrongButtonIndex !== undefined,
|
||||
'border-muted-foreground/20': wrongButtonIndex === undefined,
|
||||
'border-green-300/50 bg-green-600/5': allSelected && isCorrect,
|
||||
'border-brand-success/50 bg-brand-success/10': allSelected && isCorrect,
|
||||
})}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-1.5 select-none">
|
||||
<div className="grid grid-cols-2 gap-1.5 select-none sm:grid-cols-3">
|
||||
{!isCorrect &&
|
||||
mnemonicPhrase.map((_, index) => {
|
||||
const word = selectedWords[index]
|
||||
|
|
@ -112,10 +112,13 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn('flex items-center gap-0.5 rounded-md px-0.5 py-1.5 font-mono text-xs transition-all', {
|
||||
'bg-primary/10 text-primary border-primary/20 border': isFilled,
|
||||
'border-muted bg-muted/30 border border-dashed': !isFilled,
|
||||
})}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-0.5 rounded-md px-0.5 py-1.5 font-mono text-xs transition-all',
|
||||
{
|
||||
'bg-primary/10 text-primary border-primary/20 border': isFilled,
|
||||
'border-muted bg-muted/30 border border-dashed': !isFilled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn('min-w-8 text-right tabular-nums', {
|
||||
|
|
@ -126,7 +129,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
{index + 1}.
|
||||
</span>
|
||||
{isFilled ? (
|
||||
<span>{isHidden ? <MaskedText masked /> : word}</span>
|
||||
<span className="min-w-0">{isHidden ? <MaskedText masked /> : word}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30">···</span>
|
||||
)}
|
||||
|
|
@ -135,7 +138,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
})}
|
||||
</div>
|
||||
{allSelected && isCorrect && (
|
||||
<div className="flex h-full items-center justify-center gap-1.5 text-sm font-medium text-green-300">
|
||||
<div className="text-brand-success flex h-full items-center justify-center gap-1.5 text-sm font-medium">
|
||||
<CheckCircle2Icon className="size-4" />
|
||||
{t('create_wallet.verify_mnemonic.feedback_mnemonic_confirmed')}
|
||||
</div>
|
||||
|
|
@ -143,7 +146,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
</div>
|
||||
|
||||
{!allSelected && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{shuffledWords.map((word, index) => {
|
||||
const isPicked = pickedIndicesSet.has(index)
|
||||
const isWrong = wrongButtonIndex === index
|
||||
|
|
@ -154,7 +157,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
size="lg"
|
||||
variant={isWrong ? 'destructive' : isPicked ? 'outline' : 'secondary'}
|
||||
disabled={isPicked || wrongButtonIndex !== undefined}
|
||||
className={cn('font-mono text-sm transition-all', {
|
||||
className={cn('min-w-0 px-2 font-mono text-sm transition-all', {
|
||||
'pointer-events-none opacity-25': isPicked,
|
||||
'animate-shake': isWrong,
|
||||
})}
|
||||
|
|
@ -167,24 +170,21 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn('flex-1', {
|
||||
hidden: isCorrect,
|
||||
})}
|
||||
className="w-full"
|
||||
size="xxl"
|
||||
onClick={onBack}
|
||||
disabled={isCorrect || verifyMutation.isPending}
|
||||
disabled={!isCorrect || verifyMutation.isPending}
|
||||
onClick={() => verifyMutation.mutate({ mustBeCorrect: true })}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
{t('create_wallet.back_button')}
|
||||
{verifyMutation.isPending && <Spinner className="motion-reduce:hidden" />}
|
||||
{t('create_wallet.confirmation_button_fund_wallet')}
|
||||
</Button>
|
||||
{skipWalletBackupVerification && !isCorrect && (
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
size="xxl"
|
||||
disabled={verifyMutation.isPending}
|
||||
|
|
@ -195,13 +195,15 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
size="xxl"
|
||||
disabled={!isCorrect || verifyMutation.isPending}
|
||||
onClick={() => verifyMutation.mutate({ mustBeCorrect: true })}
|
||||
variant="ghost"
|
||||
className={cn({
|
||||
hidden: isCorrect,
|
||||
})}
|
||||
onClick={onBack}
|
||||
disabled={isCorrect || verifyMutation.isPending}
|
||||
>
|
||||
{verifyMutation.isPending && <Spinner className="motion-reduce:hidden" />}
|
||||
{t('create_wallet.confirmation_button_fund_wallet')}
|
||||
<ChevronLeftIcon />
|
||||
{t('create_wallet.back_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ComponentProps } from 'react'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { routes } from '@/constants/routes'
|
||||
|
|
@ -12,20 +13,24 @@ type CreateStepWalletDetailsProps = ComponentProps<typeof CreateWalletForm> & {
|
|||
}
|
||||
|
||||
export const CreateStepWalletDetails = ({ sessionInfo, ...createFormProps }: CreateStepWalletDetailsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sessionInfo?.session === true ? (
|
||||
<OtherWalletActiveAlert linkTarget={'login'} walletFileName={sessionInfo.wallet_name as WalletFileName} />
|
||||
<OtherWalletActiveAlert
|
||||
linkTarget={'login'}
|
||||
walletFileName={sessionInfo.wallet_name as WalletFileName | undefined}
|
||||
/>
|
||||
) : (
|
||||
<CreateWalletForm {...createFormProps} />
|
||||
)}
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{/* TODO: i18n */}
|
||||
Already have a wallet?{' '}
|
||||
{t('create_wallet.text_login_hint')}{' '}
|
||||
<Button variant="link" asChild>
|
||||
<Link to={routes.login} className="font-semibold">
|
||||
Sign in here
|
||||
{t('create_wallet.button_login_link')}
|
||||
</Link>
|
||||
</Button>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useForm, type Mode, type SubmitHandler } from 'react-hook-form'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import * as yup from 'yup'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
|
@ -91,89 +91,81 @@ export const CreateWalletForm = ({
|
|||
|
||||
return (
|
||||
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.walletName !== undefined}>
|
||||
<FieldLabel htmlFor="create-wallet-name">{t('create_wallet.label_wallet_name')}</FieldLabel>
|
||||
<Input
|
||||
id="create-wallet-name"
|
||||
{...register('walletName', {
|
||||
<Field data-invalid={errors.walletName !== undefined}>
|
||||
<FieldLabel htmlFor="create-wallet-name">{t('create_wallet.label_wallet_name')}</FieldLabel>
|
||||
<Input
|
||||
id="create-wallet-name"
|
||||
{...register('walletName', {
|
||||
required: true,
|
||||
disabled,
|
||||
})}
|
||||
placeholder={t('create_wallet.placeholder_wallet_name')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.walletName?.message && <FieldError>{errors.walletName.message}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={errors.password !== undefined}>
|
||||
<FieldLabel htmlFor="create-password">{t('create_wallet.label_password')}</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="create-password"
|
||||
{...register('password', {
|
||||
required: true,
|
||||
disabled,
|
||||
})}
|
||||
placeholder={t('create_wallet.placeholder_wallet_name')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={t('create_wallet.placeholder_password')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Field>
|
||||
{errors.walletName?.message && <div className="text-destructive text-xs">{errors.walletName.message}</div>}
|
||||
</div>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<LockIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword((val) => !val)}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeOffIcon />}
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{errors.password?.message && <FieldError>{errors.password.message}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.password !== undefined}>
|
||||
<FieldLabel htmlFor="create-password">{t('create_wallet.label_password')}</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="create-password"
|
||||
{...register('password', {
|
||||
required: true,
|
||||
disabled,
|
||||
})}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={t('create_wallet.placeholder_password')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<LockIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword((val) => !val)}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeOffIcon />}
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{errors.password?.message && <div className="text-destructive text-xs">{errors.password.message}</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.confirmPassword !== undefined}>
|
||||
<FieldLabel htmlFor="create-confirm-password">{t('create_wallet.label_password_confirm')}</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="create-confirm-password"
|
||||
{...register('confirmPassword', {
|
||||
required: true,
|
||||
disabled,
|
||||
})}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder={t('create_wallet.placeholder_password_confirm')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<LockIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={() => setShowConfirmPassword((val) => !val)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeIcon /> : <EyeOffIcon />}
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{errors.confirmPassword?.message && (
|
||||
<div className="text-destructive text-xs">{errors.confirmPassword?.message}</div>
|
||||
)}
|
||||
</div>
|
||||
<Field data-invalid={errors.confirmPassword !== undefined}>
|
||||
<FieldLabel htmlFor="create-confirm-password">{t('create_wallet.label_password_confirm')}</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="create-confirm-password"
|
||||
{...register('confirmPassword', {
|
||||
required: true,
|
||||
disabled,
|
||||
})}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
placeholder={t('create_wallet.placeholder_password_confirm')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<LockIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={() => setShowConfirmPassword((val) => !val)}
|
||||
>
|
||||
{showConfirmPassword ? <EyeIcon /> : <EyeOffIcon />}
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{errors.confirmPassword?.message && <FieldError>{errors.confirmPassword?.message}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={disabled || isSubmitting} size="xxl">
|
||||
{isSubmitting && <Spinner className="motion-reduce:hidden" />}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,14 @@ const mocks = vi.hoisted(() => ({
|
|||
type QueryOptions = { queryKey?: readonly unknown[] }
|
||||
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
createwalletMutation: vi.fn(() => ({ mutationFn: mocks.createWallet })),
|
||||
listwalletsOptions: vi.fn(() => ({ queryKey: ['wallets'], queryFn: vi.fn() })),
|
||||
sessionOptions: vi.fn(() => ({ queryKey: ['session'], queryFn: vi.fn() })),
|
||||
unlockwalletMutation: vi.fn(() => ({ mutationFn: mocks.unlockWallet })),
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({
|
||||
lockwallet: mocks.lockWallet,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import {
|
|||
listwalletsOptions,
|
||||
sessionOptions,
|
||||
unlockwalletMutation,
|
||||
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
|
||||
import { lockwallet } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
} from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import { lockwallet } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { CircleCheckBigIcon, ShieldCheckIcon, WalletIcon, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useStore } from 'zustand'
|
||||
|
|
@ -41,7 +41,7 @@ const CreateWalletCard = ({ icon: Icon, title, children }: PropsWithChildren<{ i
|
|||
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon className="text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-bold">{title}</CardTitle>
|
||||
<CardTitle className="text-center text-xl font-bold break-words">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">{children}</CardContent>
|
||||
</Card>
|
||||
|
|
@ -112,20 +112,19 @@ const CreateWalletPage = () => {
|
|||
const { data: sessionInfo } = await sessionQuery.refetch()
|
||||
if (sessionInfo?.session === true) {
|
||||
console.warn('Active session detected:', sessionInfo)
|
||||
// TODO: i18n
|
||||
toast.error(
|
||||
`Cannot create wallet as "${walletDisplayName(
|
||||
(sessionInfo?.wallet_name || 'Unknown') as WalletFileName,
|
||||
)}" wallet is currently active.`,
|
||||
t('create_wallet.error_wallet_active', {
|
||||
walletName: walletDisplayName((sessionInfo?.wallet_name || 'Unknown') as WalletFileName),
|
||||
}),
|
||||
{
|
||||
description: (
|
||||
<>
|
||||
<Trans i18nKey="create_wallet.error_wallet_active_hint">
|
||||
Alternatively, you can{' '}
|
||||
<a href={routes.login} className="font-medium underline hover:no-underline">
|
||||
log in with the existing wallet
|
||||
</a>{' '}
|
||||
instead.
|
||||
</>
|
||||
</Trans>
|
||||
),
|
||||
duration: 10_000,
|
||||
},
|
||||
|
|
@ -181,8 +180,7 @@ const CreateWalletPage = () => {
|
|||
setStep('confirm')
|
||||
} catch (error: unknown) {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
/* TODO: i18n */
|
||||
toast.error(`Failed to create wallet: ${reason}`)
|
||||
toast.error(t('create_wallet.error_creating_wallet', { reason }))
|
||||
} finally {
|
||||
toast.dismiss(durationHintToastId)
|
||||
}
|
||||
|
|
@ -211,8 +209,7 @@ const CreateWalletPage = () => {
|
|||
await navigate(routes.home)
|
||||
} catch (error: unknown) {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
/* TODO: i18n */
|
||||
toast.error(`Failed to unlock wallet: ${reason}`)
|
||||
toast.error(t('login.error_unlocking_wallet', { reason }))
|
||||
|
||||
await navigate(routes.login)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,17 +16,26 @@ export const OtherWalletActiveAlert = ({ walletFileName, linkTarget }: OtherWall
|
|||
<Alert variant="warning">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="create_wallet.alert_other_wallet_unlocked"
|
||||
values={{
|
||||
walletName: walletDisplayName((walletFileName || 'Unknown') as WalletFileName),
|
||||
}}
|
||||
>
|
||||
Currently <strong>walletName</strong> is active. You need to lock it first.
|
||||
<Link to={routes[linkTarget]} className="font-semibold underline">
|
||||
Go back
|
||||
</Link>
|
||||
</Trans>
|
||||
{walletFileName ? (
|
||||
<Trans
|
||||
i18nKey="create_wallet.alert_other_wallet_unlocked"
|
||||
values={{
|
||||
walletName: walletDisplayName(walletFileName),
|
||||
}}
|
||||
>
|
||||
Currently <strong>walletName</strong> is active. You need to lock it first.
|
||||
<Link to={routes[linkTarget]} className="font-semibold underline">
|
||||
Go back
|
||||
</Link>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="create_wallet.alert_other_wallet_unlocked_generic">
|
||||
Another wallet is active. You need to lock it first.
|
||||
<Link to={routes[linkTarget]} className="font-semibold underline">
|
||||
Go back
|
||||
</Link>
|
||||
</Trans>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ export default function DevSetupPage() {
|
|||
<h5 className="text-xl font-bold">Jam Instances</h5>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4 shrink-0" />
|
||||
<span className="font-mono">jm_regtest_joinmarket</span>
|
||||
<Badge>primary · ng native</Badge>
|
||||
</div>
|
||||
|
|
@ -57,9 +57,14 @@ export default function DevSetupPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4" />
|
||||
<a href={LINK_JM_REGTEST_JOINMARKET2} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4 shrink-0" />
|
||||
<a
|
||||
href={LINK_JM_REGTEST_JOINMARKET2}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 break-all underline"
|
||||
>
|
||||
jm_regtest_joinmarket2 ({LINK_JM_REGTEST_JOINMARKET2})
|
||||
</a>
|
||||
<Badge>secondary · ng standalone</Badge>
|
||||
|
|
@ -72,8 +77,8 @@ export default function DevSetupPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4 shrink-0" />
|
||||
<span className="font-mono">jm_regtest_joinmarket3</span>
|
||||
<Badge>tertiary · clientserver native</Badge>
|
||||
</div>
|
||||
|
|
@ -85,9 +90,14 @@ export default function DevSetupPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4" />
|
||||
<a href={LINK_JM_REGTEST_JOINMARKET4} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4 shrink-0" />
|
||||
<a
|
||||
href={LINK_JM_REGTEST_JOINMARKET4}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 break-all underline"
|
||||
>
|
||||
jm_regtest_joinmarket4 ({LINK_JM_REGTEST_JOINMARKET4})
|
||||
</a>
|
||||
<Badge>quaternary · clientserver standalone</Badge>
|
||||
|
|
@ -115,10 +125,15 @@ export default function DevSetupPage() {
|
|||
<h5 className="text-xl font-bold">Block Explorer</h5>
|
||||
<div>
|
||||
{' '}
|
||||
<div className="flex items-center gap-2">
|
||||
<BlocksIcon className="h-4 w-4" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BlocksIcon className="h-4 w-4 shrink-0" />
|
||||
|
||||
<a href={LINK_JM_REGTEST_EXPLORER} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
<a
|
||||
href={LINK_JM_REGTEST_EXPLORER}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 break-all underline"
|
||||
>
|
||||
jm_regtest_explorer ({LINK_JM_REGTEST_EXPLORER})
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -133,16 +148,23 @@ export default function DevSetupPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalIcon className="h-4 w-4" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TerminalIcon className="h-4 w-4 shrink-0" />
|
||||
|
||||
<a href={LINK_JM_REGTEST_RPC_TERMINAL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
<a
|
||||
href={LINK_JM_REGTEST_RPC_TERMINAL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 break-all underline"
|
||||
>
|
||||
Regtest RPC Terminal ({LINK_JM_REGTEST_RPC_TERMINAL})
|
||||
</a>
|
||||
</div>
|
||||
<div className="my-2 ms-5">
|
||||
Mine a block, e.g.:
|
||||
<pre>generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx</pre>
|
||||
<pre className="max-w-full overflow-x-auto">
|
||||
generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ vi.mock('./CreateFidelityBondDialogSteps', () => ({
|
|||
CreateFidelityBondDialogSteps: () => <div data-testid="steps-component">Steps Component</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./StepProgress', () => ({
|
||||
vi.mock('../fidelity-bond/StepProgress', () => ({
|
||||
StepProgress: ({ currentStep }: { currentStep: number }) => <div data-testid="step-progress">{currentStep}</div>,
|
||||
}))
|
||||
|
||||
|
|
@ -45,8 +45,8 @@ describe('CreateFidelityBondDialog', () => {
|
|||
|
||||
// Check buttons
|
||||
expect(screen.queryByText('global.back')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('earn.fidelity_bond.select_date.text_secondary_button')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.fidelity_bond.select_date.text_primary_button')).toBeInTheDocument()
|
||||
expect(screen.getByText('global.cancel')).toBeInTheDocument()
|
||||
expect(screen.getByText('global.next')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correctly on review step', () => {
|
||||
|
|
@ -65,7 +65,7 @@ describe('CreateFidelityBondDialog', () => {
|
|||
render(<CreateFidelityBondDialog open={true} onOpenChange={vi.fn()} walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.queryByTestId('step-progress')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('earn.fidelity_bond.create_fidelity_bond.text_primary_button')).toBeInTheDocument()
|
||||
expect(screen.getByText('global.done')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correctly on freeze_utxos step', () => {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
import { AlertTriangleIcon, ChevronLeftIcon } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { FidelityBondDialogLayout } from '../fidelity-bond/FidelityBondDialogLayout'
|
||||
import { WizardStepFooter } from '../fidelity-bond/WizardStepFooter'
|
||||
import { CreateFidelityBondDialogSteps } from './CreateFidelityBondDialogSteps'
|
||||
import { StepProgress } from './StepProgress'
|
||||
import type { CreateFidelityBondDialogProps } from './types'
|
||||
import { useCreateFidelityBondWizard } from './useCreateFidelityBondWizard'
|
||||
|
||||
const TOTAL_STEPS = 5
|
||||
|
||||
export function CreateFidelityBondDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -45,14 +38,9 @@ export function CreateFidelityBondDialog({
|
|||
|
||||
if (step === 'success') {
|
||||
return (
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
<>
|
||||
{frozenUtxos.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-w-32"
|
||||
onClick={() => void handleUnfreezeUtxos()}
|
||||
disabled={unfreezeUtxo.isPending}
|
||||
>
|
||||
<Button variant="outline" onClick={() => void handleUnfreezeUtxos()} disabled={unfreezeUtxo.isPending}>
|
||||
{unfreezeUtxo.isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
|
|
@ -63,63 +51,43 @@ export function CreateFidelityBondDialog({
|
|||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button className="min-w-32" onClick={() => handleOpenChange(false)}>
|
||||
{t('earn.fidelity_bond.create_fidelity_bond.text_primary_button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<Button onClick={() => handleOpenChange(false)}>{t('global.done')}</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const isLoading = freezeUtxo.isPending || directSend.isPending
|
||||
|
||||
return (
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
{step !== 'select_date' && (
|
||||
<Button variant="ghost" onClick={handleBack} disabled={isLoading}>
|
||||
<ChevronLeftIcon />
|
||||
{t('global.back')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)} disabled={isLoading}>
|
||||
{t('earn.fidelity_bond.select_date.text_secondary_button')}
|
||||
</Button>
|
||||
<Button className="min-w-32" onClick={() => void handleNext()} disabled={!canProceed() || isLoading}>
|
||||
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
|
||||
{step === 'freeze_utxos'
|
||||
<WizardStepFooter
|
||||
onBack={step !== 'select_date' ? handleBack : undefined}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
onPrimary={() => void handleNext()}
|
||||
primaryDisabled={!canProceed()}
|
||||
isLoading={freezeUtxo.isPending || directSend.isPending}
|
||||
primaryLabel={
|
||||
step === 'freeze_utxos'
|
||||
? utxosToFreeze.length > 0
|
||||
? t('earn.fidelity_bond.freeze_utxos.text_primary_button')
|
||||
: t('earn.fidelity_bond.freeze_utxos.text_primary_button_all_frozen')
|
||||
: step === 'review'
|
||||
? t('earn.fidelity_bond.review_inputs.text_primary_button')
|
||||
: t('earn.fidelity_bond.select_date.text_primary_button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
: t('global.next')
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} {...dialogProps}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.create_fidelity_bond.title')}</DialogTitle>
|
||||
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step !== 'creating' && step !== 'success' && <StepProgress currentStep={getStepNumber()} totalSteps={5} />}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="animate-in fade-in-50">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="py-2">
|
||||
<CreateFidelityBondDialogSteps wizard={wizard} />
|
||||
</div>
|
||||
|
||||
{renderFooter()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FidelityBondDialogLayout
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={t('earn.fidelity_bond.create_fidelity_bond.title')}
|
||||
currentStep={step !== 'creating' && step !== 'success' ? getStepNumber() : undefined}
|
||||
totalSteps={TOTAL_STEPS}
|
||||
error={error}
|
||||
footer={renderFooter()}
|
||||
{...dialogProps}
|
||||
>
|
||||
<CreateFidelityBondDialogSteps wizard={wizard} />
|
||||
</FidelityBondDialogLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,20 +13,59 @@ vi.mock('react-i18next', () => ({
|
|||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/BitcoinQrCode', () => ({
|
||||
BitcoinAddressQrCode: () => <div data-testid="qr-code">QR</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
formatSats: (sats: number) => `${sats} sats`,
|
||||
clamp: (val: number, min: number, max: number) => Math.min(Math.max(val, min), max),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/Address', () => ({
|
||||
Address: ({ value }: { value: string }) => <span>{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString: string }) => <span>{valueString} sats</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/fidelityBondUtils', () => ({
|
||||
utxo: {
|
||||
isFidelityBond: vi.fn(),
|
||||
},
|
||||
lockdate: {
|
||||
toDateLabel: () => 'January 1, 2025',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/store/jamSettingsStore', () => ({
|
||||
useDeveloperMode: () => ({ enabled: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/JamWalletInfoContext', () => ({
|
||||
useJamWalletInfoContext: () => ({
|
||||
jars: [
|
||||
{ jarIndex: 0, name: 'Jar 0', color: '#000', balanceSummary: {}, utxos: [] },
|
||||
{ jarIndex: 1, name: 'Jar 1', color: '#111', balanceSummary: {}, utxos: [] },
|
||||
],
|
||||
walletBalanceSummary: { calculatedTotalBalanceInSats: 14000 },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({
|
||||
name,
|
||||
onSelect,
|
||||
disabled,
|
||||
isSelected,
|
||||
}: {
|
||||
name?: string
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
isSelected?: boolean
|
||||
}) => (
|
||||
<button onSelect={onSelect} disabled={disabled} data-selected={isSelected}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const getBaseWizard = (): Wizard =>
|
||||
|
|
@ -34,15 +73,8 @@ const getBaseWizard = (): Wizard =>
|
|||
step: 'select_date',
|
||||
setSelectedLockdate: vi.fn(),
|
||||
selectedLockdate: '2025-01',
|
||||
selectedYear: '2025',
|
||||
selectedMonth: '01',
|
||||
minYear: 2024,
|
||||
minMonth: 1,
|
||||
yearOptions: [{ value: '2025', label: '2025' }],
|
||||
monthOptions: [{ value: '01', label: 'Jan' }],
|
||||
clampLockdate: vi.fn((x: unknown) => x),
|
||||
hasDuplicateLockdate: false,
|
||||
selectedDateLabel: 'Jan 2025',
|
||||
existingFbLockdates: [],
|
||||
selectedJarIndex: 0,
|
||||
setSelectedJarIndex: vi.fn(),
|
||||
jarsWithUtxos: [],
|
||||
|
|
@ -51,8 +83,6 @@ const getBaseWizard = (): Wizard =>
|
|||
utxoPage: 0,
|
||||
setUtxoPage: vi.fn(),
|
||||
toggleUtxoSelection: vi.fn(),
|
||||
selectAllUtxos: vi.fn(),
|
||||
deselectAllUtxos: vi.fn(),
|
||||
totalAmount: 1000,
|
||||
isUsingAllFunds: false,
|
||||
utxosToFreeze: [],
|
||||
|
|
@ -72,7 +102,7 @@ describe('CreateFidelityBondDialogSteps', () => {
|
|||
|
||||
expect(screen.getByText('earn.fidelity_bond.select_date.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.fidelity_bond.select_date.form_label_month')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jan 2025')).toBeInTheDocument()
|
||||
expect(screen.getByText('January 1, 2025')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders select_jar step with jars', () => {
|
||||
|
|
@ -90,8 +120,9 @@ describe('CreateFidelityBondDialogSteps', () => {
|
|||
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
|
||||
|
||||
expect(screen.getByText('earn.fidelity_bond.select_jar.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jar 0')).toBeInTheDocument()
|
||||
expect(screen.getByText('5000 sats')).toBeInTheDocument()
|
||||
// Jar 0 is eligible, Jar 1 has no eligible utxos and is disabled
|
||||
expect(screen.getByText('Jar 0')).toBeEnabled()
|
||||
expect(screen.getByText('Jar 1')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('renders select_utxos step', () => {
|
||||
|
|
@ -127,7 +158,7 @@ describe('CreateFidelityBondDialogSteps', () => {
|
|||
|
||||
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
|
||||
expect(screen.getByText('bc1...')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('qr-code')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('qr-code')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders creating step', () => {
|
||||
|
|
@ -141,18 +172,18 @@ describe('CreateFidelityBondDialogSteps', () => {
|
|||
it('renders success step', () => {
|
||||
const wizard = getBaseWizard()
|
||||
wizard.step = 'success'
|
||||
wizard.txResult = { txinfo: { txid: '1234abcd' } }
|
||||
wizard.txResult = { txinfo: { txid: '1234abcd', hex: '' } }
|
||||
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
|
||||
|
||||
expect(screen.getByText('earn.fidelity_bond.create_fidelity_bond.success_text')).toBeInTheDocument()
|
||||
expect(screen.getByText('1234abcd')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('warns about a duplicate lock date on the select_date step', () => {
|
||||
it('passes existing fidelity-bond lock dates to the date selector', () => {
|
||||
const wizard = getBaseWizard()
|
||||
wizard.hasDuplicateLockdate = true
|
||||
wizard.existingFbLockdates = ['2025-01']
|
||||
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
|
||||
expect(screen.getByTestId('trans-earn.fidelity_bond.select_date.warning_fb_with_same_expiry')).toBeInTheDocument()
|
||||
expect(screen.getByText('January 1, 2025')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights the selected jar and shows the no-jars alert state', () => {
|
||||
|
|
@ -177,7 +208,7 @@ describe('CreateFidelityBondDialogSteps', () => {
|
|||
] as unknown as Wizard['jarsWithUtxos']
|
||||
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
|
||||
expect(screen.getByText('Jar 0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jar 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jar 1')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('renders select_utxos with a selected utxo, pagination, and the all-funds warning', () => {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,7 @@
|
|||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
Loader2Icon,
|
||||
CalendarIcon,
|
||||
WalletIcon,
|
||||
CoinsIcon,
|
||||
LockIcon,
|
||||
} from 'lucide-react'
|
||||
import { AlertTriangleIcon, CheckIcon, CalendarIcon, WalletIcon, CoinsIcon, LockIcon } from 'lucide-react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { buttonVariants } from '@/components/ui/button-variants'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { BitcoinAddressQrCode } from '@/components/ui/jam/BitcoinQrCode'
|
||||
import { CopyButton } from '@/components/ui/jam/CopyButton'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
|
|
@ -25,11 +9,22 @@ import {
|
|||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import { clamp, cn, formatSats } from '@/lib/utils'
|
||||
import { clamp, cn } from '@/lib/utils'
|
||||
import {
|
||||
AddressPreview,
|
||||
ConfirmationToggle,
|
||||
CopyableField,
|
||||
FidelityBondAmount,
|
||||
InfoCard,
|
||||
InlineLoading,
|
||||
JarBadge,
|
||||
PendingStep,
|
||||
StepIntro,
|
||||
SuccessHeading,
|
||||
} from '../fidelity-bond/FidelityBondDialogParts'
|
||||
import { FidelityBondJarSelector } from '../fidelity-bond/FidelityBondJarSelector'
|
||||
import { LockdateSelect } from '../fidelity-bond/LockdateSelect'
|
||||
import type { useCreateFidelityBondWizard } from './useCreateFidelityBondWizard'
|
||||
|
||||
type Wizard = ReturnType<typeof useCreateFidelityBondWizard>
|
||||
|
|
@ -43,15 +38,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
step,
|
||||
setSelectedLockdate,
|
||||
selectedLockdate,
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
minYear,
|
||||
minMonth,
|
||||
yearOptions,
|
||||
monthOptions,
|
||||
clampLockdate,
|
||||
hasDuplicateLockdate,
|
||||
selectedDateLabel,
|
||||
existingFbLockdates,
|
||||
selectedJarIndex,
|
||||
setSelectedJarIndex,
|
||||
jarsWithUtxos,
|
||||
|
|
@ -60,8 +47,6 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
utxoPage,
|
||||
setUtxoPage,
|
||||
toggleUtxoSelection,
|
||||
selectAllUtxos,
|
||||
deselectAllUtxos,
|
||||
totalAmount,
|
||||
isUsingAllFunds,
|
||||
utxosToFreeze,
|
||||
|
|
@ -74,144 +59,42 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
t,
|
||||
} = wizard
|
||||
|
||||
const selectedJar = jarsWithUtxos.find((jar) => jar.jarIndex === selectedJarIndex)
|
||||
const selectedDateLabel = selectedLockdate ? fb.lockdate.toDateLabel(selectedLockdate) : null
|
||||
|
||||
switch (step) {
|
||||
case 'select_date':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<CalendarIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t('earn.fidelity_bond.select_date.description')}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{t('earn.fidelity_bond.select_date.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StepIntro
|
||||
icon={CalendarIcon}
|
||||
title={t('earn.fidelity_bond.select_date.description')}
|
||||
subtitle={t('earn.fidelity_bond.select_date.subtitle')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lockdate-month" className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.select_date.form_label_month')}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedMonth}
|
||||
onValueChange={(month) => {
|
||||
const year = selectedYear || String(Number.parseInt(month, 10) >= minMonth ? minYear : minYear + 1)
|
||||
const newLockdate = clampLockdate(`${year}-${month}`)
|
||||
setSelectedLockdate(newLockdate)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="lockdate-month" className="h-11 w-full">
|
||||
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_month')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lockdate-year" className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.select_date.form_label_year')}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedYear}
|
||||
onValueChange={(year) => {
|
||||
const month = selectedMonth || String(year === String(minYear) ? minMonth : 1).padStart(2, '0')
|
||||
const newLockdate = clampLockdate(`${year}-${month}`)
|
||||
setSelectedLockdate(newLockdate)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="lockdate-year" className="h-11 w-full">
|
||||
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_year')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLockdate && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('earn.fidelity_bond.select_date.label_selected_lock_date')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">{selectedDateLabel}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDuplicateLockdate && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="earn.fidelity_bond.select_date.warning_fb_with_same_expiry">
|
||||
<strong>Warning</strong>: A fidelity bond with the same expiry date already exists.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<LockdateSelect
|
||||
id="lockdate"
|
||||
value={selectedLockdate}
|
||||
onChange={setSelectedLockdate}
|
||||
unavailableValues={existingFbLockdates}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'select_jar':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<WalletIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t('earn.fidelity_bond.select_jar.title')}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{t('earn.fidelity_bond.select_jar.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StepIntro
|
||||
icon={WalletIcon}
|
||||
title={t('earn.fidelity_bond.select_jar.title')}
|
||||
subtitle={t('earn.fidelity_bond.select_jar.description')}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{jarsWithUtxos.map((jar) => {
|
||||
const isSelected = selectedJarIndex === jar.jarIndex
|
||||
return (
|
||||
<Card
|
||||
key={jar.jarIndex}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all duration-200 hover:shadow-md',
|
||||
isSelected ? 'border-primary bg-primary/5 shadow-sm' : 'hover:bg-muted/30',
|
||||
)}
|
||||
onClick={() => setSelectedJarIndex(jar.jarIndex)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="h-10 w-10 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: jar.color, opacity: 0.8 }}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold">{jar.name}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('earn.fidelity_bond.select_jar.text_available_utxos', {
|
||||
count: jar.utxos.filter((u) => !u.frozen && !fb.utxo.isFidelityBond(u)).length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-mono font-semibold">
|
||||
{formatSats(jar.balanceSummary.calculatedAvailableBalanceInSats)}
|
||||
</p>
|
||||
{isSelected && <CheckCircle2Icon className="text-primary mt-1 ml-auto h-5 w-5" />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<FidelityBondJarSelector
|
||||
selectedJarIndex={selectedJarIndex}
|
||||
onSelect={setSelectedJarIndex}
|
||||
isJarDisabled={(jar) => !jarsWithUtxos.some((eligible) => eligible.jarIndex === jar.jarIndex)}
|
||||
/>
|
||||
{jarsWithUtxos.length === 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
|
|
@ -224,27 +107,11 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
case 'select_utxos':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<CoinsIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{t('earn.fidelity_bond.select_utxos.title')}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t('earn.fidelity_bond.select_utxos.description', { jar: selectedJarIndex })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={selectAllUtxos} className="shadow-sm">
|
||||
<CheckIcon className="mr-1 h-3.5 w-3.5" />
|
||||
{t('earn.fidelity_bond.select_utxos.button_select_all')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={deselectAllUtxos} className="shadow-sm">
|
||||
{t('earn.fidelity_bond.select_utxos.button_deselect_all')}
|
||||
</Button>
|
||||
</div>
|
||||
<StepIntro
|
||||
icon={CoinsIcon}
|
||||
title={t('earn.fidelity_bond.select_utxos.title')}
|
||||
subtitle={t('earn.fidelity_bond.select_utxos.description', { jar: selectedJar?.name ?? selectedJarIndex })}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
|
|
@ -269,7 +136,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition-all',
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all',
|
||||
isSelected
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/40',
|
||||
|
|
@ -286,9 +153,10 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="font-mono text-sm font-semibold whitespace-nowrap">
|
||||
{formatSats(utxo.value)}
|
||||
</p>
|
||||
<FidelityBondAmount
|
||||
value={utxo.value}
|
||||
className="text-sm font-semibold whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -324,9 +192,11 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
|
||||
{selectedUtxos.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
|
||||
<span className="font-medium">{t('earn.fidelity_bond.select_utxos.label_total_selected')}</span>
|
||||
<span className="font-mono text-lg font-bold">{formatSats(totalAmount)}</span>
|
||||
<div className="bg-muted/50 flex flex-wrap items-center justify-between gap-2 rounded-lg p-3">
|
||||
<span className="min-w-0 font-medium break-words">
|
||||
{t('earn.fidelity_bond.select_utxos.label_total_selected')}
|
||||
</span>
|
||||
<FidelityBondAmount value={totalAmount} className="shrink-0 text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -350,18 +220,14 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
case 'freeze_utxos':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<LockIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t('earn.fidelity_bond.freeze_utxos.title')}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{t('earn.fidelity_bond.freeze_utxos.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StepIntro
|
||||
icon={LockIcon}
|
||||
title={t('earn.fidelity_bond.freeze_utxos.title')}
|
||||
subtitle={t('earn.fidelity_bond.freeze_utxos.subtitle')}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-green-500/20 bg-green-500/10 p-4">
|
||||
<div className="border-brand-success/20 bg-brand-success/10 rounded-lg border p-4">
|
||||
<p className="mb-3 text-sm font-medium">
|
||||
{t('earn.fidelity_bond.freeze_utxos.label_selected_utxos', { count: selectedUtxos.length })}
|
||||
</p>
|
||||
|
|
@ -369,14 +235,14 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
{selectedUtxos.map((utxo) => (
|
||||
<div key={utxo.utxo} className="flex items-center justify-between py-1 text-sm">
|
||||
<span className="mr-2 flex-1 truncate font-mono text-xs">{utxo.utxo.slice(0, 24)}...</span>
|
||||
<span className="font-mono font-semibold">{formatSats(utxo.value)}</span>
|
||||
<FidelityBondAmount value={utxo.value} className="text-sm font-semibold" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{utxosToFreeze.length > 0 && (
|
||||
<div className="rounded-lg border border-orange-500/20 bg-orange-500/10 p-4">
|
||||
<div className="border-brand-warning/20 bg-brand-warning/10 rounded-lg border p-4">
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t('earn.fidelity_bond.freeze_utxos.label_utxos_to_freeze', { count: utxosToFreeze.length })}
|
||||
</p>
|
||||
|
|
@ -389,7 +255,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
{utxosToFreeze.map((utxo) => (
|
||||
<div key={utxo.utxo} className="flex items-center justify-between py-1 text-sm">
|
||||
<span className="mr-2 flex-1 truncate font-mono text-xs">{utxo.utxo.slice(0, 24)}...</span>
|
||||
<span className="font-mono font-semibold">{formatSats(utxo.value)}</span>
|
||||
<FidelityBondAmount value={utxo.value} className="text-sm font-semibold" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -403,57 +269,24 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_lock_date')}>
|
||||
<p className="font-semibold">{selectedDateLabel}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">{t('earn.fidelity_bond.review_inputs.label_jar')}</p>
|
||||
<p className="font-semibold">
|
||||
{t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: selectedJarIndex })}
|
||||
</p>
|
||||
</div>
|
||||
</InfoCard>
|
||||
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_jar')}>
|
||||
<JarBadge jarIndex={selectedJarIndex} name={selectedJar?.name} />
|
||||
</InfoCard>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">{t('earn.fidelity_bond.review_inputs.label_amount')}</p>
|
||||
<p className="font-mono text-2xl font-bold">{formatSats(totalAmount)}</p>
|
||||
</div>
|
||||
<InfoCard highlight label={t('earn.fidelity_bond.review_inputs.label_amount')}>
|
||||
<FidelityBondAmount value={totalAmount} />
|
||||
</InfoCard>
|
||||
</div>
|
||||
|
||||
{timelockAddressQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<Loader2Icon className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground mt-3">{t('earn.fidelity_bond.text_loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<InlineLoading text={t('earn.fidelity_bond.text_loading')} />
|
||||
) : (
|
||||
address && (
|
||||
<div className="space-y-4">
|
||||
<div className="dark:bg-muted/30 flex justify-center rounded-lg bg-white p-4">
|
||||
<BitcoinAddressQrCode address={address} amount={totalAmount} width={200} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{t('earn.fidelity_bond.review_inputs.label_address')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">{address}</code>
|
||||
<CopyButton
|
||||
key="copy-address-review"
|
||||
value={address}
|
||||
text={<CopyIcon className="h-4 w-4" />}
|
||||
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
|
||||
onSuccess={() => toast.success(t('receive.text_copy_address'))}
|
||||
onError={() => toast.error(t('receive.error_copy_address_failed'))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
address && <AddressPreview label={t('earn.fidelity_bond.review_inputs.label_address')} address={address} />
|
||||
)}
|
||||
|
||||
<Alert variant="warning">
|
||||
|
|
@ -467,105 +300,50 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="bg-muted/50 flex items-start gap-3 rounded-lg p-4">
|
||||
<Switch
|
||||
id="confirmation"
|
||||
checked={confirmationChecked}
|
||||
onCheckedChange={(checked) => setConfirmationChecked(checked)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label htmlFor="confirmation" className="cursor-pointer text-sm font-medium">
|
||||
{t('earn.fidelity_bond.create_form.confirmation_toggle_title')}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('earn.fidelity_bond.create_form.confirmation_toggle_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmationToggle
|
||||
id="confirmation"
|
||||
checked={confirmationChecked}
|
||||
onCheckedChange={setConfirmationChecked}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'creating':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="relative">
|
||||
<Spinner className="h-16 w-16" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LockIcon className="text-primary h-6 w-6 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-6 text-lg font-semibold">{t('earn.fidelity_bond.text_creating')}</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">{t('earn.fidelity_bond.text_creating_subtitle')}</p>
|
||||
</div>
|
||||
<PendingStep
|
||||
icon={LockIcon}
|
||||
title={t('earn.fidelity_bond.text_creating')}
|
||||
subtitle={t('earn.fidelity_bond.text_creating_subtitle')}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<div className="rounded-full bg-green-500/10 p-4">
|
||||
<CheckCircle2Icon className="h-16 w-16 text-green-500" />
|
||||
</div>
|
||||
<p className="mt-4 text-xl font-bold">{t('earn.fidelity_bond.create_fidelity_bond.success_text')}</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{t('earn.fidelity_bond.create_fidelity_bond.text_success_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<SuccessHeading
|
||||
title={t('earn.fidelity_bond.create_fidelity_bond.success_text')}
|
||||
subtitle={t('earn.fidelity_bond.create_fidelity_bond.text_success_subtitle')}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t('earn.fidelity_bond.create_fidelity_bond.label_lock_date')}
|
||||
</p>
|
||||
<InfoCard label={t('earn.fidelity_bond.create_fidelity_bond.label_lock_date')}>
|
||||
<p className="font-semibold">{selectedDateLabel}</p>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
{address && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.create_fidelity_bond.label_address')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">{address}</code>
|
||||
<CopyButton
|
||||
key="copy-address-success"
|
||||
value={address}
|
||||
text={<CopyIcon className="h-4 w-4" />}
|
||||
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
|
||||
onSuccess={() => toast.success(t('receive.text_copy_address'))}
|
||||
onError={() => toast.error(t('receive.error_copy_address_failed'))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CopyableField
|
||||
label={t('earn.fidelity_bond.create_fidelity_bond.label_address')}
|
||||
value={address}
|
||||
copiedMessage={t('receive.text_copy_address')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{txResult?.txinfo?.txid && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
|
||||
{txResult.txinfo.txid}
|
||||
</code>
|
||||
<CopyButton
|
||||
key="copy-txid"
|
||||
value={txResult.txinfo.txid}
|
||||
text={<CopyIcon className="h-4 w-4" />}
|
||||
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
|
||||
onSuccess={() =>
|
||||
toast.success(t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id'))
|
||||
}
|
||||
onError={(error) => {
|
||||
const reason =
|
||||
(error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown')
|
||||
toast.error(t('global.errors.error_copy_to_clipboard_failed', { reason }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CopyableField
|
||||
label={t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
|
||||
value={txResult.txinfo.txid}
|
||||
copiedMessage={t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
import * as yup from 'yup'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import { sourceJarField } from '@/lib/formValidation'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
|
||||
export type SourceValue = {
|
||||
|
|
@ -29,10 +30,8 @@ export const createFidelityBondFormSchema = (
|
|||
t: TFunction<'translation', undefined>,
|
||||
): yup.ObjectSchema<CreateFidelityBondFormValues> => {
|
||||
const requiredMessage = t('global.errors.reason_unknown')
|
||||
const invalidSourceJarFeedbackMessage = t('receive.feedback_invalid_source_jar', {
|
||||
/* TODO: i18n: provide dedicated fidelity-bond source-jar validation copy */
|
||||
defaultValue: 'Please select a source jar.',
|
||||
})
|
||||
/* TODO: i18n: provide dedicated fidelity-bond source-jar validation copy */
|
||||
const invalidSourceJarFeedbackMessage = t('receive.feedback_invalid_source_jar')
|
||||
const validLockdates = lockdateOptions.map((option) => option.value)
|
||||
|
||||
return yup
|
||||
|
|
@ -40,15 +39,7 @@ export const createFidelityBondFormSchema = (
|
|||
lockdate: yup.string<fb.Lockdate>().oneOf(validLockdates, requiredMessage).required(requiredMessage),
|
||||
source: yup
|
||||
.object({
|
||||
fromJar: yup
|
||||
.number<JarIndex>()
|
||||
.integer(invalidSourceJarFeedbackMessage)
|
||||
.required(invalidSourceJarFeedbackMessage)
|
||||
.test(
|
||||
'valid-source-jar-index-test',
|
||||
invalidSourceJarFeedbackMessage,
|
||||
(value) => typeof value === 'number' && jarIndexes.includes(value),
|
||||
),
|
||||
fromJar: sourceJarField(invalidSourceJarFeedbackMessage, (jarIndex) => jarIndexes.includes(jarIndex)),
|
||||
})
|
||||
.required(),
|
||||
utxoIds: yup.array().of(yup.string().required(requiredMessage)).min(1, requiredMessage).required(requiredMessage),
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
type StepProgressProps = {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
}
|
||||
|
||||
export function StepProgress({ currentStep, totalSteps }: StepProgressProps) {
|
||||
return (
|
||||
<div className="mb-6 flex items-center justify-center gap-2">
|
||||
{Array.from({ length: totalSteps }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all duration-300',
|
||||
i < currentStep ? 'bg-primary w-8' : i === currentStep ? 'bg-primary w-12' : 'bg-muted w-8',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/components/earn/CreateFidelityBondDialog/types.test.ts
Normal file
23
src/components/earn/CreateFidelityBondDialog/types.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { generateLockdateOptions } from './types'
|
||||
|
||||
describe('generateLockdateOptions', () => {
|
||||
it.each([false, true])('generates well-formed, ordered YYYY-MM values (developer mode: %s)', (isDeveloperMode) => {
|
||||
const values = generateLockdateOptions(isDeveloperMode).map((option) => option.value)
|
||||
|
||||
for (const value of values) {
|
||||
expect(value).toMatch(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||
}
|
||||
// zero-padded YYYY-MM sorts lexicographically === chronologically
|
||||
expect(values.toSorted()).toEqual(values)
|
||||
})
|
||||
|
||||
it('includes the past 12 months in developer mode', () => {
|
||||
const values = generateLockdateOptions(true).map((option) => option.value)
|
||||
const now = new Date()
|
||||
const oneYearAgo = `${now.getUTCFullYear() - 1}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
expect(values[0]).toBe(oneYearAgo)
|
||||
expect(values).toHaveLength(133) // -12 .. +120 months
|
||||
})
|
||||
})
|
||||
|
|
@ -24,7 +24,8 @@ export function generateLockdateOptions(isDeveloperMode: boolean): { value: fb.L
|
|||
for (let i = startOffset; i <= 120; i++) {
|
||||
const monthOffset = startMonth + i
|
||||
const year = startYear + Math.floor(monthOffset / 12)
|
||||
const month = (monthOffset % 12) + 1
|
||||
// floored modulo — monthOffset is negative for past months in dev mode
|
||||
const month = (((monthOffset % 12) + 12) % 12) + 1
|
||||
const lockdate = `${year}-${month.toString().padStart(2, '0')}` as fb.Lockdate
|
||||
const date = new Date(Date.UTC(year, month - 1, 1))
|
||||
const label = date.toLocaleDateString(undefined, { year: 'numeric', month: 'long' })
|
||||
|
|
|
|||
|
|
@ -2,22 +2,31 @@ import { act, renderHook, waitFor } from '@testing-library/react'
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Jar } from '@/context/JamWalletInfoContext'
|
||||
import type { Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { BALANCE_SUMMARY_EMPTY } from '@/lib/balanceSummary'
|
||||
import { generateLockdateOptions } from './types'
|
||||
import { useCreateFidelityBondWizard } from './useCreateFidelityBondWizard'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
directSendMutateAsync: vi.fn(),
|
||||
freezeMutateAsync: vi.fn(),
|
||||
walletInfoRefetch: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
queryAddress: undefined as string | undefined,
|
||||
walletInfo: {
|
||||
jars: [] as Jar[],
|
||||
fidelityBondSummary: { fbOutputs: [] as Utxo[] },
|
||||
refetch: vi.fn(),
|
||||
},
|
||||
}))
|
||||
const mocks = vi.hoisted(() => {
|
||||
const addressSummary: Record<string, { status: string }> = {
|
||||
bcrt1qsource: { status: 'cj-out' },
|
||||
}
|
||||
return {
|
||||
directSendMutateAsync: vi.fn(),
|
||||
freezeMutateAsync: vi.fn(),
|
||||
walletInfoRefetch: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
queryAddress: undefined as string | undefined,
|
||||
isDeveloperMode: false,
|
||||
walletInfo: {
|
||||
jars: [] as Jar[],
|
||||
fidelityBondSummary: { fbOutputs: [] as Utxo[] },
|
||||
addressSummary,
|
||||
refetch: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
directsendMutation: vi.fn(() => ({ mutationKey: ['directsend'] })),
|
||||
freezeMutation: vi.fn(() => ({ mutationKey: ['freeze'] })),
|
||||
gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['timelock-address'] })),
|
||||
|
|
@ -58,7 +67,7 @@ vi.mock('@/hooks/useApiClient', () => ({
|
|||
|
||||
vi.mock('@/store/jamSettingsStore', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/store/jamSettingsStore')>()),
|
||||
useDeveloperMode: () => ({ enabled: false }),
|
||||
useDeveloperMode: () => ({ enabled: mocks.isDeveloperMode }),
|
||||
}))
|
||||
|
||||
const utxo = (overrides: Partial<Utxo>): Utxo => ({
|
||||
|
|
@ -82,12 +91,12 @@ const jar = (jarIndex: number, utxos: Utxo[]): Jar =>
|
|||
jarIndex,
|
||||
name: `Jar ${jarIndex}`,
|
||||
color: '#808080',
|
||||
balanceSummary: {},
|
||||
balanceSummary: BALANCE_SUMMARY_EMPTY,
|
||||
utxos,
|
||||
}) as unknown as Jar
|
||||
|
||||
const lockdateFromWizard = (wizard: ReturnType<typeof useCreateFidelityBondWizard>) => {
|
||||
const lockdate = wizard.clampLockdate('1900-01')
|
||||
const minLockdateOption = (isDeveloperMode = false) => {
|
||||
const lockdate = generateLockdateOptions(isDeveloperMode).at(0)?.value
|
||||
if (!lockdate) throw new Error('Expected a generated lockdate option')
|
||||
return lockdate
|
||||
}
|
||||
|
|
@ -100,7 +109,11 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
mocks.walletInfoRefetch.mockResolvedValue(undefined)
|
||||
mocks.walletInfo.refetch = mocks.walletInfoRefetch
|
||||
mocks.queryAddress = 'bcrt1qtimelockdestination'
|
||||
mocks.isDeveloperMode = false
|
||||
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [] }
|
||||
mocks.walletInfo.addressSummary = {
|
||||
bcrt1qsource: { status: 'cj-out' },
|
||||
}
|
||||
mocks.walletInfo.jars = [
|
||||
jar(0, [
|
||||
utxo({ utxo: 'small:0', value: 10_000 }),
|
||||
|
|
@ -117,26 +130,26 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
]
|
||||
})
|
||||
|
||||
it('derives selectable utxos and selection totals from wallet state', async () => {
|
||||
it('derives selectable utxos and keeps selection to one utxo', async () => {
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
|
||||
act(() => result.current.setSelectedJarIndex(0))
|
||||
|
||||
expect(result.current.availableUtxos.map((entry) => entry.utxo)).toEqual(['large:0', 'small:0'])
|
||||
|
||||
act(() => result.current.selectAllUtxos())
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
|
||||
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0', 'small:0'])
|
||||
expect(result.current.totalAmount).toBe(60_000)
|
||||
expect(result.current.utxosToFreeze.map((entry) => entry.utxo)).toEqual([])
|
||||
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0'])
|
||||
expect(result.current.totalAmount).toBe(50_000)
|
||||
expect(result.current.utxosToFreeze.map((entry) => entry.utxo)).toEqual(['small:0'])
|
||||
expect(result.current.isUsingAllFunds).toBe(false)
|
||||
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[1]))
|
||||
|
||||
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['small:0'])
|
||||
expect(result.current.utxosToFreeze.map((entry) => entry.utxo)).toEqual(['large:0'])
|
||||
|
||||
act(() => result.current.deselectAllUtxos())
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[1]))
|
||||
|
||||
await waitFor(() => expect(result.current.selectedUtxos).toHaveLength(0))
|
||||
})
|
||||
|
|
@ -144,7 +157,7 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
it('skips jar selection when only one jar has eligible utxos', async () => {
|
||||
mocks.walletInfo.jars = [jar(0, [utxo({ utxo: 'only:0', value: 25_000 })])]
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
const lockdate = lockdateFromWizard(result.current)
|
||||
const lockdate = minLockdateOption()
|
||||
|
||||
act(() => result.current.setSelectedLockdate(lockdate))
|
||||
await act(async () => result.current.handleNext())
|
||||
|
|
@ -152,7 +165,7 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
expect(result.current.step).toBe('select_utxos')
|
||||
expect(result.current.selectedJarIndex).toBe(0)
|
||||
|
||||
act(() => result.current.selectAllUtxos())
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
await act(async () => result.current.handleNext())
|
||||
|
||||
expect(result.current.step).toBe('review')
|
||||
|
|
@ -176,7 +189,7 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
|
||||
it('freezes unselected utxos before review and supports back navigation', async () => {
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
const lockdate = lockdateFromWizard(result.current)
|
||||
const lockdate = minLockdateOption()
|
||||
|
||||
act(() => result.current.setSelectedLockdate(lockdate))
|
||||
await act(async () => result.current.handleNext())
|
||||
|
|
@ -211,7 +224,11 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
act(() => {
|
||||
result.current.setStep('success')
|
||||
result.current.setSelectedJarIndex(0)
|
||||
result.current.selectAllUtxos()
|
||||
})
|
||||
act(() => {
|
||||
result.current.toggleUtxoSelection(result.current.availableUtxos[0])
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleOpenChange(false)
|
||||
})
|
||||
|
||||
|
|
@ -235,40 +252,8 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
expect(result.current.step).toBe('review')
|
||||
})
|
||||
|
||||
it('exposes lockdate boundaries and clamps out-of-range values', () => {
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
|
||||
const { minYear, minMonth, yearOptions, monthOptions } = result.current
|
||||
expect(minYear).toBeGreaterThan(0)
|
||||
expect(minMonth).toBeGreaterThanOrEqual(1)
|
||||
expect(yearOptions.length).toBeGreaterThan(0)
|
||||
expect(monthOptions.length).toBe(12)
|
||||
|
||||
const min = result.current.clampLockdate('1900-01')
|
||||
const max = result.current.clampLockdate('9999-12')
|
||||
expect(min).not.toBe('')
|
||||
expect(max).not.toBe('')
|
||||
expect(min < max).toBe(true)
|
||||
|
||||
// empty input clamps to min
|
||||
expect(result.current.clampLockdate('')).toBe(min)
|
||||
// a value already inside the range is returned untouched
|
||||
expect(result.current.clampLockdate(min)).toBe(min)
|
||||
|
||||
// derived year/month default to empty before a selection
|
||||
expect(result.current.selectedYear).toBe('')
|
||||
expect(result.current.selectedMonth).toBe('')
|
||||
|
||||
act(() => result.current.setSelectedLockdate(min))
|
||||
|
||||
expect(result.current.selectedYear).toBe(min.slice(0, 4))
|
||||
expect(result.current.selectedMonth).toBe(min.slice(5, 7))
|
||||
expect(result.current.selectedDateLabel).not.toBeNull()
|
||||
})
|
||||
|
||||
it('flags duplicate lockdates against existing fidelity bonds', () => {
|
||||
const { result: probe } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
const lockdate = lockdateFromWizard(probe.current)
|
||||
const lockdate = minLockdateOption()
|
||||
const timestampSeconds = Math.floor(new Date(`${lockdate}-01T00:00:00Z`).getTime() / 1000)
|
||||
|
||||
mocks.walletInfo.fidelityBondSummary = {
|
||||
|
|
@ -287,6 +272,31 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
expect(result.current.hasDuplicateLockdate).toBeFalsy()
|
||||
act(() => result.current.setSelectedLockdate(lockdate))
|
||||
expect(result.current.hasDuplicateLockdate).toBe(true)
|
||||
expect(result.current.canProceed()).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks a lock date already used by an expired fidelity bond', () => {
|
||||
mocks.isDeveloperMode = true
|
||||
const expiredLockdate = minLockdateOption(true)
|
||||
const timestampSeconds = Math.floor(new Date(`${expiredLockdate}-01T00:00:00Z`).getTime() / 1000)
|
||||
|
||||
mocks.walletInfo.fidelityBondSummary = {
|
||||
fbOutputs: [
|
||||
utxo({
|
||||
utxo: 'expired-bond:0',
|
||||
value: 100_000,
|
||||
locktime: `${expiredLockdate}-01 00:00:00`,
|
||||
path: `m/84'/1'/0'/0/0:${timestampSeconds}`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
|
||||
expect(result.current.existingFbLockdates).toContain(expiredLockdate)
|
||||
act(() => result.current.setSelectedLockdate(expiredLockdate))
|
||||
expect(result.current.hasDuplicateLockdate).toBe(true)
|
||||
expect(result.current.canProceed()).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores existing fidelity bonds whose locktime cannot be derived', () => {
|
||||
|
|
@ -303,7 +313,7 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
}
|
||||
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
const lockdate = lockdateFromWizard(result.current)
|
||||
const lockdate = minLockdateOption()
|
||||
|
||||
act(() => result.current.setSelectedLockdate(lockdate))
|
||||
expect(result.current.hasDuplicateLockdate).toBeFalsy()
|
||||
|
|
@ -313,8 +323,8 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
|
||||
act(() => result.current.setSelectedJarIndex(0))
|
||||
act(() => result.current.selectAllUtxos())
|
||||
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0', 'small:0'])
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0'])
|
||||
|
||||
// switch to a jar that does not contain the previously selected ids
|
||||
act(() => result.current.setSelectedJarIndex(1))
|
||||
|
|
@ -322,9 +332,26 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
await waitFor(() => expect(result.current.selectedUtxos).toEqual([]))
|
||||
})
|
||||
|
||||
it('only exposes unfrozen cj-out utxos for fidelity-bond creation', () => {
|
||||
mocks.walletInfo.addressSummary = {
|
||||
bcrt1qcj: { status: 'cj-out' },
|
||||
bcrt1qdeposit: { status: 'deposit' },
|
||||
}
|
||||
mocks.walletInfo.jars = [
|
||||
jar(0, [utxo({ utxo: 'cj:0', address: 'bcrt1qcj' }), utxo({ utxo: 'deposit:0', address: 'bcrt1qdeposit' })]),
|
||||
jar(1, [utxo({ utxo: 'deposit-only:0', address: 'bcrt1qdeposit', mixdepth: 1 })]),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
|
||||
expect(result.current.jarsWithUtxos.map((entry) => entry.jarIndex)).toEqual([0])
|
||||
act(() => result.current.setSelectedJarIndex(0))
|
||||
expect(result.current.availableUtxos.map((entry) => entry.utxo)).toEqual(['cj:0'])
|
||||
})
|
||||
|
||||
it('reflects canProceed gating for each step', () => {
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
const lockdate = lockdateFromWizard(result.current)
|
||||
const lockdate = minLockdateOption()
|
||||
|
||||
// select_date
|
||||
expect(result.current.canProceed()).toBe(false)
|
||||
|
|
@ -340,7 +367,7 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
// select_utxos
|
||||
act(() => result.current.setStep('select_utxos'))
|
||||
expect(result.current.canProceed()).toBe(false)
|
||||
act(() => result.current.selectAllUtxos())
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
expect(result.current.canProceed()).toBe(true)
|
||||
|
||||
// freeze_utxos always proceeds
|
||||
|
|
@ -419,19 +446,16 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
expect(result.current.step).toBe('select_utxos')
|
||||
})
|
||||
|
||||
it('skips freezing and moves straight to review when all utxos are selected', async () => {
|
||||
it('skips freezing and moves straight to review when the selected utxo is the only one in the jar', async () => {
|
||||
mocks.walletInfo.jars = [jar(0, [utxo({ utxo: 'only:0', value: 25_000 })])]
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
const lockdate = lockdateFromWizard(result.current)
|
||||
const lockdate = minLockdateOption()
|
||||
|
||||
act(() => result.current.setSelectedLockdate(lockdate))
|
||||
await act(async () => result.current.handleNext())
|
||||
expect(result.current.step).toBe('select_jar')
|
||||
|
||||
act(() => result.current.setSelectedJarIndex(0))
|
||||
await act(async () => result.current.handleNext())
|
||||
expect(result.current.step).toBe('select_utxos')
|
||||
|
||||
act(() => result.current.selectAllUtxos())
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
await act(async () => result.current.handleNext())
|
||||
|
||||
expect(result.current.step).toBe('review')
|
||||
|
|
@ -443,7 +467,7 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
|
||||
act(() => result.current.setSelectedJarIndex(0))
|
||||
act(() => result.current.selectAllUtxos())
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
act(() => result.current.setStep('review'))
|
||||
// confirmation not accepted -> trigger() fails the whole form
|
||||
await act(async () => result.current.handleNext())
|
||||
|
|
@ -467,7 +491,7 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
|
||||
act(() => result.current.setSelectedJarIndex(0))
|
||||
act(() => result.current.selectAllUtxos())
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
act(() => result.current.setStep('select_utxos'))
|
||||
act(() => result.current.handleBack())
|
||||
|
||||
|
|
@ -481,7 +505,7 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
|
||||
act(() => result.current.setSelectedJarIndex(0))
|
||||
act(() => result.current.selectAllUtxos())
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
act(() => result.current.setStep('select_utxos'))
|
||||
act(() => result.current.handleBack())
|
||||
|
||||
|
|
@ -500,10 +524,11 @@ describe('useCreateFidelityBondWizard', () => {
|
|||
})
|
||||
|
||||
it('returns from review to select_utxos when nothing was frozen', () => {
|
||||
mocks.walletInfo.jars = [jar(0, [utxo({ utxo: 'only:0', value: 25_000 })])]
|
||||
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
|
||||
|
||||
act(() => result.current.setSelectedJarIndex(0))
|
||||
act(() => result.current.selectAllUtxos())
|
||||
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
|
||||
act(() => result.current.setStep('review'))
|
||||
expect(result.current.utxosToFreeze).toEqual([])
|
||||
act(() => result.current.handleBack())
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import {
|
||||
directsendMutation,
|
||||
freezeMutation,
|
||||
gettimelockaddressOptions,
|
||||
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
|
||||
import type { DirectSendResponse, ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { gettimelockaddressOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
|
||||
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY,
|
||||
} from '@/constants/jam'
|
||||
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import type { Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { getErrorReason } from '@/lib/errorReason'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import type { WalletFileName } from '@/lib/utils'
|
||||
import { delayedPromise, type WalletFileName } from '@/lib/utils'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
import { useFidelityBondMutations } from '../fidelity-bond/useFidelityBondMutations'
|
||||
import {
|
||||
CREATE_FIDELITY_BOND_FORM_DEFAULT_VALUES,
|
||||
createFidelityBondFormSchema,
|
||||
type CreateFidelityBondFormValues,
|
||||
} from './CreateFidelityBondFormSchema'
|
||||
import type { Step } from './types'
|
||||
import { generateLockdateOptions, getYearOptions, getMonthOptions } from './types'
|
||||
import { generateLockdateOptions } from './types'
|
||||
|
||||
export function useCreateFidelityBondWizard(
|
||||
open: boolean,
|
||||
|
|
@ -40,13 +40,29 @@ export function useCreateFidelityBondWizard(
|
|||
const [frozenUtxos, setFrozenUtxos] = useState<Utxo[]>([])
|
||||
const [utxoPage, setUtxoPage] = useState(0)
|
||||
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
|
||||
const { freezeUtxo, unfreezeUtxo, directSend, error, setError } = useFidelityBondMutations({
|
||||
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
|
||||
sendErrorKey: 'earn.fidelity_bond.error_creating_fidelity_bond',
|
||||
})
|
||||
|
||||
const lockdateOptions = useMemo(() => generateLockdateOptions(isDeveloperMode), [isDeveloperMode])
|
||||
const existingFbLockdates = useMemo(() => {
|
||||
return walletInfo.fidelityBondSummary.fbOutputs
|
||||
.map((fbUtxo) => {
|
||||
const locktime = fb.utxo.getLocktime(fbUtxo)
|
||||
return locktime ? fb.lockdate.fromTimestamp(locktime) : null
|
||||
})
|
||||
.filter(Boolean) as fb.Lockdate[]
|
||||
}, [walletInfo.fidelityBondSummary.fbOutputs])
|
||||
const availableLockdateOptions = useMemo(
|
||||
() => lockdateOptions.filter((option) => !existingFbLockdates.includes(option.value)),
|
||||
[existingFbLockdates, lockdateOptions],
|
||||
)
|
||||
const jarIndexes = useMemo(() => walletInfo.jars.map((jar) => jar.jarIndex), [walletInfo.jars])
|
||||
const formSchema = useMemo(
|
||||
() => createFidelityBondFormSchema(lockdateOptions, jarIndexes, t),
|
||||
[lockdateOptions, jarIndexes, t],
|
||||
() => createFidelityBondFormSchema(availableLockdateOptions, jarIndexes, t),
|
||||
[availableLockdateOptions, jarIndexes, t],
|
||||
)
|
||||
const form = useForm<CreateFidelityBondFormValues, unknown, CreateFidelityBondFormValues>({
|
||||
mode: 'onChange',
|
||||
|
|
@ -55,7 +71,7 @@ export function useCreateFidelityBondWizard(
|
|||
})
|
||||
|
||||
const watchedLockdate = useWatch({ control: form.control, name: 'lockdate' })
|
||||
const selectedLockdate = watchedLockdate ?? ''
|
||||
const selectedLockdate: fb.Lockdate | '' = watchedLockdate ?? ''
|
||||
const selectedJarIndex = useWatch({ control: form.control, name: 'source.fromJar' })
|
||||
const selectedUtxoIds = useWatch({
|
||||
control: form.control,
|
||||
|
|
@ -68,20 +84,6 @@ export function useCreateFidelityBondWizard(
|
|||
defaultValue: CREATE_FIDELITY_BOND_FORM_DEFAULT_VALUES.confirmationAccepted,
|
||||
})
|
||||
|
||||
const yearOptions = useMemo(() => getYearOptions(lockdateOptions), [lockdateOptions])
|
||||
const monthOptions = useMemo(() => getMonthOptions(), [])
|
||||
|
||||
const minLockdate = lockdateOptions.at(0)?.value ?? ''
|
||||
const maxLockdate = lockdateOptions.at(-1)?.value ?? ''
|
||||
const clampLockdate = (lockdate: string): fb.Lockdate | '' => {
|
||||
if (!lockdate || lockdate < minLockdate) return minLockdate || ''
|
||||
if (lockdate > maxLockdate) return maxLockdate || ''
|
||||
return lockdate as fb.Lockdate
|
||||
}
|
||||
const selectedYear = selectedLockdate ? selectedLockdate.slice(0, 4) : ''
|
||||
const selectedMonth = selectedLockdate ? selectedLockdate.slice(5, 7) : ''
|
||||
const minYear = minLockdate ? Number.parseInt(minLockdate.slice(0, 4), 10) : 0
|
||||
const minMonth = minLockdate ? Number.parseInt(minLockdate.slice(5, 7), 10) : 1
|
||||
const setSelectedLockdate = (lockdate: fb.Lockdate | '') => {
|
||||
form.setValue('lockdate', lockdate || undefined, { shouldDirty: true, shouldTouch: true, shouldValidate: true })
|
||||
}
|
||||
|
|
@ -99,16 +101,6 @@ export function useCreateFidelityBondWizard(
|
|||
})
|
||||
}
|
||||
|
||||
const existingFbLockdates = useMemo(() => {
|
||||
return walletInfo.fidelityBondSummary.fbOutputs
|
||||
.filter((fbUtxo) => fb.utxo.isLocked(fbUtxo))
|
||||
.map((fbUtxo) => {
|
||||
const locktime = fb.utxo.getLocktime(fbUtxo)
|
||||
return locktime ? fb.lockdate.fromTimestamp(locktime) : null
|
||||
})
|
||||
.filter(Boolean) as fb.Lockdate[]
|
||||
}, [walletInfo.fidelityBondSummary.fbOutputs])
|
||||
|
||||
const hasDuplicateLockdate = selectedLockdate && existingFbLockdates.includes(selectedLockdate)
|
||||
|
||||
const selectedJar = useMemo(() => {
|
||||
|
|
@ -118,21 +110,19 @@ export function useCreateFidelityBondWizard(
|
|||
|
||||
const jarsWithUtxos = useMemo(() => {
|
||||
return walletInfo.jars.filter((jar) => {
|
||||
// TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction
|
||||
const availableUtxos = jar.utxos.filter((utxo) => !utxo.frozen && !fb.utxo.isFidelityBond(utxo))
|
||||
const availableUtxos = jar.utxos.filter((utxo) =>
|
||||
fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status),
|
||||
)
|
||||
return availableUtxos.length > 0
|
||||
})
|
||||
}, [walletInfo.jars])
|
||||
}, [walletInfo.addressSummary, walletInfo.jars])
|
||||
|
||||
const availableUtxos = useMemo(() => {
|
||||
if (selectedJar === undefined) return []
|
||||
return (
|
||||
selectedJar.utxos
|
||||
// TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction
|
||||
.filter((utxo) => !utxo.frozen && !fb.utxo.isFidelityBond(utxo))
|
||||
.toSorted((a, b) => b.value - a.value)
|
||||
)
|
||||
}, [selectedJar])
|
||||
return selectedJar.utxos
|
||||
.filter((utxo) => fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status))
|
||||
.toSorted((a, b) => b.value - a.value)
|
||||
}, [selectedJar, walletInfo.addressSummary])
|
||||
|
||||
const selectedUtxos = useMemo(() => {
|
||||
const availableUtxosById = new Map<string, Utxo>(availableUtxos.map((utxo) => [utxo.utxo, utxo]))
|
||||
|
|
@ -171,30 +161,6 @@ export function useCreateFidelityBondWizard(
|
|||
|
||||
const address = timelockAddressQuery.data?.address
|
||||
|
||||
const freezeUtxo = useMutation({
|
||||
...freezeMutation({ client }),
|
||||
onError: (error: ErrorMessage) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
setError(`${t('earn.fidelity_bond.error_freezing_utxos')} ${reason}`)
|
||||
},
|
||||
})
|
||||
|
||||
const unfreezeUtxo = useMutation({
|
||||
...freezeMutation({ client }),
|
||||
onError: (error: ErrorMessage) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
setError(`${t('earn.fidelity_bond.error_unfreezing_utxos')} ${reason}`)
|
||||
},
|
||||
})
|
||||
|
||||
const directSend = useMutation({
|
||||
...directsendMutation({ client }),
|
||||
onError: (error: ErrorMessage) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
setError(`${t('earn.fidelity_bond.error_creating_fidelity_bond')} ${reason}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleReset = () => {
|
||||
setStep('select_date')
|
||||
form.reset(CREATE_FIDELITY_BOND_FORM_DEFAULT_VALUES)
|
||||
|
|
@ -311,17 +277,15 @@ export function useCreateFidelityBondWizard(
|
|||
setStep('creating')
|
||||
|
||||
try {
|
||||
// TODO: refactor: use the sweep functionality from `useFidelityBondSweep` here
|
||||
const result = await directSend.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: {
|
||||
mixdepth: selectedJar.jarIndex,
|
||||
amount_sats: 0,
|
||||
amount_sats: 0, // 0 := sweep!
|
||||
destination: address,
|
||||
},
|
||||
})
|
||||
setTxResult(result)
|
||||
setStep('success')
|
||||
toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text'))
|
||||
|
||||
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
|
||||
for (const utxo of frozenUtxos) {
|
||||
|
|
@ -329,12 +293,33 @@ export function useCreateFidelityBondWizard(
|
|||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: false },
|
||||
throwOnError: true,
|
||||
})
|
||||
} catch {
|
||||
// logged via onError
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
// only debug output
|
||||
console.debug('Error while unfreezing previously frozen UTXO.')
|
||||
}
|
||||
}
|
||||
|
||||
// freeze resulting fidelity bond output
|
||||
if (JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS) {
|
||||
try {
|
||||
await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY)
|
||||
const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output
|
||||
await freezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': fbUtxoId, freeze: true },
|
||||
throwOnError: true,
|
||||
})
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
console.warn('Error while freezing Fidelity Bond UTXO - continuing.')
|
||||
}
|
||||
}
|
||||
|
||||
setTxResult(result)
|
||||
setStep('success')
|
||||
toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text'))
|
||||
|
||||
await walletInfo.refetch()
|
||||
} catch {
|
||||
// Best-effort rollback — unfreeze UTXOs that were frozen for this operation
|
||||
|
|
@ -344,8 +329,9 @@ export function useCreateFidelityBondWizard(
|
|||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: false },
|
||||
})
|
||||
} catch {
|
||||
// logged via onError
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
// only debug output
|
||||
console.debug('Error while unfreezing previously frozen UTXO in error handling.')
|
||||
}
|
||||
}
|
||||
setFrozenUtxos([])
|
||||
|
|
@ -370,28 +356,14 @@ export function useCreateFidelityBondWizard(
|
|||
}
|
||||
|
||||
const toggleUtxoSelection = (utxo: Utxo) => {
|
||||
// TODO: select/deselect all utxos to the same address
|
||||
const isSelected = selectedUtxoIds.includes(utxo.utxo)
|
||||
setSelectedUtxoIds(
|
||||
isSelected ? selectedUtxoIds.filter((utxoId) => utxoId !== utxo.utxo) : [...selectedUtxoIds, utxo.utxo],
|
||||
)
|
||||
setSelectedUtxoIds(isSelected ? [] : [utxo.utxo])
|
||||
}
|
||||
|
||||
const selectAllUtxos = () => setSelectedUtxoIds(availableUtxos.map((utxo) => utxo.utxo))
|
||||
const deselectAllUtxos = () => setSelectedUtxoIds([])
|
||||
|
||||
const selectedDateLabel = selectedLockdate
|
||||
? new Date(fb.lockdate.toTimestamp(selectedLockdate)).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: null
|
||||
|
||||
const canProceed = () => {
|
||||
switch (step) {
|
||||
case 'select_date':
|
||||
return !!selectedLockdate
|
||||
return !!selectedLockdate && !hasDuplicateLockdate
|
||||
case 'select_jar':
|
||||
return selectedJar !== undefined
|
||||
case 'select_utxos':
|
||||
|
|
@ -415,15 +387,8 @@ export function useCreateFidelityBondWizard(
|
|||
setStep,
|
||||
setSelectedLockdate,
|
||||
selectedLockdate,
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
minYear,
|
||||
minMonth,
|
||||
yearOptions,
|
||||
monthOptions,
|
||||
clampLockdate,
|
||||
hasDuplicateLockdate,
|
||||
selectedDateLabel,
|
||||
existingFbLockdates,
|
||||
selectedJarIndex,
|
||||
setSelectedJarIndex,
|
||||
jarsWithUtxos,
|
||||
|
|
@ -432,8 +397,6 @@ export function useCreateFidelityBondWizard(
|
|||
utxoPage,
|
||||
setUtxoPage,
|
||||
toggleUtxoSelection,
|
||||
selectAllUtxos,
|
||||
deselectAllUtxos,
|
||||
totalAmount,
|
||||
isUsingAllFunds,
|
||||
utxosToFreeze,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ vi.mock('react-i18next', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/CurrencySymbol', () => ({
|
||||
SatSymbol: (props: Record<string, unknown>) => <span {...props}>sat-symbol</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../dev/DevBadge', () => ({
|
||||
DevBadge: ({ className }: { className?: string }) => <span className={className}>dev-badge</span>,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { yupResolver } from '@hookform/resolvers/yup'
|
|||
import type { TFunction } from 'i18next'
|
||||
import { HandshakeIcon, PercentIcon } from 'lucide-react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import type { Resolver, SubmitHandler } from 'react-hook-form'
|
||||
import type { SubmitHandler } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as yup from 'yup'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -17,7 +17,7 @@ import { cn, factorToPercentage, isValidInteger, isValidNumber } from '@/lib/uti
|
|||
import type { AmountSats } from '@/types/global'
|
||||
import { DevBadge } from '../dev/DevBadge'
|
||||
import { Card, CardContent, CardHeader } from '../ui/card'
|
||||
import { Field, FieldDescription, FieldLabel } from '../ui/field'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '../ui/field'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
|
||||
|
|
@ -90,8 +90,8 @@ const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
|
|||
const id = useId()
|
||||
|
||||
return (
|
||||
<RadioGroup className="flex items-center justify-center" {...props}>
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
|
||||
<RadioGroup className="flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center" {...props}>
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
|
||||
<RadioGroupItem
|
||||
value={OFFERTYPE_ABS}
|
||||
id={`${id}-sw0absoffer`}
|
||||
|
|
@ -104,7 +104,7 @@ const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
|
|||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
|
||||
<RadioGroupItem
|
||||
value={OFFERTYPE_REL}
|
||||
id={`${id}-sw0reloffer`}
|
||||
|
|
@ -179,8 +179,7 @@ export function EarnForm({
|
|||
} = useForm<EarnFormValues, unknown, EarnFormValues>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: FORM_INPUT_DEFAULT_VALUES,
|
||||
// force type (see https://github.com/react-hook-form/resolvers/issues/807)
|
||||
resolver: yupResolver(schema) as Resolver<EarnFormValues, unknown, EarnFormValues>,
|
||||
resolver: yupResolver(schema),
|
||||
})
|
||||
|
||||
const values = useWatch({ control })
|
||||
|
|
@ -203,89 +202,81 @@ export function EarnForm({
|
|||
/>
|
||||
<Tabs value={watchOfferType}>
|
||||
<TabsContent value={OFFERTYPE_ABS}>
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.offerAbsoluteFee !== undefined}>
|
||||
<FieldLabel htmlFor="offerAbsoluteFee">
|
||||
{t('earn.label_abs_fee', {
|
||||
fee: '', // empty on purpose
|
||||
<Field data-invalid={errors.offerAbsoluteFee !== undefined}>
|
||||
<FieldLabel htmlFor="offerAbsoluteFee">
|
||||
{t('earn.label_abs_fee', {
|
||||
fee: '', // empty on purpose
|
||||
})}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">{t('earn.description_abs_fee')}</FieldDescription>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="offerAbsoluteFee"
|
||||
{...register('offerAbsoluteFee', {
|
||||
disabled,
|
||||
})}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">{t('earn.description_abs_fee')}</FieldDescription>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="offerAbsoluteFee"
|
||||
{...register('offerAbsoluteFee', {
|
||||
disabled,
|
||||
})}
|
||||
type="number"
|
||||
step={1}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">{FieldPrefixSatSymbol}</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{errors.offerAbsoluteFee?.message && (
|
||||
<div className="text-destructive text-xs">{errors.offerAbsoluteFee.message}</div>
|
||||
)}
|
||||
</div>
|
||||
type="number"
|
||||
step={1}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">{FieldPrefixSatSymbol}</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
{errors.offerAbsoluteFee?.message && <FieldError>{errors.offerAbsoluteFee.message}</FieldError>}
|
||||
</Field>
|
||||
</TabsContent>
|
||||
<TabsContent value={OFFERTYPE_REL}>
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.offerRelativeFeeInPercent !== undefined}>
|
||||
<FieldLabel htmlFor="offerRelativeFeeInPercent">
|
||||
{t('earn.label_rel_fee', {
|
||||
fee: getValues('offerRelativeFeeInPercent') ? `(${getValues('offerRelativeFeeInPercent')!}%)` : '',
|
||||
<Field data-invalid={errors.offerRelativeFeeInPercent !== undefined}>
|
||||
<FieldLabel htmlFor="offerRelativeFeeInPercent">
|
||||
{t('earn.label_rel_fee', {
|
||||
fee: getValues('offerRelativeFeeInPercent') ? `(${getValues('offerRelativeFeeInPercent')!}%)` : '',
|
||||
})}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">{t('earn.description_rel_fee')}</FieldDescription>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="offerRelativeFeeInPercent"
|
||||
{...register('offerRelativeFeeInPercent', {
|
||||
disabled,
|
||||
})}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">{t('earn.description_rel_fee')}</FieldDescription>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="offerRelativeFeeInPercent"
|
||||
{...register('offerRelativeFeeInPercent', {
|
||||
disabled,
|
||||
})}
|
||||
type="number"
|
||||
step={factorToPercentage(JAM.OFFER_FEE_REL_STEP)}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">%</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{errors.offerRelativeFeeInPercent && (
|
||||
<div className="text-destructive text-xs">{errors.offerRelativeFeeInPercent.message}</div>
|
||||
)}
|
||||
</div>
|
||||
type="number"
|
||||
step={factorToPercentage(JAM.OFFER_FEE_REL_STEP)}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">%</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{errors.offerRelativeFeeInPercent && <FieldError>{errors.offerRelativeFeeInPercent.message}</FieldError>}
|
||||
</Field>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.offerMinAmount !== undefined}>
|
||||
<FieldLabel htmlFor="offerMinAmount">{t('earn.label_min_amount_input')}</FieldLabel>
|
||||
<FieldDescription className="text-xs">{t('earn.description_min_amount_input')}</FieldDescription>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="offerMinAmount"
|
||||
{...register('offerMinAmount', {
|
||||
required: true,
|
||||
disabled,
|
||||
})}
|
||||
type="number"
|
||||
max={offerMinsizeMax}
|
||||
step={1}
|
||||
placeholder={t('earn.placeholder_min_amount_input')}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">{FieldPrefixSatSymbol}</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field data-invalid={errors.offerMinAmount !== undefined}>
|
||||
<FieldLabel htmlFor="offerMinAmount">{t('earn.label_min_amount_input')}</FieldLabel>
|
||||
<FieldDescription className="text-xs">{t('earn.description_min_amount_input')}</FieldDescription>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="offerMinAmount"
|
||||
{...register('offerMinAmount', {
|
||||
required: true,
|
||||
disabled,
|
||||
})}
|
||||
type="number"
|
||||
max={offerMinsizeMax}
|
||||
step={1}
|
||||
placeholder={t('earn.placeholder_min_amount_input')}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">{FieldPrefixSatSymbol}</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
{errors.offerMinAmount && (
|
||||
<div className="text-destructive text-xs">
|
||||
<FieldError>
|
||||
{offerMinsizeMax < JAM.OFFER_MINSIZE_MIN ? (
|
||||
<>{t('earn.feedback_invalid_min_amount_insufficient_funds')}</>
|
||||
) : (
|
||||
<>{errors.offerMinAmount?.message || t('earn.feedback_invalid_min_amount')}</>
|
||||
)}
|
||||
</div>
|
||||
</FieldError>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant={disabled && !isWaitingMakerStart ? 'outline' : undefined}
|
||||
|
|
@ -310,11 +301,11 @@ export function EarnForm({
|
|||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="overflow-scroll">
|
||||
<code className="light:text-red-700 text-red-800">isValid:</code>
|
||||
<code className="text-destructive">isValid:</code>
|
||||
<pre className="text-xs">{JSON.stringify(isValid, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="overflow-scroll">
|
||||
<code className="light:text-red-700 text-red-800">values:</code>
|
||||
<code className="text-destructive">values:</code>
|
||||
<pre className="text-xs">{JSON.stringify(values, null, 2)}</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import type React from 'react'
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import * as JAM from '@/constants/jam'
|
||||
import type { Jar } from '@/context/JamWalletInfoContext'
|
||||
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { jmSessionStore } from '@/store/jmSessionStore'
|
||||
import type { EarnFormValues } from './EarnForm'
|
||||
import { EarnPage } from './EarnPage'
|
||||
|
|
@ -10,6 +12,12 @@ import { EarnPage } from './EarnPage'
|
|||
const mocks = vi.hoisted(() => ({
|
||||
developerMode: false,
|
||||
feeConfigMissing: false,
|
||||
orderbookData: vi.fn<() => unknown>(),
|
||||
orderbookQueryOptions: vi.fn(),
|
||||
orderbookQueryState: {
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
},
|
||||
scrollToTop: vi.fn(),
|
||||
startMaker: vi.fn(),
|
||||
startMutationState: {
|
||||
|
|
@ -26,22 +34,17 @@ const mocks = vi.hoisted(() => ({
|
|||
toastInfo: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
walletInfo: {
|
||||
addressSummary: {},
|
||||
fidelityBondSummary: { fbOutputs: [] as FidelityBondUtxo[] },
|
||||
hasEligibleFidelityBondUtxo: true,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
jars: [] as Array<{
|
||||
balanceSummary: {
|
||||
calculatedAvailableBalanceInSats: number
|
||||
calculatedConfirmedAvailableBalanceInSats: number
|
||||
calculatedFrozenOrLockedBalanceInSats: number
|
||||
calculatedTotalBalanceInSats: number
|
||||
}
|
||||
}>,
|
||||
jars: [] as Jar[],
|
||||
maxJarAvailableBalance: 100_000_000,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
startmakerMutation: vi.fn(() => ({ mutationFn: mocks.startMaker })),
|
||||
stopmakerOptions: vi.fn(() => ({ queryKey: ['stopmaker'], queryFn: vi.fn() })),
|
||||
}))
|
||||
|
|
@ -76,15 +79,26 @@ vi.mock('@tanstack/react-query', () => ({
|
|||
reset: vi.fn(),
|
||||
}
|
||||
}),
|
||||
useQuery: vi.fn(() => ({
|
||||
refetch: mocks.stopMakerRefetch,
|
||||
})),
|
||||
useQuery: vi.fn(() => {
|
||||
return { refetch: mocks.stopMakerRefetch }
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useQueryOrderbook', () => ({
|
||||
useQueryOrderbook: (options: unknown) => {
|
||||
mocks.orderbookQueryOptions(options)
|
||||
return {
|
||||
queryResult: {
|
||||
data: mocks.orderbookData(),
|
||||
...mocks.orderbookQueryState,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
Trans: ({ children, i18nKey }: { children?: React.ReactNode; i18nKey?: string }) => (
|
||||
<span>{children ?? i18nKey}</span>
|
||||
),
|
||||
Trans: ({ i18nKey, values }: { i18nKey: string; values?: unknown }) =>
|
||||
values ? `${i18nKey}:${JSON.stringify(values)}` : i18nKey,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
|
||||
}),
|
||||
|
|
@ -117,14 +131,6 @@ vi.mock('@/components/ui/jam/PageLoading', () => ({
|
|||
PageLoading: () => <div>page-loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/PageTitle', () => ({
|
||||
default: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<h1>
|
||||
{title}:{subtitle}
|
||||
</h1>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/JamWalletInfoContext', () => ({
|
||||
useJamWalletInfoContext: () => mocks.walletInfo,
|
||||
}))
|
||||
|
|
@ -174,6 +180,7 @@ vi.mock('./EarnForm', () => ({
|
|||
<div>
|
||||
earn-form:{String(disabled)}:{String(debug)}
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
void onSubmit({
|
||||
offerMinAmount: 50_000,
|
||||
|
|
@ -202,9 +209,24 @@ vi.mock('./MoveToJarDialog', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('./OfferCard', () => ({
|
||||
OfferCard: ({ children, nickname }: { children?: React.ReactNode; nickname?: string }) => (
|
||||
OfferCard: ({
|
||||
children,
|
||||
nickname,
|
||||
orderbookStatus,
|
||||
orderbookOffer,
|
||||
fidelityBond,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
nickname?: string
|
||||
orderbookStatus?: string
|
||||
orderbookOffer?: { fidelity_bond_value?: number }
|
||||
fidelityBond?: { amount?: number }
|
||||
}) => (
|
||||
<div>
|
||||
offer-card:{nickname}
|
||||
<span>orderbook-status:{orderbookStatus}</span>
|
||||
<span>bond-value:{orderbookOffer?.fidelity_bond_value}</span>
|
||||
<span>bond-amount:{fidelityBond?.amount}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
|
@ -220,22 +242,49 @@ vi.mock('./report/EarnReportOverlay', () => ({
|
|||
|
||||
const balanceSummary = {
|
||||
calculatedAvailableBalanceInSats: 100_000_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedConfirmedAvailableBalanceInSats: 100_000_000,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
calculatedTotalBalanceInSats: 100_000_000,
|
||||
}
|
||||
|
||||
const expiredBond = {
|
||||
const eligibleUtxo: Utxo = {
|
||||
address: 'bcrt1qcj',
|
||||
confirmations: 12,
|
||||
external: false,
|
||||
frozen: false,
|
||||
label: '',
|
||||
locktime: undefined,
|
||||
mixdepth: 0,
|
||||
path: "m/84'/1'/0'/0/0",
|
||||
tries: 3,
|
||||
tries_remaining: 3,
|
||||
utxo: 'eligible:0',
|
||||
value: 100_000,
|
||||
}
|
||||
|
||||
const makeJar = (balanceOverrides = {}): Jar => ({
|
||||
balanceSummary: { ...balanceSummary, ...balanceOverrides },
|
||||
color: '#e2b86a',
|
||||
jarIndex: 0,
|
||||
name: 'Jar 0',
|
||||
utxos: [eligibleUtxo],
|
||||
})
|
||||
|
||||
const expiredBond: FidelityBondUtxo = {
|
||||
address: 'bc1qbond',
|
||||
confirmations: 12,
|
||||
frozen: false,
|
||||
label: '',
|
||||
locktime: '1970-01-01 00:00:00',
|
||||
locktime: '2000-01-01 00:00:00',
|
||||
path: "m/84'/1'/0':1",
|
||||
tries_remaining: 3,
|
||||
utxo: 'bond-tx:0',
|
||||
value: 50_000,
|
||||
} as unknown as FidelityBondUtxo
|
||||
tries: 3,
|
||||
external: false,
|
||||
mixdepth: 0,
|
||||
}
|
||||
|
||||
const setSession = (overrides: Record<string, unknown> = {}) => {
|
||||
jmSessionStore.setState({
|
||||
|
|
@ -256,6 +305,11 @@ describe('EarnPage', () => {
|
|||
beforeEach(() => {
|
||||
mocks.developerMode = false
|
||||
mocks.feeConfigMissing = false
|
||||
mocks.orderbookData.mockReset()
|
||||
mocks.orderbookData.mockReturnValue(undefined)
|
||||
mocks.orderbookQueryOptions.mockReset()
|
||||
mocks.orderbookQueryState.isError = false
|
||||
mocks.orderbookQueryState.isLoading = false
|
||||
mocks.scrollToTop.mockReset()
|
||||
mocks.startMaker.mockReset()
|
||||
mocks.startMaker.mockResolvedValue({})
|
||||
|
|
@ -269,10 +323,14 @@ describe('EarnPage', () => {
|
|||
mocks.toastError.mockReset()
|
||||
mocks.toastInfo.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.walletInfo.addressSummary = {
|
||||
[eligibleUtxo.address]: { status: 'cj-out' },
|
||||
}
|
||||
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [] }
|
||||
mocks.walletInfo.hasEligibleFidelityBondUtxo = true
|
||||
mocks.walletInfo.isFetching = false
|
||||
mocks.walletInfo.isLoading = false
|
||||
mocks.walletInfo.jars = [{ balanceSummary }]
|
||||
mocks.walletInfo.jars = [makeJar()]
|
||||
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
|
||||
setSession()
|
||||
})
|
||||
|
|
@ -283,21 +341,22 @@ describe('EarnPage', () => {
|
|||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('page-loading')).toBeInTheDocument()
|
||||
|
||||
expect(screen.queryByText('submit-earn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts earning and opens fee/report dialogs', async () => {
|
||||
it('starts earning', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.feeConfigMissing = true
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
await user.click(screen.getByText('open-fee-config'))
|
||||
expect(screen.getByText('fee-config-dialog:true')).toBeInTheDocument()
|
||||
expect(screen.queryByText('earn.precondition.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'earn.button_stop' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'earn.button_show_report' }))
|
||||
expect(screen.getByText('earn-report:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('submit-earn')).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByText('submit-earn'))
|
||||
|
||||
await waitFor(() => expect(mocks.startMaker).toHaveBeenCalled())
|
||||
expect(mocks.startMaker).toHaveBeenCalledWith({
|
||||
body: {
|
||||
|
|
@ -312,6 +371,21 @@ describe('EarnPage', () => {
|
|||
expect(mocks.scrollToTop).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens fee/report dialogs', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.feeConfigMissing = true
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByText('open-fee-config'))
|
||||
expect(screen.getByText('fee-config-dialog:true')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'earn.button_show_report' }))
|
||||
expect(screen.getByText('earn-report:true')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows running maker offer and stops it', async () => {
|
||||
const user = userEvent.setup()
|
||||
setSession({
|
||||
|
|
@ -321,78 +395,275 @@ describe('EarnPage', () => {
|
|||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('offer-card:maker-a')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.alert_running')).toBeInTheDocument()
|
||||
expect(screen.getByText('offer-card:maker-a')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'earn.button_stop' })).toBeEnabled()
|
||||
expect(screen.getByText('submit-earn')).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'earn.button_stop' }))
|
||||
expect(mocks.stopMakerRefetch).toHaveBeenCalledWith({ throwOnError: true })
|
||||
})
|
||||
|
||||
it('shows the current offer and fidelity bond from the local orderbook', () => {
|
||||
setSession({
|
||||
maker_running: true,
|
||||
offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }],
|
||||
})
|
||||
mocks.orderbookData.mockReturnValue({
|
||||
offers: [{ counterparty: 'maker-a', oid: 7, fidelity_bond_value: 42_000 }],
|
||||
fidelitybonds: [
|
||||
{ counterparty: 'maker-a', amount: 50_000, locktime: 1_700_000_000 },
|
||||
{ counterparty: 'maker-a', amount: 100_000, locktime: 1_800_000_000 },
|
||||
],
|
||||
})
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('orderbook-status:visible')).toBeInTheDocument()
|
||||
expect(screen.getByText('bond-value:42000')).toBeInTheDocument()
|
||||
expect(screen.getByText('bond-amount:100000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('polls until the current offer is visible, then slows down', () => {
|
||||
setSession({
|
||||
maker_running: true,
|
||||
offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }],
|
||||
})
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
const { refetchInterval } = mocks.orderbookQueryOptions.mock.calls[0][0] as {
|
||||
refetchInterval: (query: {
|
||||
state: {
|
||||
data?: { offers: Array<{ counterparty: string; oid: number }> }
|
||||
error?: Error | null
|
||||
}
|
||||
}) => number
|
||||
}
|
||||
const visibleData = { offers: [{ counterparty: 'maker-a', oid: 7 }] }
|
||||
|
||||
expect(refetchInterval({ state: {} })).toBe(JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL)
|
||||
expect(refetchInterval({ state: { data: visibleData } })).toBe(JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL)
|
||||
expect(refetchInterval({ state: { data: visibleData, error: new Error('offline') } })).toBe(
|
||||
JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL,
|
||||
)
|
||||
})
|
||||
|
||||
it('shows waiting states while maker updates', () => {
|
||||
mocks.startMutationState.isSuccess = true
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('Waiting for maker to start...')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.alert_waiting_start_title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens fidelity bond actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('display debug output in developer mode', () => {
|
||||
mocks.developerMode = true
|
||||
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [expiredBond] }
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('fidelity-bond:bond-tx:0')).toBeInTheDocument()
|
||||
expect(screen.getByText(/walletInfo\.fidelityBondSummary\.fbOutputs/u)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_spend/u }))
|
||||
it('enables creating a fidelity bond', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
expect(mocks.walletInfo.fidelityBondSummary.fbOutputs, 'sanity check').toHaveLength(0)
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('create-bond-dialog:false')).toBeInTheDocument()
|
||||
|
||||
const createFidelityBondButton = screen.getByRole('button', {
|
||||
name: 'earn.fidelity_bond.create_form.button_create',
|
||||
})
|
||||
expect(createFidelityBondButton).toBeEnabled()
|
||||
|
||||
await user.click(createFidelityBondButton)
|
||||
expect(screen.getByText('create-bond-dialog:true')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains when no UTXO is eligible for fidelity-bond creation', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.walletInfo.hasEligibleFidelityBondUtxo = false
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('earn.fidelity_bond.create_form.alert_no_eligible_utxos_title')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.fidelity_bond.create_form.alert_no_eligible_utxos_description')).toBeInTheDocument()
|
||||
|
||||
const createFidelityBondButton = screen.getByRole('button', {
|
||||
name: 'earn.fidelity_bond.create_form.button_create',
|
||||
})
|
||||
expect(createFidelityBondButton).toBeDisabled()
|
||||
|
||||
await user.click(createFidelityBondButton)
|
||||
expect(screen.getByText('create-bond-dialog:false')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables creating a fidelity bond while rescanning', async () => {
|
||||
const user = userEvent.setup()
|
||||
setSession({
|
||||
rescanning: true,
|
||||
})
|
||||
|
||||
expect(mocks.walletInfo.fidelityBondSummary.fbOutputs, 'sanity check').toHaveLength(0)
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('create-bond-dialog:false')).toBeInTheDocument()
|
||||
|
||||
const createFidelityBondButton = screen.getByRole('button', {
|
||||
name: 'earn.fidelity_bond.create_form.button_create',
|
||||
})
|
||||
expect(createFidelityBondButton).toBeDisabled()
|
||||
|
||||
await user.click(createFidelityBondButton)
|
||||
expect(screen.getByText('create-bond-dialog:false')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enables fidelity bond actions on existing fidelity bond', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [expiredBond] }
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('fidelity-bond:bond-tx:0')).toBeInTheDocument()
|
||||
|
||||
expect(screen.queryByText('move-to-jar-dialog:false')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('move-to-jar-dialog:true')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('renew-bond-dialog:false')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('renew-bond-dialog:true')).not.toBeInTheDocument()
|
||||
|
||||
const moveToJarButton = screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_spend/u })
|
||||
expect(moveToJarButton).toBeEnabled()
|
||||
|
||||
await user.click(moveToJarButton)
|
||||
expect(screen.getByText('move-to-jar-dialog:true')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_renew/u }))
|
||||
const renewButton = screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_renew/u })
|
||||
expect(renewButton).toBeEnabled()
|
||||
|
||||
await user.click(renewButton)
|
||||
expect(screen.getByText('renew-bond-dialog:true')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables fidelity bond actions on existing fidelity bond when rescanning is active', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [expiredBond] }
|
||||
setSession({
|
||||
rescanning: true,
|
||||
})
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.queryByText('move-to-jar-dialog:false')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('move-to-jar-dialog:true')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('renew-bond-dialog:false')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('renew-bond-dialog:true')).not.toBeInTheDocument()
|
||||
|
||||
const moveToJarButton = screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_spend/u })
|
||||
expect(moveToJarButton).toBeDisabled()
|
||||
|
||||
await user.click(moveToJarButton)
|
||||
expect(screen.queryByText('move-to-jar-dialog:false')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('move-to-jar-dialog:true')).not.toBeInTheDocument()
|
||||
|
||||
const renewButton = screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_renew/u })
|
||||
expect(renewButton).toBeDisabled()
|
||||
|
||||
await user.click(renewButton)
|
||||
expect(screen.queryByText('renew-bond-dialog:false')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('renew-bond-dialog:true')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the coinjoin-in-progress alert', () => {
|
||||
setSession({ coinjoin_in_process: true })
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows the waiting-to-stop alert', () => {
|
||||
setSession({ maker_running: true })
|
||||
mocks.stopMutationState.isSuccess = true
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
expect(screen.getByText('Waiting for maker to stop...')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('earn.alert_waiting_stop_title')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows the loading-offer alert while the maker runs without an offer', () => {
|
||||
setSession({ maker_running: true, offer_list: [] })
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('earn.alert_loading_offer')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('warns when the spendable balance is only unconfirmed', () => {
|
||||
mocks.walletInfo.jars = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
|
||||
mocks.walletInfo.jars = [makeJar({ calculatedConfirmedAvailableBalanceInSats: 0 })]
|
||||
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
expect(screen.getByText('earn.alert_unconfirmed_balance_title')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('earn.precondition.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.precondition.hint_missing_confirmations:{"count":1}')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('warns when there is no spendable balance at all', () => {
|
||||
mocks.walletInfo.jars = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
|
||||
mocks.walletInfo.jars = [makeJar({ calculatedConfirmedAvailableBalanceInSats: 0 })]
|
||||
mocks.walletInfo.maxJarAvailableBalance = 0
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
expect(screen.getByText('earn.alert_no_spendable_balance_title')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('earn.precondition.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.precondition.hint_missing_utxos')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('warns when there are unfrozen fidelity bonds', () => {
|
||||
mocks.walletInfo.fidelityBondSummary = {
|
||||
fbOutputs: [
|
||||
{
|
||||
...expiredBond,
|
||||
frozen: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('earn.precondition.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.precondition.hint_non_frozen_fidelity_bond:{"count":1}')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows an error toast when starting the maker fails', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const user = userEvent.setup()
|
||||
mocks.startMaker.mockRejectedValue(new Error('start boom'))
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByText('submit-earn'))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
|
|
@ -400,7 +671,12 @@ describe('EarnPage', () => {
|
|||
const user = userEvent.setup()
|
||||
setSession({ maker_running: true, offer_list: [{ cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }] })
|
||||
mocks.stopMakerRefetch.mockRejectedValue(new Error('stop boom'))
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'earn.button_stop' })).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'earn.button_stop' }))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { startmakerMutation, stopmakerOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
|
||||
import type { ErrorMessage, StartMakerRequest } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import { startmakerMutation, stopmakerOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import type { StartMakerRequest } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangleIcon, FileTextIcon, HourglassIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react'
|
||||
import { FileTextIcon, HourglassIcon, InfoIcon, PlusIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react'
|
||||
import type { SubmitHandler } from 'react-hook-form'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
|
@ -16,13 +16,16 @@ import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader,
|
|||
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
|
||||
import { PageLoading } from '@/components/ui/jam/PageLoading'
|
||||
import PageTitle from '@/components/ui/jam/PageTitle'
|
||||
import { isDevMode } from '@/constants/debugFeatures'
|
||||
import * as JAM from '@/constants/jam'
|
||||
import { routes } from '@/constants/routes'
|
||||
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
|
||||
import { useQueryOrderbook } from '@/hooks/useQueryOrderbook'
|
||||
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import { useRefreshSession } from '@/hooks/useRefreshSession'
|
||||
import * as OrderbookApi from '@/lib/api/orderbook'
|
||||
import { getErrorReason } from '@/lib/errorReason'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import { withQueryDelay } from '@/lib/queryClient'
|
||||
|
|
@ -30,25 +33,16 @@ import { cn, isAbsoluteOffer, isRelativeOffer, percentageToFactor, scrollToTop }
|
|||
import type { WalletFileName } from '@/lib/utils'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import { jmSessionStore } from '@/store/jmSessionStore'
|
||||
import type { Milliseconds } from '@/types/global'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
import { CreateFidelityBondDialog } from './CreateFidelityBondDialog'
|
||||
import { EarnForm, type EarnFormValues } from './EarnForm'
|
||||
import { NonFrozenFidelityBondsAlert, NoSpendableBalanceAlert, UnconfirmedBalanceAlert } from './EarnPreconditionAlerts'
|
||||
import { FidelityBondCard } from './FidelityBondCard'
|
||||
import { MoveToJarDialog } from './MoveToJarDialog'
|
||||
import { OfferCard } from './OfferCard'
|
||||
import { RenewBondDialog } from './RenewBondDialog'
|
||||
import { EarnReportOverlay } from './report/EarnReportOverlay'
|
||||
|
||||
// In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
|
||||
// Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case.
|
||||
// There is currently no way to know for sure - adding a delay at least mitigates the problem.
|
||||
// 2022-04-26: With value of 2_000ms, no state corruption could be provoked in a local dev setup.
|
||||
const MAKER_STOP_RESPONSE_DELAY: Milliseconds = 2_000
|
||||
|
||||
const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = 3_000
|
||||
const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = 1_000
|
||||
|
||||
const toStartMakerRequest = (values: EarnFormValues): StartMakerRequest => {
|
||||
// both fee properties need to be provided.
|
||||
// prevent providing an invalid value by setting the ignored prop to zero
|
||||
|
|
@ -67,37 +61,7 @@ interface EarnPageProps {
|
|||
walletFileName: WalletFileName
|
||||
}
|
||||
|
||||
const UnconfirmedBalanceAlert = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Alert variant="warning" className="mb-4">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t('earn.alert_unconfirmed_balance_title')}</AlertTitle>
|
||||
<AlertDescription>{t('earn.alert_unconfirmed_balance_description')}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const NoSpendableBalanceAlert = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Alert variant="warning" className="mb-4">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t('earn.alert_no_spendable_balance_title')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="earn.alert_no_spendable_balance_description">
|
||||
Fund your wallet on the
|
||||
<Link to={routes.receive} className="font-semibold">
|
||||
Receive page
|
||||
</Link>
|
||||
to start earning.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
const JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED = isDevMode()
|
||||
|
||||
export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -111,12 +75,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
const maxConfirmedJarAvailableBalance = useMemo(() => {
|
||||
return Math.max(0, ...walletInfo.jars.map((jar) => jar.balanceSummary.calculatedConfirmedAvailableBalanceInSats))
|
||||
}, [walletInfo.jars])
|
||||
const earnBalanceWarning =
|
||||
!walletInfo.isFetching && maxConfirmedJarAvailableBalance < JAM.OFFER_MINSIZE_MIN
|
||||
? walletInfo.maxJarAvailableBalance >= JAM.OFFER_MINSIZE_MIN
|
||||
? 'unconfirmed'
|
||||
: 'unavailable'
|
||||
: null
|
||||
|
||||
const [moveToJarUtxo, setMoveToJarUtxo] = useState<FidelityBondUtxo | undefined>()
|
||||
const [renewBondUtxo, setRenewBondUtxo] = useState<FidelityBondUtxo | undefined>()
|
||||
|
|
@ -126,6 +84,34 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false)
|
||||
|
||||
const isCurrentOfferAvailable = jmSession?.offer_list && jmSession.offer_list.length > 0
|
||||
const currentOffer = jmSession?.offer_list?.[0]
|
||||
const isCurrentOrderbookOffer = (offer: OrderbookApi.OrderbookOffer) =>
|
||||
offer.counterparty === jmSession?.nickname && String(offer.oid) === String(currentOffer?.oid)
|
||||
|
||||
const {
|
||||
queryResult: { data: orderbookData, isFetching: orderbookIsFetching, isError: orderbookIsError },
|
||||
} = useQueryOrderbook({
|
||||
enabled: makerRunning && !!currentOffer && !!jmSession?.nickname,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchInterval: (query) =>
|
||||
query.state.error || !query.state.data?.offers.some((offer) => isCurrentOrderbookOffer(offer))
|
||||
? JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL
|
||||
: JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL,
|
||||
})
|
||||
|
||||
const currentOrderbookOffer = orderbookData?.offers.find((offer) => isCurrentOrderbookOffer(offer))
|
||||
const currentOrderbookFidelityBond =
|
||||
Number(currentOrderbookOffer?.fidelity_bond_value) > 0
|
||||
? orderbookData?.fidelitybonds?.findLast((bond) => bond.counterparty === jmSession?.nickname)
|
||||
: undefined
|
||||
const orderbookStatus =
|
||||
!jmSession?.nickname || orderbookIsFetching
|
||||
? 'checking'
|
||||
: orderbookIsError
|
||||
? 'error'
|
||||
: currentOrderbookOffer
|
||||
? 'visible'
|
||||
: 'missing'
|
||||
|
||||
const stopMakerQueryOptions = stopmakerOptions({
|
||||
client,
|
||||
|
|
@ -135,7 +121,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
const stopMakerQuery = useQuery({
|
||||
...stopMakerQueryOptions,
|
||||
queryFn: withQueryDelay(stopMakerQueryOptions.queryFn, {
|
||||
delayAfter: MAKER_STOP_RESPONSE_DELAY,
|
||||
delayAfter: JAM.MAKER_STOP_RESPONSE_DELAY,
|
||||
}),
|
||||
enabled: false,
|
||||
retry: false,
|
||||
|
|
@ -150,10 +136,9 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
onMutate: () => {
|
||||
toast.info(t('earn.alert_stopping'), { id: 'earn.alert_stopping' })
|
||||
},
|
||||
onError: (error: ErrorMessage) => {
|
||||
onError: (error: unknown) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
// TODO: i18n
|
||||
toast.error(`Error while stopping the earn process. Reason: ${reason}`)
|
||||
toast.error(t('earn.error_stopping_earn', { reason }))
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -163,7 +148,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
onMutate: () => {
|
||||
toast.info(t('earn.alert_starting'), { id: 'earn.alert_starting' })
|
||||
},
|
||||
onError: (error: ErrorMessage) => {
|
||||
onError: (error) => {
|
||||
console.error('StartMaker error:', error)
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
toast.error(t('earn.alert_start_failed', { reason }))
|
||||
|
|
@ -176,10 +161,38 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
const waitingForMakerUpdate = isWaitingMakerStart || isWaitingMakerStop
|
||||
const waitingForOfferUpdate = makerRunning && !isCurrentOfferAvailable
|
||||
|
||||
const hasFidelityBond = walletInfo.fidelityBondSummary.fbOutputs.length > 0
|
||||
const isFidelityBondActionsEnabled =
|
||||
jmSession?.rescanning === false &&
|
||||
jmSession.maker_running === false &&
|
||||
jmSession.coinjoin_in_process === false &&
|
||||
!waitingForMakerUpdate &&
|
||||
!waitingForOfferUpdate &&
|
||||
!walletInfo.isFetching
|
||||
|
||||
const isCreateFidelityBondEnabled =
|
||||
isFidelityBondActionsEnabled &&
|
||||
walletInfo.hasEligibleFidelityBondUtxo &&
|
||||
(!hasFidelityBond || JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED)
|
||||
const showCreateAdditionalFidelityBond = JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED
|
||||
|
||||
const numberOfNonFrozenFidelityBondOutputs = !hasFidelityBond
|
||||
? 0
|
||||
: walletInfo.fidelityBondSummary.fbOutputs.filter((it) => it.frozen !== true).length
|
||||
|
||||
const earnPreconditionWarning =
|
||||
numberOfNonFrozenFidelityBondOutputs > 0
|
||||
? 'non-frozen-fb-present'
|
||||
: !walletInfo.isFetching && maxConfirmedJarAvailableBalance < JAM.OFFER_MINSIZE_MIN
|
||||
? walletInfo.maxJarAvailableBalance >= JAM.OFFER_MINSIZE_MIN
|
||||
? 'missing-utxos'
|
||||
: 'missing-confirmations'
|
||||
: undefined
|
||||
|
||||
useRefreshSession({
|
||||
enabled: waitingForMakerUpdate || waitingForOfferUpdate,
|
||||
refetchInterval: WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
|
||||
refetchDelay: WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
|
||||
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
|
||||
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
|
||||
})
|
||||
|
||||
const onStop = async () => {
|
||||
|
|
@ -222,13 +235,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
<div className="mx-auto max-w-4xl space-y-3 p-4">
|
||||
<PageTitle title={t('earn.title')} subtitle={t('earn.subtitle')} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowEarnReport(true)}>
|
||||
<FileTextIcon />
|
||||
{t('earn.button_show_report')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{feeConfigValidation.maxFeesConfigMissing && (
|
||||
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
|
||||
)}
|
||||
|
|
@ -236,7 +242,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
{jmSession.coinjoin_in_process === true && (
|
||||
<Alert variant="warning">
|
||||
<HourglassIcon className="motion-safe:animate-pulse" />
|
||||
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
|
||||
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
|
@ -249,13 +255,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
{isWaitingMakerStart && (
|
||||
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
<AlertTitle>{/* TODO: i18n */ t('Waiting for maker to start...')}</AlertTitle>
|
||||
<AlertTitle>{t('earn.alert_waiting_start_title')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
{isWaitingMakerStop && (
|
||||
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
<AlertTitle>{/* TODO: i18n */ t('Waiting for maker to stop...')}</AlertTitle>
|
||||
<AlertTitle>{t('earn.alert_waiting_stop_title')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
{waitingForOfferUpdate && (
|
||||
|
|
@ -265,11 +271,33 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{!isWaitingMakerStart && !isWaitingMakerStop && !jmSession.maker_running && earnPreconditionWarning && (
|
||||
<>
|
||||
{earnPreconditionWarning === 'non-frozen-fb-present' ? (
|
||||
<NonFrozenFidelityBondsAlert numberOfNonFrozenFidelityBondOutputs={numberOfNonFrozenFidelityBondOutputs} />
|
||||
) : null}
|
||||
{earnPreconditionWarning === 'missing-utxos' ? (
|
||||
<UnconfirmedBalanceAlert numberOfMissingConfirmations={1} />
|
||||
) : null}
|
||||
{earnPreconditionWarning === 'missing-confirmations' ? <NoSpendableBalanceAlert /> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => setShowEarnReport(true)}>
|
||||
<FileTextIcon />
|
||||
{t('earn.button_show_report')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{jmSession.offer_list && jmSession.offer_list.length > 0 && (
|
||||
<OfferCard
|
||||
className="motion-safe:animate-in blur-in"
|
||||
value={jmSession.offer_list[0]}
|
||||
nickname={jmSession.nickname}
|
||||
orderbookStatus={orderbookStatus}
|
||||
orderbookOffer={currentOrderbookOffer}
|
||||
fidelityBond={currentOrderbookFidelityBond}
|
||||
>
|
||||
<Button type="button" onClick={() => void onStop()} className="w-full" size="lg">
|
||||
{isWaitingMakerStop ? (
|
||||
|
|
@ -288,21 +316,23 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
<Card
|
||||
className={cn({
|
||||
hidden: jmSession.maker_running && !waitingForOfferUpdate,
|
||||
'blur-[2px]': isWaitingMakerStop || waitingForOfferUpdate,
|
||||
'blur-[2px]':
|
||||
isWaitingMakerStop ||
|
||||
waitingForOfferUpdate ||
|
||||
jmSession.maker_running ||
|
||||
jmSession.coinjoin_in_process ||
|
||||
jmSession.rescanning,
|
||||
})}
|
||||
>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground mb-4 text-sm">{t('earn.market_explainer')}</p>
|
||||
|
||||
{earnBalanceWarning === 'unconfirmed' && <UnconfirmedBalanceAlert />}
|
||||
{earnBalanceWarning === 'unavailable' && <NoSpendableBalanceAlert />}
|
||||
|
||||
<EarnForm
|
||||
onSubmit={onSubmit}
|
||||
isWaitingMakerStart={isWaitingMakerStart}
|
||||
offerMinsizeMax={maxConfirmedJarAvailableBalance}
|
||||
disabled={
|
||||
earnBalanceWarning !== null ||
|
||||
earnPreconditionWarning !== undefined ||
|
||||
walletInfo.isFetching ||
|
||||
isWaitingMakerStart ||
|
||||
isWaitingMakerStop ||
|
||||
|
|
@ -316,9 +346,9 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
</Card>
|
||||
|
||||
{/* Fidelity Bonds */}
|
||||
{walletInfo.fidelityBondSummary.fbOutputs.length === 0 ? (
|
||||
{!hasFidelityBond ? (
|
||||
<div
|
||||
className={cn({
|
||||
className={cn('mt-8', {
|
||||
hidden: jmSession.maker_running || waitingForMakerUpdate || waitingForOfferUpdate,
|
||||
})}
|
||||
>
|
||||
|
|
@ -331,39 +361,64 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
<CardDescription>{t('earn.fidelity_bond.subtitle')}</CardDescription>
|
||||
<CardAction></CardAction>
|
||||
</CardHeader>
|
||||
{!walletInfo.hasEligibleFidelityBondUtxo && (
|
||||
<CardContent>
|
||||
<Alert variant="default">
|
||||
<InfoIcon />
|
||||
<AlertTitle>{t('earn.fidelity_bond.create_form.alert_no_eligible_utxos_title')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="earn.fidelity_bond.create_form.alert_no_eligible_utxos_description"
|
||||
components={{
|
||||
'1': <Link to={routes.send} className="font-semibold" />,
|
||||
'3': <Link to={routes.walletJarsDetails} className="font-semibold" />,
|
||||
}}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
)}
|
||||
<CardFooter className="gap-2">
|
||||
<Button variant="default" onClick={() => setShowCreateFidelityBondDialog(true)}>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!isCreateFidelityBondEnabled}
|
||||
onClick={() => setShowCreateFidelityBondDialog(true)}
|
||||
>
|
||||
{t('earn.fidelity_bond.create_form.button_create')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
{/* Fidelity Bonds exist */}
|
||||
<h2 className="my-2 text-xl font-semibold tracking-tight">
|
||||
{t('earn.title_fidelity_bonds', { count: walletInfo.fidelityBondSummary.fbOutputs.length })}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4 text-sm">{t('earn.subtitle_fidelity_bonds')}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="space-y-4">
|
||||
{walletInfo.fidelityBondSummary.fbOutputs.map((it) => {
|
||||
const isExpired = !fb.utxo.isLocked(it)
|
||||
const actionsEnabled =
|
||||
isExpired &&
|
||||
!jmSession.rescanning &&
|
||||
!jmSession.maker_running &&
|
||||
!waitingForMakerUpdate &&
|
||||
!waitingForOfferUpdate &&
|
||||
!walletInfo.isFetching
|
||||
const actionsEnabled = isExpired && isFidelityBondActionsEnabled
|
||||
return (
|
||||
<FidelityBondCard value={it} key={it.utxo}>
|
||||
{actionsEnabled && (
|
||||
<div className="flex w-full flex-row gap-2">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setMoveToJarUtxo(it)}>
|
||||
{isExpired && (
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setMoveToJarUtxo(it)}
|
||||
disabled={!actionsEnabled}
|
||||
>
|
||||
<UnlockIcon />
|
||||
{t('earn.fidelity_bond.existing.button_spend')}
|
||||
</Button>
|
||||
<Button variant="default" className="flex-1" onClick={() => setRenewBondUtxo(it)}>
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={() => setRenewBondUtxo(it)}
|
||||
disabled={!actionsEnabled}
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
{t('earn.fidelity_bond.existing.button_renew')}
|
||||
</Button>
|
||||
|
|
@ -373,7 +428,21 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
|
||||
{showCreateAdditionalFidelityBond && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isCreateFidelityBondEnabled}
|
||||
onClick={() => setShowCreateFidelityBondDialog(true)}
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('earn.fidelity_bond.create_form.button_create')}
|
||||
</Button>
|
||||
<DevBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fee Configuration Dialog */}
|
||||
|
|
@ -418,11 +487,11 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="overflow-scroll">
|
||||
<code className="light:text-red-700 text-red-800">walletInfo.fidelityBondSummary.fbOutputs:</code>
|
||||
<code className="text-destructive">walletInfo.fidelityBondSummary.fbOutputs:</code>
|
||||
<pre className="text-xs">{JSON.stringify(walletInfo.fidelityBondSummary.fbOutputs, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="mt-8 overflow-scroll">
|
||||
<code className="light:text-red-700 text-red-800">jmSessionState.offer_list:</code>
|
||||
<code className="text-destructive">jmSessionState.offer_list:</code>
|
||||
<pre className="text-xs">{JSON.stringify(jmSession.offer_list, null, 2)}</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
77
src/components/earn/EarnPreconditionAlerts.tsx
Normal file
77
src/components/earn/EarnPreconditionAlerts.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { AlertTriangleIcon } from 'lucide-react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { routes } from '@/constants/routes'
|
||||
|
||||
export const UnconfirmedBalanceAlert = ({ numberOfMissingConfirmations }: { numberOfMissingConfirmations: number }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t('earn.precondition.title')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-outside list-disc space-y-2">
|
||||
<li>
|
||||
{t('earn.precondition.hint_missing_confirmations', {
|
||||
count: numberOfMissingConfirmations,
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export const NoSpendableBalanceAlert = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t('earn.precondition.title')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-outside list-disc space-y-2">
|
||||
<li>
|
||||
<Trans i18nKey="earn.precondition.hint_missing_utxos">
|
||||
<Link to={routes.receive} className="font-semibold">
|
||||
Fund your wallet
|
||||
</Link>
|
||||
to start earning.
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export const NonFrozenFidelityBondsAlert = ({
|
||||
numberOfNonFrozenFidelityBondOutputs,
|
||||
}: {
|
||||
numberOfNonFrozenFidelityBondOutputs: number
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t('earn.precondition.title')}</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<ul className="list-outside list-disc space-y-2">
|
||||
<li>
|
||||
<Trans
|
||||
i18nKey="earn.precondition.hint_non_frozen_fidelity_bond"
|
||||
values={{ count: numberOfNonFrozenFidelityBondOutputs }}
|
||||
components={{
|
||||
'1': <Link to={routes.walletJarsDetails} className="font-semibold" />,
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: ({ value }: { value?: string }) => <span data-testid="address">{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,19 @@ import { useMemo, type PropsWithChildren } from 'react'
|
|||
import { ClockIcon, CoinsIcon, CopyIcon } from 'lucide-react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import { time } from '@/lib/utils'
|
||||
import { cn, time } from '@/lib/utils'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
|
||||
interface FidelityBondCardProps {
|
||||
value: FidelityBondUtxo
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FidelityBondCard({ value, children }: PropsWithChildren<FidelityBondCardProps>) {
|
||||
export function FidelityBondCard({ value, className, children }: PropsWithChildren<FidelityBondCardProps>) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const isExpired = !fb.utxo.isLocked(value)
|
||||
|
|
@ -31,7 +32,7 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isExpired ? (
|
||||
|
|
@ -42,7 +43,7 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
|
|||
t('earn.fidelity_bond.existing.title_active')
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardDescription className="min-w-0 text-xs break-all">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<code>{value.path}</code>
|
||||
|
|
@ -54,29 +55,29 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
|
|||
</Tooltip>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex items-center gap-2">
|
||||
<CoinsIcon />
|
||||
<div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<CoinsIcon className="shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Balance valueString={String(value.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<ClockIcon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-4">
|
||||
<ClockIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-muted-foreground text-sm font-semibold">
|
||||
{t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)}
|
||||
</span>
|
||||
<span className="text-md font-mono">
|
||||
<span className="text-md block font-mono break-words">
|
||||
{value.locktime} ({humanReadableLockDuration})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<CopyIcon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-4">
|
||||
<CopyIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-muted-foreground text-sm font-semibold">
|
||||
{t('earn.fidelity_bond.existing.label_address')}
|
||||
</span>
|
||||
|
|
|
|||
17
src/components/earn/MoveToJarDialog.schema.test.ts
Normal file
17
src/components/earn/MoveToJarDialog.schema.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { createMoveToJarFormSchema } from './MoveToJarDialog.schema'
|
||||
|
||||
describe('createMoveToJarFormSchema', () => {
|
||||
const schema = createMoveToJarFormSchema([0, 2])
|
||||
|
||||
it('accepts an available destination jar', async () => {
|
||||
await expect(schema.validate({ destinationJarIndex: 2 })).resolves.toEqual({ destinationJarIndex: 2 })
|
||||
})
|
||||
|
||||
it.each([{ destinationJarIndex: 1 }, { destinationJarIndex: undefined }])(
|
||||
'rejects an unavailable destination jar',
|
||||
async (values) => {
|
||||
await expect(schema.isValid(values)).resolves.toBe(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
13
src/components/earn/MoveToJarDialog.schema.ts
Normal file
13
src/components/earn/MoveToJarDialog.schema.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import * as yup from 'yup'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
|
||||
export type MoveToJarFormValues = {
|
||||
destinationJarIndex: JarIndex
|
||||
}
|
||||
|
||||
export const createMoveToJarFormSchema = (availableJarIndexes: JarIndex[]): yup.ObjectSchema<MoveToJarFormValues> =>
|
||||
yup
|
||||
.object({
|
||||
destinationJarIndex: yup.number().oneOf(availableJarIndexes).required(),
|
||||
})
|
||||
.required()
|
||||
|
|
@ -4,17 +4,20 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|||
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import { MoveToJarDialog } from './MoveToJarDialog'
|
||||
|
||||
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
|
||||
|
||||
type ChildrenProps = { children: ReactNode }
|
||||
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
|
||||
type ValueProps = { value: string }
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
queryReturn: {
|
||||
fetchAddressQueryData: {
|
||||
data: undefined as { address?: string } | undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
},
|
||||
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }),
|
||||
directSendAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }),
|
||||
freezeAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
|
||||
|
|
@ -27,13 +30,16 @@ vi.mock('react-i18next', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => h.queryReturn,
|
||||
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
|
||||
useQuery: () => h.fetchAddressQueryData,
|
||||
useMutation: vi.fn((options: MutationOptions) => ({
|
||||
isPending: false,
|
||||
mutateAsync: async (input: unknown) => await options.mutationFn(input),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
|
||||
directsendMutation: vi.fn(() => ({})),
|
||||
freezeMutation: vi.fn(() => ({})),
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
|
||||
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
|
||||
getaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
|
||||
}))
|
||||
|
||||
|
|
@ -64,10 +70,29 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
utxos: [],
|
||||
},
|
||||
],
|
||||
walletBalanceSummary: { calculatedTotalBalanceInSats: 6000 },
|
||||
refetch: h.refetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({
|
||||
name,
|
||||
onSelect,
|
||||
disabled,
|
||||
isSelected,
|
||||
}: {
|
||||
name?: string
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
isSelected?: boolean
|
||||
}) => (
|
||||
<button onClick={onSelect} disabled={disabled} data-selected={isSelected}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
|
||||
open ? (
|
||||
|
|
@ -93,6 +118,10 @@ vi.mock('@/components/ui/jam/Address', () => ({
|
|||
Address: ({ value }: ValueProps) => <div data-testid="address">{value}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString: string }) => <span>{valueString} sats</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/CopyButton', () => ({
|
||||
CopyButton: ({ value }: ValueProps) => <div data-testid="copy">{value}</div>,
|
||||
}))
|
||||
|
|
@ -118,13 +147,14 @@ const renderDialog = (props?: { utxo?: FidelityBondUtxo; onOpenChange?: (open: b
|
|||
|
||||
const goToConfirm = () => {
|
||||
fireEvent.click(screen.getByText('Jar 1'))
|
||||
fireEvent.click(screen.getByText('earn.fidelity_bond.select_date.text_primary_button'))
|
||||
fireEvent.click(screen.getByText('global.next'))
|
||||
}
|
||||
|
||||
describe('MoveToJarDialog', () => {
|
||||
beforeEach(() => {
|
||||
h.queryReturn = { data: { address: 'bc111' }, isLoading: false, isError: false }
|
||||
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } })
|
||||
h.fetchAddressQueryData = { data: { address: 'bc111' }, isLoading: false, isError: false }
|
||||
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } })
|
||||
h.freezeAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({})
|
||||
h.isPending = false
|
||||
h.refetch = vi.fn()
|
||||
h.extraUtxos = []
|
||||
|
|
@ -134,35 +164,35 @@ describe('MoveToJarDialog', () => {
|
|||
renderDialog()
|
||||
expect(screen.getByText('earn.fidelity_bond.move.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.fidelity_bond.move.select_jar.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jar 0')).toBeEnabled()
|
||||
expect(screen.getByText('Jar 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves to the confirm step and shows the destination address', () => {
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_jar_n {"jar":1}')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jar 1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('address')).toHaveTextContent('bc111')
|
||||
})
|
||||
|
||||
it('shows a loading indicator while the destination address loads', () => {
|
||||
h.queryReturn = { data: undefined, isLoading: true, isError: false }
|
||||
h.fetchAddressQueryData = { data: undefined, isLoading: true, isError: false }
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
expect(screen.getByText('earn.fidelity_bond.move.text_loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an error alert when the address query fails', () => {
|
||||
h.queryReturn = { data: undefined, isLoading: false, isError: true }
|
||||
h.fetchAddressQueryData = { data: undefined, isLoading: false, isError: true }
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
expect(screen.getByText('earn.fidelity_bond.move.error_loading_address')).toBeInTheDocument()
|
||||
expect(screen.getByText('global.errors.error_loading_address_failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('completes the move flow and reaches the success step', async () => {
|
||||
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
fireEvent.click(screen.getByTestId('confirm-switch'))
|
||||
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument())
|
||||
|
|
@ -174,19 +204,17 @@ describe('MoveToJarDialog', () => {
|
|||
const frozenUtxo = { ...utxo, frozen: true } as unknown as FidelityBondUtxo
|
||||
renderDialog({ utxo: frozenUtxo })
|
||||
goToConfirm()
|
||||
fireEvent.click(screen.getByTestId('confirm-switch'))
|
||||
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument())
|
||||
const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
|
||||
const calls = h.freezeAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
|
||||
expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns to the confirm step when sending fails', async () => {
|
||||
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
|
||||
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
fireEvent.click(screen.getByTestId('confirm-switch'))
|
||||
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
|
||||
|
||||
await waitFor(() =>
|
||||
|
|
|
|||
|
|
@ -1,48 +1,39 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
directsendMutation,
|
||||
freezeMutation,
|
||||
getaddressOptions,
|
||||
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
|
||||
import type { DirectSendResponse, ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronLeftIcon,
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
UnlockIcon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { getaddressOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangleIcon, UnlockIcon } from 'lucide-react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Alert, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { buttonVariants } from '@/components/ui/button-variants'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { CopyButton } from '@/components/ui/jam/CopyButton'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { getErrorReason } from '@/lib/errorReason'
|
||||
import { cn, formatSats, type WalletFileName } from '@/lib/utils'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import type { WalletFileName } from '@/lib/utils'
|
||||
import { createMoveToJarFormSchema, type MoveToJarFormValues } from './MoveToJarDialog.schema'
|
||||
import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout'
|
||||
import {
|
||||
AddressPreview,
|
||||
CopyableField,
|
||||
FidelityBondAmount,
|
||||
InfoCard,
|
||||
InlineLoading,
|
||||
JarBadge,
|
||||
PendingStep,
|
||||
StepIntro,
|
||||
SuccessHeading,
|
||||
} from './fidelity-bond/FidelityBondDialogParts'
|
||||
import { FidelityBondJarSelector } from './fidelity-bond/FidelityBondJarSelector'
|
||||
import { WizardStepFooter } from './fidelity-bond/WizardStepFooter'
|
||||
import { useFidelityBondSweep } from './fidelity-bond/useFidelityBondSweep'
|
||||
|
||||
type Step = 'select_jar' | 'confirm' | 'sending' | 'success'
|
||||
|
||||
const WIZARD_STEPS: Step[] = ['select_jar', 'confirm']
|
||||
|
||||
interface MoveToJarDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
|
|
@ -56,23 +47,33 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
const walletInfo = useJamWalletInfoContext()
|
||||
|
||||
const [step, setStep] = useState<Step>('select_jar')
|
||||
const [selectedJarIndex, setSelectedJarIndex] = useState<JarIndex | undefined>()
|
||||
const [confirmationChecked, setConfirmationChecked] = useState(false)
|
||||
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
|
||||
const sourceJar = walletInfo.jars.find((jar) => jar.jarIndex === utxo.mixdepth)
|
||||
const availableJarIndexes = useMemo(() => walletInfo.jars.map((jar) => jar.jarIndex), [walletInfo.jars])
|
||||
const formSchema = useMemo(() => createMoveToJarFormSchema(availableJarIndexes), [availableJarIndexes])
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<MoveToJarFormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
destinationJarIndex: undefined,
|
||||
},
|
||||
resolver: yupResolver(formSchema),
|
||||
})
|
||||
const selectedJarIndex = useWatch({ control, name: 'destinationJarIndex' })
|
||||
|
||||
// All jars except the FB's source jar are valid destinations
|
||||
const destinationJars = useMemo(() => {
|
||||
return walletInfo.jars.filter((jar) => jar.jarIndex !== utxo.mixdepth)
|
||||
}, [walletInfo.jars, utxo.mixdepth])
|
||||
const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({
|
||||
walletFileName,
|
||||
utxo,
|
||||
unfreezeErrorKey: 'earn.fidelity_bond.move.error_unfreezing_fidelity_bond',
|
||||
sendErrorKey: 'earn.fidelity_bond.move.error_spending_fidelity_bond',
|
||||
})
|
||||
|
||||
// UTXOs in the source jar that are NOT this FB — they need to be frozen during sweep
|
||||
const utxosToFreeze = useMemo(() => {
|
||||
if (!sourceJar) return []
|
||||
return sourceJar.utxos.filter((u) => u.utxo !== utxo.utxo && !u.frozen)
|
||||
}, [sourceJar, utxo.utxo])
|
||||
const destinationJar = walletInfo.jars.find((jar) => jar.jarIndex === selectedJarIndex)
|
||||
|
||||
const getAddressQueryOptions = getaddressOptions({
|
||||
client,
|
||||
|
|
@ -89,40 +90,12 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
retry: false,
|
||||
})
|
||||
|
||||
if (getAddressQuery.isError && !error) {
|
||||
setError(t('earn.fidelity_bond.move.error_loading_address'))
|
||||
}
|
||||
|
||||
const destinationAddress = getAddressQuery.data?.address
|
||||
|
||||
const freezeUtxo = useMutation({
|
||||
...freezeMutation({ client }),
|
||||
onError: (error: ErrorMessage) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
setError(`${t('earn.fidelity_bond.move.error_freezing_utxos')} ${reason}`)
|
||||
},
|
||||
})
|
||||
|
||||
const unfreezeUtxo = useMutation({
|
||||
...freezeMutation({ client }),
|
||||
onError: (error: ErrorMessage) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
setError(`${t('earn.fidelity_bond.move.error_unfreezing_fidelity_bond')} ${reason}`)
|
||||
},
|
||||
})
|
||||
|
||||
const directSend = useMutation({
|
||||
...directsendMutation({ client }),
|
||||
onError: (error: ErrorMessage) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
setError(`${t('earn.fidelity_bond.move.error_spending_fidelity_bond')} ${reason}`)
|
||||
},
|
||||
})
|
||||
const displayError = error ?? (getAddressQuery.isError ? t('global.errors.error_loading_address_failed') : undefined)
|
||||
|
||||
const handleReset = () => {
|
||||
setStep('select_jar')
|
||||
setSelectedJarIndex(undefined)
|
||||
setConfirmationChecked(false)
|
||||
reset()
|
||||
setTxResult(undefined)
|
||||
setError(undefined)
|
||||
}
|
||||
|
|
@ -134,293 +107,131 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedJarIndex === undefined || !destinationAddress) return
|
||||
const submitMove = handleSubmit(async () => {
|
||||
if (!destinationAddress) return
|
||||
|
||||
setStep('sending')
|
||||
setError(undefined)
|
||||
const txResult = await sweep({
|
||||
destination: destinationAddress,
|
||||
tryFreezeAfterBroadcast: false,
|
||||
})
|
||||
|
||||
const frozen: Utxo[] = []
|
||||
try {
|
||||
// Freeze other UTXOs in the source jar so only the FB gets swept
|
||||
for (const u of utxosToFreeze) {
|
||||
await freezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: true },
|
||||
})
|
||||
frozen.push(u)
|
||||
}
|
||||
setTxResult(txResult)
|
||||
|
||||
if (utxo.frozen) {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: false },
|
||||
})
|
||||
}
|
||||
|
||||
const result = await directSend.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: {
|
||||
mixdepth: utxo.mixdepth,
|
||||
amount_sats: 0,
|
||||
destination: destinationAddress,
|
||||
},
|
||||
})
|
||||
|
||||
setTxResult(result)
|
||||
if (txResult) {
|
||||
setStep('success')
|
||||
toast.success(t('earn.fidelity_bond.move.success_text'))
|
||||
|
||||
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
|
||||
for (const u of frozen) {
|
||||
try {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: false },
|
||||
})
|
||||
} catch {
|
||||
// logged via onError
|
||||
}
|
||||
}
|
||||
|
||||
await walletInfo.refetch()
|
||||
} catch {
|
||||
// Best-effort rollback — unfreeze UTXOs that were frozen before the error
|
||||
for (const u of frozen) {
|
||||
try {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: false },
|
||||
})
|
||||
} catch {
|
||||
// logged via onError
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setStep('confirm')
|
||||
}
|
||||
})
|
||||
|
||||
const renderFooter = () => {
|
||||
switch (step) {
|
||||
case 'select_jar':
|
||||
return (
|
||||
<WizardStepFooter
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
onPrimary={() => setStep('confirm')}
|
||||
primaryDisabled={selectedJarIndex === undefined}
|
||||
primaryLabel={t('global.next')}
|
||||
/>
|
||||
)
|
||||
case 'confirm':
|
||||
return (
|
||||
<WizardStepFooter
|
||||
onBack={() => setStep('select_jar')}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
onPrimary={() => void submitMove()}
|
||||
primaryDisabled={!destinationAddress}
|
||||
isLoading={isLoading || isSubmitting}
|
||||
primaryLabel={t('earn.fidelity_bond.move.text_button_submit')}
|
||||
/>
|
||||
)
|
||||
case 'sending':
|
||||
return null
|
||||
case 'success':
|
||||
return <Button onClick={() => handleOpenChange(false)}>{t('global.done')}</Button>
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = freezeUtxo.isPending || unfreezeUtxo.isPending || directSend.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.move.title')}</DialogTitle>
|
||||
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FidelityBondDialogLayout
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={t('earn.fidelity_bond.move.title')}
|
||||
currentStep={step === 'sending' || step === 'success' ? undefined : WIZARD_STEPS.indexOf(step)}
|
||||
totalSteps={WIZARD_STEPS.length}
|
||||
error={displayError}
|
||||
footer={renderFooter()}
|
||||
>
|
||||
{step === 'select_jar' && (
|
||||
<div className="space-y-6">
|
||||
<StepIntro icon={UnlockIcon} title={t('earn.fidelity_bond.move.select_jar.description')} />
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="animate-in fade-in-50">
|
||||
<InfoCard label={t('earn.fidelity_bond.move.label_amount')}>
|
||||
<FidelityBondAmount value={utxo.value} className="text-lg" />
|
||||
</InfoCard>
|
||||
|
||||
<FidelityBondJarSelector
|
||||
selectedJarIndex={selectedJarIndex}
|
||||
onSelect={(jarIndex) =>
|
||||
setValue('destinationJarIndex', jarIndex, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
isJarDisabled={() => false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_jar')}>
|
||||
<JarBadge jarIndex={utxo.mixdepth} name={sourceJar?.name} />
|
||||
</InfoCard>
|
||||
<InfoCard label={t('earn.fidelity_bond.move.label_destination')}>
|
||||
<JarBadge jarIndex={selectedJarIndex} name={destinationJar?.name} />
|
||||
</InfoCard>
|
||||
</div>
|
||||
|
||||
<InfoCard highlight label={t('earn.fidelity_bond.move.label_amount')}>
|
||||
<FidelityBondAmount value={utxo.value} />
|
||||
</InfoCard>
|
||||
|
||||
{getAddressQuery.isLoading ? (
|
||||
<InlineLoading text={t('earn.fidelity_bond.move.text_loading')} />
|
||||
) : (
|
||||
destinationAddress && (
|
||||
<AddressPreview label={t('earn.fidelity_bond.move.label_destination')} address={destinationAddress} />
|
||||
)
|
||||
)}
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertTitle>{t('earn.fidelity_bond.move.confirm_send_modal.title')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-2">
|
||||
{step === 'select_jar' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<UnlockIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t('earn.fidelity_bond.move.select_jar.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{step === 'sending' && <PendingStep icon={UnlockIcon} title={t('earn.fidelity_bond.move.text_sending')} />}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t('earn.fidelity_bond.review_inputs.label_amount')}
|
||||
</p>
|
||||
<p className="font-mono text-lg font-bold">{formatSats(utxo.value)}</p>
|
||||
</div>
|
||||
{step === 'success' && (
|
||||
<div className="space-y-6">
|
||||
<SuccessHeading title={t('earn.fidelity_bond.move.success_text')} />
|
||||
|
||||
<div className="grid gap-3">
|
||||
{destinationJars.map((jar) => {
|
||||
const isSelected = selectedJarIndex === jar.jarIndex
|
||||
return (
|
||||
<Card
|
||||
key={jar.jarIndex}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all duration-200 hover:shadow-md',
|
||||
isSelected ? 'border-primary bg-primary/5 shadow-sm' : 'hover:bg-muted/30',
|
||||
)}
|
||||
onClick={() => setSelectedJarIndex(jar.jarIndex)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="h-10 w-10 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: jar.color, opacity: 0.8 }}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold">{jar.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-mono font-semibold">
|
||||
{formatSats(jar.balanceSummary.calculatedAvailableBalanceInSats)}
|
||||
</p>
|
||||
{isSelected && <CheckCircle2Icon className="text-primary mt-1 ml-auto h-5 w-5" />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t('earn.fidelity_bond.review_inputs.label_jar')}
|
||||
</p>
|
||||
<p className="font-semibold">
|
||||
{t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: utxo.mixdepth })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">{t('earn.fidelity_bond.move.label_destination')}</p>
|
||||
<p className="font-semibold">
|
||||
{t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: selectedJarIndex })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t('earn.fidelity_bond.review_inputs.label_amount')}
|
||||
</p>
|
||||
<p className="font-mono text-2xl font-bold">{formatSats(utxo.value)}</p>
|
||||
</div>
|
||||
|
||||
{getAddressQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2Icon className="text-primary h-6 w-6 animate-spin" />
|
||||
<p className="text-muted-foreground ml-2">{t('earn.fidelity_bond.move.text_loading')}</p>
|
||||
</div>
|
||||
) : (
|
||||
destinationAddress && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{t('earn.fidelity_bond.move.label_destination')}</Label>
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Address className="text-xs" value={destinationAddress} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertTitle>{t('earn.fidelity_bond.move.confirm_send_modal.title')}</AlertTitle>
|
||||
</Alert>
|
||||
|
||||
<div className="bg-muted/50 flex items-start gap-3 rounded-lg p-4">
|
||||
<Switch
|
||||
id="move-confirmation"
|
||||
checked={confirmationChecked}
|
||||
onCheckedChange={(checked) => setConfirmationChecked(checked)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label htmlFor="move-confirmation" className="cursor-pointer text-sm font-medium">
|
||||
{t('earn.fidelity_bond.create_form.confirmation_toggle_title')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'sending' && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="relative">
|
||||
<Spinner className="h-16 w-16" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<UnlockIcon className="text-primary h-6 w-6 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-6 text-lg font-semibold">{t('earn.fidelity_bond.move.text_sending')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<div className="rounded-full bg-green-500/10 p-4">
|
||||
<CheckCircle2Icon className="h-16 w-16 text-green-500" />
|
||||
</div>
|
||||
<p className="mt-4 text-xl font-bold">{t('earn.fidelity_bond.move.success_text')}</p>
|
||||
</div>
|
||||
|
||||
{txResult?.txinfo?.txid && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
|
||||
{txResult.txinfo.txid}
|
||||
</code>
|
||||
<CopyButton
|
||||
key="copy-txid-move"
|
||||
value={txResult.txinfo.txid}
|
||||
text={<CopyIcon className="h-4 w-4" />}
|
||||
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
|
||||
onSuccess={() =>
|
||||
toast.success(t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id'))
|
||||
}
|
||||
onError={() => toast.error(t('global.errors.reason_unknown'))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{txResult?.txinfo?.txid && (
|
||||
<CopyableField
|
||||
label={t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
|
||||
value={txResult.txinfo.txid}
|
||||
copiedMessage={t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 'select_jar' && (
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)}>
|
||||
{t('earn.fidelity_bond.move.text_button_cancel')}
|
||||
</Button>
|
||||
<Button className="min-w-32" disabled={selectedJarIndex === undefined} onClick={() => setStep('confirm')}>
|
||||
{t('earn.fidelity_bond.select_date.text_primary_button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
<Button variant="ghost" onClick={() => setStep('select_jar')} disabled={isLoading}>
|
||||
<ChevronLeftIcon className="mr-1 h-4 w-4" />
|
||||
{t('global.back')}
|
||||
</Button>
|
||||
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)} disabled={isLoading}>
|
||||
{t('earn.fidelity_bond.move.text_button_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-32"
|
||||
disabled={!confirmationChecked || !destinationAddress || isLoading}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
|
||||
{t('earn.fidelity_bond.move.text_button_submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
<Button className="min-w-32" onClick={() => handleOpenChange(false)}>
|
||||
{t('earn.fidelity_bond.move.text_button_done')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</FidelityBondDialogLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
|
||||
import { OfferCard } from './OfferCard'
|
||||
|
||||
type Offer = NonNullable<SessionResponse['offer_list']>[number]
|
||||
|
|
@ -18,7 +19,7 @@ vi.mock('@/lib/utils', () => ({
|
|||
isRelativeOffer: (type: string) => type === 'sw0relo',
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ describe('OfferCard', () => {
|
|||
})
|
||||
|
||||
it('renders without txfee', () => {
|
||||
const offer = { ...baseOffer, ordertype: 'sw0abso', txfee: undefined }
|
||||
const offer = { ...baseOffer, ordertype: 'sw0abso', txfee: undefined } as unknown as Offer
|
||||
render(<OfferCard value={offer} nickname="JMBot" />)
|
||||
|
||||
expect(screen.queryByText('earn.current.text_txfee')).not.toBeInTheDocument()
|
||||
|
|
@ -79,4 +80,41 @@ describe('OfferCard', () => {
|
|||
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows whether the offer is visible in the local orderbook', () => {
|
||||
const { rerender } = render(<OfferCard value={baseOffer} nickname="JMBot" orderbookStatus="visible" />)
|
||||
|
||||
expect(screen.getByText('earn.current.text_orderbook_visible')).toBeInTheDocument()
|
||||
|
||||
rerender(<OfferCard value={baseOffer} nickname="JMBot" orderbookStatus="missing" />)
|
||||
expect(screen.getByText('earn.current.text_orderbook_missing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the advertised fidelity bond details', () => {
|
||||
withRuntimeLocale('de-DE', () => {
|
||||
render(
|
||||
<OfferCard
|
||||
value={baseOffer}
|
||||
nickname="JMBot"
|
||||
orderbookStatus="visible"
|
||||
orderbookOffer={{
|
||||
counterparty: 'JMBot',
|
||||
oid: 123,
|
||||
ordertype: 'sw0absoffer',
|
||||
minsize: 10_000,
|
||||
maxsize: 50_000,
|
||||
txfee: 500,
|
||||
cjfee: 1_000,
|
||||
fidelity_bond_value: 42_000,
|
||||
}}
|
||||
fidelityBond={{ counterparty: 'JMBot', amount: 100_000, locktime: 1_800_000_000 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('earn.current.text_fidelity_bond')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.current.text_bond_value: 42.000')).toBeInTheDocument()
|
||||
expect(screen.getByText('100000')).toBeInTheDocument()
|
||||
expect(screen.getByText(/earn\.current\.text_bond_locktime/u)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import type { PropsWithChildren } from 'react'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { FingerprintIcon, HandCoinsIcon, Maximize2Icon, Minimize2Icon } from 'lucide-react'
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
FingerprintIcon,
|
||||
HandCoinsIcon,
|
||||
Maximize2Icon,
|
||||
Minimize2Icon,
|
||||
PickaxeIcon,
|
||||
SearchIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { OrderbookFidelityBond, OrderbookOffer } from '@/lib/api/orderbook'
|
||||
import { cn, factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '@/lib/utils'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
import { Label } from '../ui/label'
|
||||
|
|
@ -15,33 +26,71 @@ type Offer = NonNullable<SessionResponse['offer_list']>[number]
|
|||
const OfferTypeBadge = ({ value }: { value: Offer }) => {
|
||||
const { t } = useTranslation()
|
||||
const text = renderOfferText(value, t)
|
||||
return <Badge variant={text ? 'default' : 'outline'}>{text}</Badge>
|
||||
return (
|
||||
<Badge className="max-w-full truncate" variant={text ? 'default' : 'outline'}>
|
||||
{text}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const renderOfferText = (value: Offer, t: TFunction<'translation', undefined>) => {
|
||||
if (isAbsoluteOffer(value?.ordertype || '')) {
|
||||
if (isAbsoluteOffer(String(value?.ordertype || ''))) {
|
||||
return t('earn.current.text_offer_type_absolute')
|
||||
}
|
||||
if (isRelativeOffer(value?.ordertype || '')) {
|
||||
if (isRelativeOffer(String(value?.ordertype || ''))) {
|
||||
return t('earn.current.text_offer_type_relative')
|
||||
}
|
||||
return value?.ordertype
|
||||
return String(value?.ordertype || '')
|
||||
}
|
||||
|
||||
interface OfferCardProps {
|
||||
className?: string
|
||||
value: Offer
|
||||
nickname: SessionResponse['nickname']
|
||||
orderbookStatus?: 'checking' | 'visible' | 'missing' | 'error'
|
||||
orderbookOffer?: OrderbookOffer
|
||||
fidelityBond?: OrderbookFidelityBond
|
||||
}
|
||||
|
||||
export function OfferCard({ className, value, nickname, children }: PropsWithChildren<OfferCardProps>) {
|
||||
const orderbookStatusEntry = (value: OfferCardProps['orderbookStatus'], t: TFunction) => {
|
||||
return value === 'visible'
|
||||
? { icon: CircleCheckIcon, text: t('earn.current.text_orderbook_visible'), variant: 'success' as const }
|
||||
: value === 'missing'
|
||||
? { icon: SearchIcon, text: t('earn.current.text_orderbook_missing'), variant: 'warning' as const }
|
||||
: value === 'error'
|
||||
? { icon: CircleAlertIcon, text: t('earn.current.text_orderbook_error'), variant: 'destructive' as const }
|
||||
: value === 'checking'
|
||||
? { icon: SearchIcon, text: t('earn.current.text_orderbook_checking'), variant: 'muted' as const }
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function OfferCard({
|
||||
className,
|
||||
value,
|
||||
nickname,
|
||||
orderbookStatus,
|
||||
orderbookOffer,
|
||||
fidelityBond,
|
||||
children,
|
||||
}: PropsWithChildren<OfferCardProps>) {
|
||||
const { t } = useTranslation()
|
||||
const bondValue = Number(orderbookOffer?.fidelity_bond_value) || 0
|
||||
|
||||
const offerInOrderbookStatus = orderbookStatusEntry(orderbookStatus, t)
|
||||
const bondWarning = bondValue === 0 || orderbookOffer?.fidelity_bond_verification_stale === true
|
||||
|
||||
return (
|
||||
<Card className={cn('w-full', className)}>
|
||||
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('earn.current.text_offer')}</CardTitle>
|
||||
<CardDescription></CardDescription>
|
||||
<CardDescription>
|
||||
{offerInOrderbookStatus && (
|
||||
<Badge className="whitespace-normal" variant={offerInOrderbookStatus.variant}>
|
||||
<offerInOrderbookStatus.icon className="shrink-0" />
|
||||
{offerInOrderbookStatus.text}
|
||||
</Badge>
|
||||
)}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -52,41 +101,43 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
|
|||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<FingerprintIcon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<FingerprintIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_offer_id')}</Label>
|
||||
<span className="text-md font-mono select-all">
|
||||
<span className="text-md block font-mono break-all select-all">
|
||||
{nickname}:{value?.oid}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<HandCoinsIcon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<HandCoinsIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_cjfee')}</Label>
|
||||
<span className="text-sm">
|
||||
{isRelativeOffer(value?.ordertype || '') ? (
|
||||
<span className="select-all">{factorToPercentage(Number.parseFloat(value?.cjfee || '') || 0)}%</span>
|
||||
{isRelativeOffer(String(value?.ordertype || '')) ? (
|
||||
<span className="select-all">
|
||||
{factorToPercentage(Number.parseFloat(String(value?.cjfee || '')) || 0)}%
|
||||
</span>
|
||||
) : (
|
||||
<Balance valueString={String(value?.cjfee || '0')} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Minimize2Icon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Minimize2Icon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_minsize')}</Label>
|
||||
<span className="text-sm">
|
||||
<Balance valueString={String(value?.minsize || '0')} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Maximize2Icon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Maximize2Icon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_maxsize')}</Label>
|
||||
<span className="text-sm">
|
||||
<Balance valueString={String(value?.maxsize || '0')} />
|
||||
|
|
@ -94,8 +145,9 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
|
|||
</div>
|
||||
</div>
|
||||
{!!value?.txfee && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<PickaxeIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_txfee')}</Label>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<Balance valueString={String(value?.txfee || '0')} />
|
||||
|
|
@ -103,6 +155,45 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fidelityBond !== undefined && (
|
||||
<div className="flex min-w-0 items-start gap-4 sm:col-span-full">
|
||||
<ShieldCheckIcon
|
||||
className={cn('mt-0.5 shrink-0', {
|
||||
'text-brand-success': bondValue > 0,
|
||||
'text-brand-warning': bondWarning,
|
||||
})}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label
|
||||
className={cn('font-semibold', {
|
||||
'text-brand-success': bondValue > 0,
|
||||
'text-brand-warning': bondWarning,
|
||||
})}
|
||||
>
|
||||
{t('earn.current.text_fidelity_bond')}
|
||||
</Label>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<span className={bondValue <= 0 ? 'text-muted-foreground' : undefined}>
|
||||
{t('earn.current.text_bond_value')}: {Math.floor(bondValue).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<Balance valueString={String(fidelityBond.amount)} />
|
||||
<span className="text-muted-foreground">
|
||||
{t('earn.current.text_bond_locktime', {
|
||||
date: new Date(fidelityBond.locktime * 1_000).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2">{children}</CardFooter>
|
||||
</Card>
|
||||
|
|
|
|||
16
src/components/earn/RenewBondDialog.schema.test.ts
Normal file
16
src/components/earn/RenewBondDialog.schema.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { renewBondFormSchema } from './RenewBondDialog.schema'
|
||||
|
||||
describe('renewBondFormSchema', () => {
|
||||
it('accepts a lockdate after confirmation', async () => {
|
||||
const values = { lockdate: '2030-01' as const, confirmationAccepted: true }
|
||||
await expect(renewBondFormSchema.validate(values)).resolves.toEqual(values)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ lockdate: undefined, confirmationAccepted: true },
|
||||
{ lockdate: '2030-01' as const, confirmationAccepted: false },
|
||||
])('rejects incomplete renewal values', async (values) => {
|
||||
await expect(renewBondFormSchema.isValid(values)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
19
src/components/earn/RenewBondDialog.schema.ts
Normal file
19
src/components/earn/RenewBondDialog.schema.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as yup from 'yup'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
|
||||
export type RenewBondFormValues = {
|
||||
lockdate?: fb.Lockdate
|
||||
confirmationAccepted: boolean
|
||||
}
|
||||
|
||||
export const RENEW_BOND_FORM_DEFAULT_VALUES: RenewBondFormValues = {
|
||||
lockdate: undefined,
|
||||
confirmationAccepted: false,
|
||||
}
|
||||
|
||||
export const renewBondFormSchema: yup.ObjectSchema<RenewBondFormValues> = yup
|
||||
.object({
|
||||
lockdate: yup.string<fb.Lockdate>().required(),
|
||||
confirmationAccepted: yup.boolean().oneOf([true]).required(),
|
||||
})
|
||||
.required()
|
||||
|
|
@ -4,13 +4,15 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|||
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import { RenewBondDialog } from './RenewBondDialog'
|
||||
|
||||
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
|
||||
|
||||
type ChildrenProps = { children?: ReactNode }
|
||||
type ValueProps = { value: string }
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
queryReturn: { data: undefined as { address: string } | undefined, isLoading: false, isError: false },
|
||||
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }),
|
||||
isPending: false,
|
||||
fetchAddressQueryData: { data: undefined as { address: string } | undefined, isLoading: false, isError: false },
|
||||
directSendAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }),
|
||||
freezeAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({}),
|
||||
refetch: vi.fn(),
|
||||
utxoFrozen: false,
|
||||
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
|
||||
|
|
@ -24,13 +26,16 @@ vi.mock('react-i18next', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => h.queryReturn,
|
||||
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
|
||||
useQuery: () => h.fetchAddressQueryData,
|
||||
useMutation: vi.fn((options: MutationOptions) => ({
|
||||
isPending: false,
|
||||
mutateAsync: async (input: unknown) => await options.mutationFn(input),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
|
||||
directsendMutation: vi.fn(() => ({})),
|
||||
freezeMutation: vi.fn(() => ({})),
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
|
||||
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
|
||||
gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
|
||||
}))
|
||||
|
||||
|
|
@ -56,7 +61,7 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
vi.mock('@/lib/errorReason', () => ({ getErrorReason: () => 'reason' }))
|
||||
|
||||
vi.mock('@/lib/fidelityBondUtils', () => ({
|
||||
lockdate: { toTimestamp: () => 1_893_456_000_000 },
|
||||
lockdate: { toDateLabel: () => 'January 1, 2030' },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', async (importOriginal) => ({
|
||||
|
|
@ -136,6 +141,10 @@ vi.mock('@/components/ui/jam/CopyButton', () => ({
|
|||
CopyButton: ({ value }: ValueProps) => <div data-testid="copy">{value}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString: string }) => <span>{valueString} sats</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/label', () => ({ Label: ({ children }: ChildrenProps) => <label>{children}</label> }))
|
||||
|
||||
vi.mock('../ui/jam/Address', () => ({
|
||||
|
|
@ -166,15 +175,15 @@ const selectYear = (value: string) => {
|
|||
|
||||
const goToConfirm = () => {
|
||||
selectMonth('06')
|
||||
fireEvent.click(screen.getByText('earn.fidelity_bond.select_date.text_primary_button'))
|
||||
fireEvent.click(screen.getByText('global.next'))
|
||||
}
|
||||
|
||||
describe('RenewBondDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
h.queryReturn = { data: { address: 'bc1timelock' }, isLoading: false, isError: false }
|
||||
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } })
|
||||
h.isPending = false
|
||||
h.fetchAddressQueryData = { data: { address: 'bc1timelock' }, isLoading: false, isError: false }
|
||||
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } })
|
||||
h.freezeAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({})
|
||||
h.utxoFrozen = false
|
||||
h.extraUtxos = []
|
||||
h.selectHandlers = []
|
||||
|
|
@ -189,13 +198,13 @@ describe('RenewBondDialog', () => {
|
|||
it('selecting a month enables the next button and shows the chosen date', () => {
|
||||
renderDialog()
|
||||
selectMonth('06')
|
||||
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.fidelity_bond.select_date.label_selected_lock_date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selecting a year computes a clamped lock date', () => {
|
||||
renderDialog()
|
||||
selectYear('2027')
|
||||
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.fidelity_bond.select_date.label_selected_lock_date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selecting an early month bumps the year forward', () => {
|
||||
|
|
@ -203,7 +212,7 @@ describe('RenewBondDialog', () => {
|
|||
// a month earlier than the minimum month forces year = minYear + 1
|
||||
selectMonth('06')
|
||||
selectYear('2026')
|
||||
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.fidelity_bond.select_date.label_selected_lock_date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves to the confirm step and shows the timelock address', () => {
|
||||
|
|
@ -213,27 +222,33 @@ describe('RenewBondDialog', () => {
|
|||
})
|
||||
|
||||
it('shows a loading spinner while the timelock address loads', () => {
|
||||
h.queryReturn = { data: undefined, isLoading: true, isError: false }
|
||||
h.fetchAddressQueryData = { data: undefined, isLoading: true, isError: false }
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
expect(screen.getByText('earn.fidelity_bond.renew.text_loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an error alert when the address query fails', () => {
|
||||
h.queryReturn = { data: undefined, isLoading: false, isError: true }
|
||||
h.fetchAddressQueryData = { data: undefined, isLoading: false, isError: true }
|
||||
renderDialog()
|
||||
expect(screen.getByText('earn.fidelity_bond.error_loading_address')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('completes the renew flow and reaches the success step', async () => {
|
||||
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
|
||||
|
||||
renderDialog()
|
||||
|
||||
expect(screen.queryByText('earn.fidelity_bond.renew.success_text')).not.toBeInTheDocument()
|
||||
|
||||
goToConfirm()
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-switch'))
|
||||
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
|
||||
expect(screen.getAllByText('abcd1234').length).toBeGreaterThan(0)
|
||||
|
||||
expect(h.refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -245,13 +260,40 @@ describe('RenewBondDialog', () => {
|
|||
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
|
||||
const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
|
||||
expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true)
|
||||
const calls = h.freezeAsync.mock.calls as unknown as Array<
|
||||
[{ body?: { 'utxo-string'?: string; freeze?: boolean } }]
|
||||
>
|
||||
expect(
|
||||
calls.some((call) => call[0]?.body?.['utxo-string'] === frozenUtxo.utxo && call[0]?.body?.freeze === false),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('freezes a fidelity bond after sending', async () => {
|
||||
const frozenUtxo = { ...utxo, frozen: true } as unknown as FidelityBondUtxo
|
||||
render(<RenewBondDialog open onOpenChange={vi.fn()} walletFileName={'test.jmdat'} utxo={frozenUtxo} />)
|
||||
goToConfirm()
|
||||
fireEvent.click(screen.getByTestId('confirm-switch'))
|
||||
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
|
||||
|
||||
const calls = h.freezeAsync.mock.calls as unknown as Array<
|
||||
[{ body?: { 'utxo-string'?: string; freeze?: boolean } }]
|
||||
>
|
||||
// unfreezes the expired fb utxo
|
||||
expect(
|
||||
calls.some((call) => call[0]?.body?.['utxo-string'] === frozenUtxo.utxo && call[0]?.body?.freeze === false),
|
||||
).toBe(true)
|
||||
|
||||
// freezes the renewed fb utxo
|
||||
expect(
|
||||
calls.some((call) => call[0]?.body?.['utxo-string'] === 'abcd1234:0' && call[0]?.body?.freeze === true),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns to the confirm step when sending fails', async () => {
|
||||
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
|
||||
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
|
||||
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
fireEvent.click(screen.getByTestId('confirm-switch'))
|
||||
|
|
|
|||
|
|
@ -1,50 +1,41 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
directsendMutation,
|
||||
freezeMutation,
|
||||
gettimelockaddressOptions,
|
||||
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
|
||||
import type { DirectSendResponse, ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CalendarIcon,
|
||||
CheckCircle2Icon,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
CopyIcon,
|
||||
RefreshCwIcon,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { gettimelockaddressOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangleIcon, CalendarIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { buttonVariants } from '@/components/ui/button-variants'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { CopyButton } from '@/components/ui/jam/CopyButton'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
||||
import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS } from '@/constants/jam'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { getErrorReason } from '@/lib/errorReason'
|
||||
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import { cn, formatSats, type WalletFileName } from '@/lib/utils'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import { Address } from '../ui/jam/Address'
|
||||
import { generateLockdateOptions, getYearOptions, getMonthOptions } from './CreateFidelityBondDialog/types'
|
||||
import type { WalletFileName } from '@/lib/utils'
|
||||
import { RENEW_BOND_FORM_DEFAULT_VALUES, renewBondFormSchema, type RenewBondFormValues } from './RenewBondDialog.schema'
|
||||
import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout'
|
||||
import {
|
||||
AddressPreview,
|
||||
ConfirmationToggle,
|
||||
CopyableField,
|
||||
FidelityBondAmount,
|
||||
InfoCard,
|
||||
InlineLoading,
|
||||
JarBadge,
|
||||
PendingStep,
|
||||
StepIntro,
|
||||
SuccessHeading,
|
||||
} from './fidelity-bond/FidelityBondDialogParts'
|
||||
import { LockdateSelect } from './fidelity-bond/LockdateSelect'
|
||||
import { WizardStepFooter } from './fidelity-bond/WizardStepFooter'
|
||||
import { useFidelityBondSweep } from './fidelity-bond/useFidelityBondSweep'
|
||||
|
||||
type Step = 'select_date' | 'confirm' | 'sending' | 'success'
|
||||
|
||||
const WIZARD_STEPS: Step[] = ['select_date', 'confirm']
|
||||
|
||||
interface RenewBondDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
|
|
@ -55,46 +46,35 @@ interface RenewBondDialogProps {
|
|||
export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: RenewBondDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const client = useApiClient()
|
||||
const walletInfo = useJamWalletInfoContext()
|
||||
const { enabled: isDeveloperMode } = useDeveloperMode()
|
||||
|
||||
const [step, setStep] = useState<Step>('select_date')
|
||||
const [selectedLockdate, setSelectedLockdate] = useState<fb.Lockdate | ''>('')
|
||||
const [confirmationChecked, setConfirmationChecked] = useState(false)
|
||||
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
|
||||
const lockdateOptions = useMemo(() => generateLockdateOptions(isDeveloperMode), [isDeveloperMode])
|
||||
const yearOptions = useMemo(() => getYearOptions(lockdateOptions), [lockdateOptions])
|
||||
const monthOptions = useMemo(() => getMonthOptions(), [])
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<RenewBondFormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: RENEW_BOND_FORM_DEFAULT_VALUES,
|
||||
resolver: yupResolver(renewBondFormSchema),
|
||||
})
|
||||
const selectedLockdate = useWatch({ control, name: 'lockdate' })
|
||||
const confirmationChecked = useWatch({
|
||||
control,
|
||||
name: 'confirmationAccepted',
|
||||
})
|
||||
|
||||
const minLockdate = lockdateOptions.at(0)?.value ?? ''
|
||||
const maxLockdate = lockdateOptions.at(-1)?.value ?? ''
|
||||
const clampLockdate = (lockdate: string): fb.Lockdate | '' => {
|
||||
if (!lockdate || lockdate < minLockdate) return minLockdate || ''
|
||||
if (lockdate > maxLockdate) return maxLockdate || ''
|
||||
return lockdate as fb.Lockdate
|
||||
}
|
||||
const selectedYear = selectedLockdate ? selectedLockdate.slice(0, 4) : ''
|
||||
const selectedMonth = selectedLockdate ? selectedLockdate.slice(5, 7) : ''
|
||||
const minYear = minLockdate ? Number.parseInt(minLockdate.slice(0, 4), 10) : 0
|
||||
const minMonth = minLockdate ? Number.parseInt(minLockdate.slice(5, 7), 10) : 1
|
||||
const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({
|
||||
walletFileName,
|
||||
utxo,
|
||||
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
|
||||
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
|
||||
})
|
||||
|
||||
const selectedDateLabel = selectedLockdate
|
||||
? new Date(fb.lockdate.toTimestamp(selectedLockdate)).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: null
|
||||
|
||||
const sourceJar = walletInfo.jars.find((jar) => jar.jarIndex === utxo.mixdepth)
|
||||
|
||||
// UTXOs in the source jar that are NOT this FB — they need to be frozen during sweep
|
||||
const utxosToFreeze = useMemo(() => {
|
||||
if (!sourceJar) return []
|
||||
return sourceJar.utxos.filter((u) => u.utxo !== utxo.utxo && !u.frozen)
|
||||
}, [sourceJar, utxo.utxo])
|
||||
const selectedDateLabel = selectedLockdate ? fb.lockdate.toDateLabel(selectedLockdate) : null
|
||||
|
||||
const timelockAddressQuery = useQuery({
|
||||
...gettimelockaddressOptions({
|
||||
|
|
@ -109,40 +89,13 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
retry: false,
|
||||
})
|
||||
|
||||
if (timelockAddressQuery.isError && !error) {
|
||||
setError(t('earn.fidelity_bond.error_loading_address'))
|
||||
}
|
||||
|
||||
const destinationAddress = timelockAddressQuery.data?.address
|
||||
|
||||
const freezeUtxo = useMutation({
|
||||
...freezeMutation({ client }),
|
||||
onError: (error: ErrorMessage) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
setError(`${t('earn.fidelity_bond.error_freezing_utxos')} ${reason}`)
|
||||
},
|
||||
})
|
||||
|
||||
const unfreezeUtxo = useMutation({
|
||||
...freezeMutation({ client }),
|
||||
onError: (error: ErrorMessage) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
setError(`${t('earn.fidelity_bond.error_unfreezing_utxos')} ${reason}`)
|
||||
},
|
||||
})
|
||||
|
||||
const directSend = useMutation({
|
||||
...directsendMutation({ client }),
|
||||
onError: (error: ErrorMessage) => {
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
setError(`${t('earn.fidelity_bond.renew.error_renewing_fidelity_bond')} ${reason}`)
|
||||
},
|
||||
})
|
||||
const displayError =
|
||||
error ?? (timelockAddressQuery.isError ? t('earn.fidelity_bond.error_loading_address') : undefined)
|
||||
|
||||
const handleReset = () => {
|
||||
setStep('select_date')
|
||||
setSelectedLockdate('')
|
||||
setConfirmationChecked(false)
|
||||
reset(RENEW_BOND_FORM_DEFAULT_VALUES)
|
||||
setTxResult(undefined)
|
||||
setError(undefined)
|
||||
}
|
||||
|
|
@ -154,348 +107,161 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const submitRenewal = handleSubmit(async () => {
|
||||
if (!destinationAddress) return
|
||||
|
||||
setStep('sending')
|
||||
setError(undefined)
|
||||
|
||||
const frozen: Utxo[] = []
|
||||
try {
|
||||
// Freeze other UTXOs in the source jar so only the FB gets swept
|
||||
for (const u of utxosToFreeze) {
|
||||
await freezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: true },
|
||||
})
|
||||
frozen.push(u)
|
||||
}
|
||||
const txResult = await sweep({
|
||||
destination: destinationAddress,
|
||||
tryFreezeAfterBroadcast: JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
|
||||
})
|
||||
|
||||
if (utxo.frozen) {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: false },
|
||||
})
|
||||
}
|
||||
|
||||
const result = await directSend.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: {
|
||||
mixdepth: utxo.mixdepth,
|
||||
amount_sats: 0,
|
||||
destination: destinationAddress,
|
||||
},
|
||||
})
|
||||
|
||||
setTxResult(result)
|
||||
setTxResult(txResult)
|
||||
if (txResult) {
|
||||
setStep('success')
|
||||
toast.success(t('earn.fidelity_bond.renew.success_text'))
|
||||
|
||||
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
|
||||
for (const u of frozen) {
|
||||
try {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: false },
|
||||
})
|
||||
} catch {
|
||||
// logged via onError
|
||||
}
|
||||
}
|
||||
|
||||
await walletInfo.refetch()
|
||||
} catch {
|
||||
// Best-effort rollback — unfreeze UTXOs that were frozen before the error
|
||||
for (const u of frozen) {
|
||||
try {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: false },
|
||||
})
|
||||
} catch {
|
||||
// logged via onError
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setStep('confirm')
|
||||
}
|
||||
})
|
||||
|
||||
const renderFooter = () => {
|
||||
switch (step) {
|
||||
case 'select_date':
|
||||
return (
|
||||
<WizardStepFooter
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
onPrimary={() => setStep('confirm')}
|
||||
primaryDisabled={!selectedLockdate}
|
||||
primaryLabel={t('global.next')}
|
||||
/>
|
||||
)
|
||||
case 'confirm':
|
||||
return (
|
||||
<WizardStepFooter
|
||||
onBack={() => setStep('select_date')}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
onPrimary={() => void submitRenewal()}
|
||||
primaryDisabled={!confirmationChecked || !destinationAddress}
|
||||
isLoading={isLoading || isSubmitting}
|
||||
primaryLabel={t('earn.fidelity_bond.renew.text_button_submit')}
|
||||
/>
|
||||
)
|
||||
case 'sending':
|
||||
return null
|
||||
case 'success':
|
||||
return <Button onClick={() => handleOpenChange(false)}>{t('global.done')}</Button>
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = freezeUtxo.isPending || unfreezeUtxo.isPending || directSend.isPending
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.renew.title')}</DialogTitle>
|
||||
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FidelityBondDialogLayout
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={t('earn.fidelity_bond.renew.title')}
|
||||
currentStep={step === 'sending' || step === 'success' ? undefined : WIZARD_STEPS.indexOf(step)}
|
||||
totalSteps={WIZARD_STEPS.length}
|
||||
error={displayError}
|
||||
footer={renderFooter()}
|
||||
>
|
||||
{step === 'select_date' && (
|
||||
<div className="space-y-6">
|
||||
<StepIntro icon={CalendarIcon} title={t('earn.fidelity_bond.select_date.description')} />
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="animate-in fade-in-50">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_amount')}>
|
||||
<FidelityBondAmount value={utxo.value} className="text-lg" />
|
||||
</InfoCard>
|
||||
|
||||
<LockdateSelect
|
||||
id="renew-lockdate"
|
||||
value={selectedLockdate ?? ''}
|
||||
onChange={(lockdate) =>
|
||||
setValue('lockdate', lockdate || undefined, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_lock_date')}>
|
||||
<p className="font-semibold">{selectedDateLabel}</p>
|
||||
</InfoCard>
|
||||
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_jar')}>
|
||||
<JarBadge jarIndex={utxo.mixdepth} name={sourceJar?.name} />
|
||||
</InfoCard>
|
||||
</div>
|
||||
|
||||
<InfoCard highlight label={t('earn.fidelity_bond.review_inputs.label_amount')}>
|
||||
<FidelityBondAmount value={utxo.value} />
|
||||
</InfoCard>
|
||||
|
||||
{timelockAddressQuery.isLoading ? (
|
||||
<InlineLoading text={t('earn.fidelity_bond.renew.text_loading')} />
|
||||
) : (
|
||||
destinationAddress && (
|
||||
<AddressPreview
|
||||
label={t('earn.fidelity_bond.review_inputs.label_address')}
|
||||
address={destinationAddress}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t('earn.fidelity_bond.renew.confirm_send_modal.title')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('earn.fidelity_bond.confirm_modal.body', {
|
||||
/* TODO: fix human readable duration */
|
||||
humanReadableDuration: selectedDateLabel ? `until ${selectedDateLabel}` : '',
|
||||
date: selectedDateLabel || '',
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="py-2">
|
||||
{step === 'select_date' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<CalendarIcon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t('earn.fidelity_bond.select_date.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmationToggle
|
||||
id="renew-confirmation"
|
||||
checked={confirmationChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('confirmationAccepted', checked, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t('earn.fidelity_bond.review_inputs.label_amount')}
|
||||
</p>
|
||||
<p className="font-mono text-lg font-bold">{formatSats(utxo.value)}</p>
|
||||
</div>
|
||||
{step === 'sending' && <PendingStep icon={RefreshCwIcon} title={t('earn.fidelity_bond.renew.text_sending')} />}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="renew-lockdate-month" className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.select_date.form_label_month')}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedMonth}
|
||||
onValueChange={(month) => {
|
||||
const year =
|
||||
selectedYear || String(Number.parseInt(month, 10) >= minMonth ? minYear : minYear + 1)
|
||||
const newLockdate = clampLockdate(`${year}-${month}`)
|
||||
setSelectedLockdate(newLockdate)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="renew-lockdate-month" className="h-11 w-full">
|
||||
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_month')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="renew-lockdate-year" className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.select_date.form_label_year')}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedYear}
|
||||
onValueChange={(year) => {
|
||||
const month = selectedMonth || String(year === String(minYear) ? minMonth : 1).padStart(2, '0')
|
||||
const newLockdate = clampLockdate(`${year}-${month}`)
|
||||
setSelectedLockdate(newLockdate)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="renew-lockdate-year" className="h-11 w-full">
|
||||
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_year')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{step === 'success' && (
|
||||
<div className="space-y-6">
|
||||
<SuccessHeading title={t('earn.fidelity_bond.renew.success_text')} />
|
||||
|
||||
{selectedLockdate && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">{selectedDateLabel}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{destinationAddress && (
|
||||
<CopyableField
|
||||
label={t('earn.fidelity_bond.create_fidelity_bond.label_address')}
|
||||
value={destinationAddress}
|
||||
copiedMessage={t('receive.text_copy_address')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
|
||||
</p>
|
||||
<p className="font-semibold">{selectedDateLabel}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-muted-foreground text-xs">{t('earn.fidelity_bond.review_inputs.label_jar')}</p>
|
||||
<p className="font-semibold">
|
||||
{t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: utxo.mixdepth })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
|
||||
<p className="text-muted-foreground text-xs">{t('earn.fidelity_bond.review_inputs.label_amount')}</p>
|
||||
<p className="font-mono text-2xl font-bold">{formatSats(utxo.value)}</p>
|
||||
</div>
|
||||
|
||||
{timelockAddressQuery.isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-4">
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{t('earn.fidelity_bond.renew.text_loading')}
|
||||
</div>
|
||||
) : (
|
||||
destinationAddress && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{t('earn.fidelity_bond.review_inputs.label_address')}</Label>
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Address className="text-xs" value={destinationAddress} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>{t('earn.fidelity_bond.renew.confirm_send_modal.title')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('earn.fidelity_bond.confirm_modal.body', {
|
||||
/* TODO: fix human readable duration */
|
||||
humanReadableDuration: selectedDateLabel ? `until ${selectedDateLabel}` : '',
|
||||
date: selectedDateLabel || '',
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="bg-muted/50 flex items-start gap-3 rounded-lg p-4">
|
||||
<Switch
|
||||
id="renew-confirmation"
|
||||
checked={confirmationChecked}
|
||||
onCheckedChange={(checked) => setConfirmationChecked(checked)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label htmlFor="renew-confirmation" className="cursor-pointer text-sm font-medium">
|
||||
{t('earn.fidelity_bond.create_form.confirmation_toggle_title')}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('earn.fidelity_bond.create_form.confirmation_toggle_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'sending' && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="relative">
|
||||
<Spinner className="h-16 w-16" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<RefreshCwIcon className="text-primary h-6 w-6 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-6 text-lg font-semibold">{t('earn.fidelity_bond.renew.text_sending')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<div className="rounded-full bg-green-500/10 p-4">
|
||||
<CheckCircle2Icon className="h-16 w-16 text-green-500" />
|
||||
</div>
|
||||
<p className="mt-4 text-xl font-bold">{t('earn.fidelity_bond.renew.success_text')}</p>
|
||||
</div>
|
||||
|
||||
{destinationAddress && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.create_fidelity_bond.label_address')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
|
||||
{destinationAddress}
|
||||
</code>
|
||||
<CopyButton
|
||||
key="copy-address-renew"
|
||||
value={destinationAddress}
|
||||
text={<CopyIcon className="h-4 w-4" />}
|
||||
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
|
||||
onSuccess={() => toast.success(t('receive.text_copy_address'))}
|
||||
onError={() => toast.error(t('global.errors.reason_unknown'))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{txResult?.txinfo?.txid && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
|
||||
{txResult.txinfo.txid}
|
||||
</code>
|
||||
<CopyButton
|
||||
key="copy-txid-renew"
|
||||
value={txResult.txinfo.txid}
|
||||
text={<CopyIcon className="h-4 w-4" />}
|
||||
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
|
||||
onSuccess={() =>
|
||||
toast.success(t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id'))
|
||||
}
|
||||
onError={() => toast.error(t('global.errors.reason_unknown'))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{txResult?.txinfo?.txid && (
|
||||
<CopyableField
|
||||
label={t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
|
||||
value={txResult.txinfo.txid}
|
||||
copiedMessage={t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 'select_date' && (
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)}>
|
||||
{t('earn.fidelity_bond.select_date.text_secondary_button')}
|
||||
</Button>
|
||||
<Button className="min-w-32" disabled={!selectedLockdate} onClick={() => setStep('confirm')}>
|
||||
{t('earn.fidelity_bond.select_date.text_primary_button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
<Button variant="ghost" onClick={() => setStep('select_date')} disabled={isLoading}>
|
||||
<ChevronLeftIcon className="mr-1 h-4 w-4" />
|
||||
{t('global.back')}
|
||||
</Button>
|
||||
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)} disabled={isLoading}>
|
||||
{t('earn.fidelity_bond.select_date.text_secondary_button')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-32"
|
||||
disabled={!confirmationChecked || !destinationAddress || isLoading}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
|
||||
{t('earn.fidelity_bond.renew.text_button_submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<DialogFooter className="gap-3 sm:gap-2">
|
||||
<Button className="min-w-32" onClick={() => handleOpenChange(false)}>
|
||||
{t('earn.fidelity_bond.create_fidelity_bond.text_primary_button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</FidelityBondDialogLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { AlertTriangleIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { StepProgress } from './StepProgress'
|
||||
|
||||
type FidelityBondDialogLayoutProps = Omit<ComponentProps<typeof Dialog>, 'children'> & {
|
||||
title: string
|
||||
/** zero-based wizard step, or undefined to hide the progress indicator (pending/success states) */
|
||||
currentStep?: number
|
||||
totalSteps: number
|
||||
error?: string
|
||||
footer: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FidelityBondDialogLayout({
|
||||
title,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
error,
|
||||
footer,
|
||||
children,
|
||||
...dialogProps
|
||||
}: FidelityBondDialogLayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog {...dialogProps}>
|
||||
<DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">{title}</DialogTitle>
|
||||
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{currentStep !== undefined && <StepProgress currentStep={currentStep} totalSteps={totalSteps} />}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="animate-in fade-in-50">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* negative margin + matching padding keep the scrollbar in the dialog padding, clear of content */}
|
||||
<div className="-mr-4 min-h-0 flex-1 overflow-y-auto py-2 pr-4 sm:-mr-6 sm:pr-6">{children}</div>
|
||||
|
||||
{footer && <DialogFooter className="gap-3 sm:gap-2">{footer}</DialogFooter>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
176
src/components/earn/fidelity-bond/FidelityBondDialogParts.tsx
Normal file
176
src/components/earn/fidelity-bond/FidelityBondDialogParts.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { CheckCircle2Icon, CheckIcon, CopyIcon, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { jarBadgeVariant } from '@/components/ui/badge-variants'
|
||||
import { buttonVariants } from '@/components/ui/button-variants'
|
||||
import { Item, ItemTitle, ItemDescription, ItemContent, ItemMedia } from '@/components/ui/item'
|
||||
import { Address } from '@/components/ui/jam/Address'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { CopyButton } from '@/components/ui/jam/CopyButton'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AmountSats, JarIndex } from '@/types/global'
|
||||
|
||||
/** icon + title/subtitle block shown at the top of a wizard step */
|
||||
export function StepIntro({ icon: Icon, title, subtitle }: { icon: LucideIcon; title: string; subtitle?: string }) {
|
||||
return (
|
||||
<Item className="bg-muted/50 rounded-lg">
|
||||
<ItemMedia variant="icon" className="bg-primary/10 rounded-lg">
|
||||
<Icon className="text-primary h-5 w-5" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{title}</ItemTitle>
|
||||
<ItemDescription>{subtitle}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
/** labeled summary card used on review/confirm steps */
|
||||
export function InfoCard({
|
||||
label,
|
||||
highlight = false,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
highlight?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('rounded-lg p-4', highlight ? 'bg-primary/5 border-primary/20 border' : 'bg-muted/50')}>
|
||||
<p className="text-muted-foreground mb-1 text-xs">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FidelityBondAmount({ value, className }: { value: AmountSats; className?: string }) {
|
||||
return <Balance valueString={String(value)} className={cn('font-mono text-2xl font-bold', className)} />
|
||||
}
|
||||
|
||||
/* TODO: move component to different namespace */
|
||||
export function JarBadge({ jarIndex, name }: { jarIndex: JarIndex | undefined; name?: string }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Badge variant={jarBadgeVariant(jarIndex)}>
|
||||
{name ? (
|
||||
<>
|
||||
{name} <span>#{jarIndex}</span>
|
||||
</>
|
||||
) : (
|
||||
/* TODO: i18n - move key to different namespace */
|
||||
t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: jarIndex })
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfirmationToggle({
|
||||
id,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id: string
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="bg-muted/50 flex items-start gap-3 rounded-lg p-4">
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label htmlFor={id} className="cursor-pointer text-sm font-medium">
|
||||
{t('earn.fidelity_bond.create_form.confirmation_toggle_title')}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('earn.fidelity_bond.create_form.confirmation_toggle_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** labeled value with a copy-to-clipboard button, used on success steps */
|
||||
export function CopyableField({
|
||||
label,
|
||||
value,
|
||||
copiedMessage,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
copiedMessage: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">{value}</code>
|
||||
<CopyButton
|
||||
value={value}
|
||||
text={<CopyIcon className="h-4 w-4" />}
|
||||
successText={<CheckIcon className="text-brand-success h-4 w-4" />}
|
||||
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
|
||||
onSuccess={() => toast.success(copiedMessage)}
|
||||
onError={(error) => {
|
||||
const reason = (error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown')
|
||||
toast.error(t('global.errors.error_copy_to_clipboard_failed', { reason }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** labeled read-only address preview, used on confirm steps */
|
||||
export function AddressPreview({ label, address }: { label: string; address: string }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<Address className="text-xs" value={address} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineLoading({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-4">
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** full-step spinner shown while a transaction is in flight */
|
||||
export function PendingStep({ icon: Icon, title, subtitle }: { icon: LucideIcon; title: string; subtitle?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="relative">
|
||||
<Spinner className="h-16 w-16" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Icon className="text-primary h-6 w-6 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-6 text-lg font-semibold">{title}</p>
|
||||
{subtitle && <p className="text-muted-foreground mt-2 text-sm">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuccessHeading({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<div className="bg-brand-success/10 rounded-full p-4">
|
||||
<CheckCircle2Icon className="text-brand-success h-16 w-16" />
|
||||
</div>
|
||||
<p className="mt-4 text-xl font-bold">{title}</p>
|
||||
{subtitle && <p className="text-muted-foreground mt-2 text-sm">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { SelectableJar } from '@/components/ui/jam/SelectableJar'
|
||||
import { useJamWalletInfoContext, type Jar } from '@/context/JamWalletInfoContext'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
|
||||
type FidelityBondJarSelectorProps = {
|
||||
selectedJarIndex: JarIndex | undefined
|
||||
onSelect: (jarIndex: JarIndex) => void
|
||||
isJarDisabled: (jar: Jar) => boolean
|
||||
}
|
||||
|
||||
/** jar selection grid shared by the fidelity bond flows, same look as the send flow's jar selector */
|
||||
export function FidelityBondJarSelector({ selectedJarIndex, onSelect, isJarDisabled }: FidelityBondJarSelectorProps) {
|
||||
const { jars, walletBalanceSummary } = useJamWalletInfoContext()
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-row flex-wrap items-center justify-center gap-2 gap-y-4">
|
||||
{jars.map((jar) => (
|
||||
<SelectableJar
|
||||
key={jar.jarIndex}
|
||||
name={jar.name}
|
||||
color={jar.color}
|
||||
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
|
||||
isSelected={selectedJarIndex === jar.jarIndex}
|
||||
onSelect={() => onSelect(jar.jarIndex)}
|
||||
disabled={isJarDisabled(jar)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
src/components/earn/fidelity-bond/LockdateSelect.tsx
Normal file
114
src/components/earn/fidelity-bond/LockdateSelect.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import * as fb from '@/lib/fidelityBondUtils'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import { generateLockdateOptions, getMonthOptions, getYearOptions } from '../CreateFidelityBondDialog/types'
|
||||
|
||||
type LockdateSelectProps = {
|
||||
/** prefix for the month/year element ids */
|
||||
id: string
|
||||
value: fb.Lockdate | ''
|
||||
onChange: (value: fb.Lockdate | '') => void
|
||||
unavailableValues?: readonly fb.Lockdate[]
|
||||
}
|
||||
|
||||
export function LockdateSelect({ id, value, onChange, unavailableValues = [] }: LockdateSelectProps) {
|
||||
const { t } = useTranslation()
|
||||
const { enabled: isDeveloperMode } = useDeveloperMode()
|
||||
|
||||
const lockdateOptions = useMemo(() => generateLockdateOptions(isDeveloperMode), [isDeveloperMode])
|
||||
const unavailableSet = useMemo(() => new Set(unavailableValues), [unavailableValues])
|
||||
const availableLockdateOptions = useMemo(
|
||||
() => lockdateOptions.filter((option) => !unavailableSet.has(option.value)),
|
||||
[lockdateOptions, unavailableSet],
|
||||
)
|
||||
const yearOptions = useMemo(() => getYearOptions(lockdateOptions), [lockdateOptions])
|
||||
const monthOptions = useMemo(() => getMonthOptions(), [])
|
||||
|
||||
const selectedYear = value ? value.slice(0, 4) : ''
|
||||
const selectedMonth = value ? value.slice(5, 7) : ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-month`} className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.select_date.form_label_month')}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedMonth}
|
||||
onValueChange={(month) => {
|
||||
const selectedCandidate = selectedYear ? (`${selectedYear}-${month}` as fb.Lockdate) : undefined
|
||||
const nextValue =
|
||||
selectedCandidate && !unavailableSet.has(selectedCandidate)
|
||||
? selectedCandidate
|
||||
: availableLockdateOptions.find((option) => option.value.endsWith(`-${month}`))?.value
|
||||
onChange(nextValue ?? '')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`${id}-month`} className="h-11 w-full">
|
||||
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_month')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthOptions.map((option) => {
|
||||
const candidate = selectedYear ? (`${selectedYear}-${option.value}` as fb.Lockdate) : undefined
|
||||
const disabled = candidate
|
||||
? !availableLockdateOptions.some((lockdate) => lockdate.value === candidate)
|
||||
: !availableLockdateOptions.some((lockdate) => lockdate.value.endsWith(`-${option.value}`))
|
||||
return (
|
||||
<SelectItem key={option.value} value={option.value} disabled={disabled}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-year`} className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.select_date.form_label_year')}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedYear}
|
||||
onValueChange={(year) => {
|
||||
const selectedCandidate = selectedMonth ? (`${year}-${selectedMonth}` as fb.Lockdate) : undefined
|
||||
const nextValue =
|
||||
selectedCandidate && !unavailableSet.has(selectedCandidate)
|
||||
? selectedCandidate
|
||||
: availableLockdateOptions.find((option) => option.value.startsWith(`${year}-`))?.value
|
||||
onChange(nextValue ?? '')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={`${id}-year`} className="h-11 w-full">
|
||||
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_year')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((option) => {
|
||||
const candidate = selectedMonth ? (`${option.value}-${selectedMonth}` as fb.Lockdate) : undefined
|
||||
const disabled = candidate
|
||||
? !availableLockdateOptions.some((lockdate) => lockdate.value === candidate)
|
||||
: !availableLockdateOptions.some((lockdate) => lockdate.value.startsWith(`${option.value}-`))
|
||||
return (
|
||||
<SelectItem key={option.value} value={option.value} disabled={disabled}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('earn.fidelity_bond.select_date.label_selected_lock_date')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">{fb.lockdate.toDateLabel(value)}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,22 +1,29 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { StepProgress } from './StepProgress'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
describe('StepProgress', () => {
|
||||
it('renders correct number of steps', () => {
|
||||
it('renders correct number of steps and the step label', () => {
|
||||
const { container } = render(<StepProgress currentStep={0} totalSteps={3} />)
|
||||
const steps = container.firstChild?.childNodes
|
||||
const steps = container.querySelectorAll('.rounded-full')
|
||||
|
||||
expect(steps).toHaveLength(3)
|
||||
expect(screen.getByText('global.step_label {"current":1,"total":3}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct classes based on currentStep', () => {
|
||||
const { container } = render(<StepProgress currentStep={1} totalSteps={3} />)
|
||||
const steps = container.firstChild?.childNodes as NodeListOf<HTMLElement>
|
||||
const steps = container.querySelectorAll('.rounded-full')
|
||||
|
||||
// Step 0: completed (i < currentStep)
|
||||
expect(steps[0].className).toContain('bg-primary w-8')
|
||||
29
src/components/earn/fidelity-bond/StepProgress.tsx
Normal file
29
src/components/earn/fidelity-bond/StepProgress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type StepProgressProps = {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
}
|
||||
|
||||
export function StepProgress({ currentStep, totalSteps }: StepProgressProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{Array.from({ length: totalSteps }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all duration-300',
|
||||
i < currentStep ? 'bg-primary w-8' : i === currentStep ? 'bg-primary w-12' : 'bg-muted w-8',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('global.step_label', { current: currentStep + 1, total: totalSteps })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
src/components/earn/fidelity-bond/WizardStepFooter.tsx
Normal file
43
src/components/earn/fidelity-bond/WizardStepFooter.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
type WizardStepFooterProps = {
|
||||
/** omit to hide the back button (first step) */
|
||||
onBack?: () => void
|
||||
onCancel: () => void
|
||||
primaryLabel: ReactNode
|
||||
onPrimary: () => void
|
||||
primaryDisabled?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function WizardStepFooter({
|
||||
onBack,
|
||||
onCancel,
|
||||
primaryLabel,
|
||||
onPrimary,
|
||||
primaryDisabled = false,
|
||||
isLoading = false,
|
||||
}: WizardStepFooterProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
{onBack && (
|
||||
<Button variant="ghost" onClick={onBack} disabled={isLoading}>
|
||||
<ChevronLeftIcon />
|
||||
{t('global.back')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
|
||||
{t('global.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onPrimary} disabled={primaryDisabled || isLoading}>
|
||||
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { useState } from 'react'
|
||||
import { directsendMutation, freezeMutation } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import { getErrorReason } from '@/lib/errorReason'
|
||||
|
||||
/**
|
||||
* The freeze/unfreeze/direct-send mutations shared by all fidelity bond flows,
|
||||
* reporting failures through a common error state. The unfreeze/send error
|
||||
* messages differ per flow (create/renew/move) and are passed as i18n keys.
|
||||
*/
|
||||
export function useFidelityBondMutations({
|
||||
unfreezeErrorKey,
|
||||
sendErrorKey,
|
||||
}: {
|
||||
unfreezeErrorKey: string
|
||||
sendErrorKey: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const client = useApiClient()
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
|
||||
const setErrorWithReason = (error: unknown, message: string) =>
|
||||
setError(`${message} ${getErrorReason(error, t('global.errors.reason_unknown'))}`)
|
||||
|
||||
const freezeUtxo = useMutation({
|
||||
...freezeMutation({ client }),
|
||||
retry: false,
|
||||
onError: (error) => setErrorWithReason(error, t('global.errors.error_freezing_utxos')),
|
||||
})
|
||||
|
||||
const unfreezeUtxo = useMutation({
|
||||
...freezeMutation({ client }),
|
||||
retry: false,
|
||||
onError: (error) => setErrorWithReason(error, t(unfreezeErrorKey)),
|
||||
})
|
||||
|
||||
const directSend = useMutation({
|
||||
...directsendMutation({ client }),
|
||||
retry: false,
|
||||
onError: (error) => setErrorWithReason(error, t(sendErrorKey)),
|
||||
})
|
||||
|
||||
return { freezeUtxo, unfreezeUtxo, directSend, error, setError }
|
||||
}
|
||||
208
src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts
Normal file
208
src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Jar } from '@/context/JamWalletInfoContext'
|
||||
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { BALANCE_SUMMARY_EMPTY } from '@/lib/balanceSummary'
|
||||
import { useFidelityBondSweep } from './useFidelityBondSweep'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
freezeMutateAsync: vi.fn(),
|
||||
unfreezeMutateAsync: vi.fn(),
|
||||
directSendMutateAsync: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
walletInfoRefetch: vi.fn(),
|
||||
walletInfo: {
|
||||
jars: [] as Jar[],
|
||||
refetch: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// `useFidelityBondSweep` only talks to the network through these two hooks —
|
||||
// mock at that boundary and let the hook's own orchestration logic run for real.
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
|
||||
mutateAsync: options.mutationFn,
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('./useFidelityBondMutations', () => ({
|
||||
useFidelityBondMutations: () => ({
|
||||
freezeUtxo: { mutateAsync: mocks.freezeMutateAsync },
|
||||
unfreezeUtxo: { mutateAsync: mocks.unfreezeMutateAsync },
|
||||
directSend: { mutateAsync: mocks.directSendMutateAsync },
|
||||
error: undefined,
|
||||
setError: mocks.setError,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/JamWalletInfoContext', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/context/JamWalletInfoContext')>()),
|
||||
useJamWalletInfoContext: () => mocks.walletInfo,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/utils')>()),
|
||||
delayedPromise: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
const utxo = (overrides: Partial<Utxo>): Utxo => ({
|
||||
utxo: 'tx:0',
|
||||
address: 'bcrt1qsource',
|
||||
path: "m/84'/1'/0'/0/0",
|
||||
label: '',
|
||||
value: 100_000,
|
||||
tries: 0,
|
||||
tries_remaining: 3,
|
||||
external: false,
|
||||
mixdepth: 0,
|
||||
confirmations: 6,
|
||||
frozen: false,
|
||||
locktime: undefined,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const bondUtxo = (overrides: Partial<FidelityBondUtxo>): FidelityBondUtxo => ({
|
||||
...utxo({ utxo: 'bond:0', frozen: true, ...overrides }),
|
||||
locktime: '2999-01-01 00:00:00',
|
||||
})
|
||||
|
||||
const jar = (jarIndex: number, utxos: Utxo[]): Jar =>
|
||||
({
|
||||
jarIndex,
|
||||
name: `Jar ${jarIndex}`,
|
||||
color: '#808080',
|
||||
balanceSummary: BALANCE_SUMMARY_EMPTY,
|
||||
utxos,
|
||||
}) as unknown as Jar
|
||||
|
||||
describe('useFidelityBondSweep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.freezeMutateAsync.mockResolvedValue(undefined)
|
||||
mocks.unfreezeMutateAsync.mockResolvedValue(undefined)
|
||||
mocks.walletInfoRefetch.mockResolvedValue(undefined)
|
||||
mocks.walletInfo.refetch = mocks.walletInfoRefetch
|
||||
})
|
||||
|
||||
it('re-freezes the fidelity bond after a failed sweep that had unfrozen it', async () => {
|
||||
const bond = bondUtxo({ mixdepth: 0 })
|
||||
const other = utxo({ utxo: 'other:0', mixdepth: 0, frozen: false })
|
||||
mocks.walletInfo.jars = [jar(0, [bond, other])]
|
||||
mocks.directSendMutateAsync.mockRejectedValue(new Error('broadcast failed'))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFidelityBondSweep({
|
||||
walletFileName: 'wallet.jmdat',
|
||||
utxo: bond,
|
||||
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
|
||||
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
|
||||
}),
|
||||
)
|
||||
|
||||
const txResult = await result.current.sweep({
|
||||
destination: 'bcrt1qdestination',
|
||||
tryFreezeAfterBroadcast: false,
|
||||
})
|
||||
|
||||
expect(txResult).toBeUndefined()
|
||||
|
||||
// the other jar utxo was frozen then correctly rolled back
|
||||
expect(mocks.freezeMutateAsync).toHaveBeenCalledWith({
|
||||
path: { walletname: 'wallet.jmdat' },
|
||||
body: { 'utxo-string': 'other:0', freeze: true },
|
||||
})
|
||||
expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: { 'utxo-string': 'other:0', freeze: false } }),
|
||||
)
|
||||
|
||||
// the bond itself was unfrozen before the failed send, and MUST be re-frozen in rollback
|
||||
expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith({
|
||||
path: { walletname: 'wallet.jmdat' },
|
||||
body: { 'utxo-string': 'bond:0', freeze: false },
|
||||
})
|
||||
expect(mocks.freezeMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not attempt to re-freeze the bond on failure when it was never unfrozen', async () => {
|
||||
const bond = bondUtxo({ mixdepth: 0, frozen: false })
|
||||
mocks.walletInfo.jars = [jar(0, [bond])]
|
||||
mocks.directSendMutateAsync.mockRejectedValue(new Error('broadcast failed'))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFidelityBondSweep({
|
||||
walletFileName: 'wallet.jmdat',
|
||||
utxo: bond,
|
||||
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
|
||||
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
|
||||
}),
|
||||
)
|
||||
|
||||
await result.current.sweep({ destination: 'bcrt1qdestination', tryFreezeAfterBroadcast: false })
|
||||
|
||||
expect(mocks.unfreezeMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.freezeMutateAsync).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('sweeps successfully: freezes other utxos, unfreezes the bond, then restores the others', async () => {
|
||||
const bond = bondUtxo({ mixdepth: 0 })
|
||||
const other = utxo({ utxo: 'other:0', mixdepth: 0, frozen: false })
|
||||
mocks.walletInfo.jars = [jar(0, [bond, other])]
|
||||
mocks.directSendMutateAsync.mockResolvedValue({ txinfo: { txid: 'renewed-tx' } })
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFidelityBondSweep({
|
||||
walletFileName: 'wallet.jmdat',
|
||||
utxo: bond,
|
||||
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
|
||||
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
|
||||
}),
|
||||
)
|
||||
|
||||
const txResult = await result.current.sweep({
|
||||
destination: 'bcrt1qdestination',
|
||||
tryFreezeAfterBroadcast: false,
|
||||
})
|
||||
|
||||
expect(txResult).toEqual({ txinfo: { txid: 'renewed-tx' } })
|
||||
expect(mocks.directSendMutateAsync).toHaveBeenCalledWith({
|
||||
path: { walletname: 'wallet.jmdat' },
|
||||
body: { mixdepth: 0, amount_sats: 0, destination: 'bcrt1qdestination' },
|
||||
})
|
||||
// the other utxo ends up unfrozen again post-broadcast
|
||||
expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: { 'utxo-string': 'other:0', freeze: false } }),
|
||||
)
|
||||
expect(mocks.walletInfoRefetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not re-freeze the bond if the sweep broadcast but the refetch after it fails', async () => {
|
||||
const bond = bondUtxo({ mixdepth: 0 })
|
||||
mocks.walletInfo.jars = [jar(0, [bond])]
|
||||
mocks.directSendMutateAsync.mockResolvedValue({ txinfo: { txid: 'renewed-tx' } })
|
||||
mocks.walletInfoRefetch.mockRejectedValue(new Error('refetch failed'))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFidelityBondSweep({
|
||||
walletFileName: 'wallet.jmdat',
|
||||
utxo: bond,
|
||||
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
|
||||
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
|
||||
}),
|
||||
)
|
||||
|
||||
const txResult = await result.current.sweep({
|
||||
destination: 'bcrt1qdestination',
|
||||
tryFreezeAfterBroadcast: false,
|
||||
})
|
||||
|
||||
expect(txResult).toEqual({ txinfo: { txid: 'renewed-tx' } })
|
||||
expect(mocks.freezeMutateAsync).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }),
|
||||
)
|
||||
})
|
||||
})
|
||||
172
src/components/earn/fidelity-bond/useFidelityBondSweep.ts
Normal file
172
src/components/earn/fidelity-bond/useFidelityBondSweep.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { useMemo } from 'react'
|
||||
import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY } from '@/constants/jam'
|
||||
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
||||
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { delayedPromise, type WalletFileName } from '@/lib/utils'
|
||||
import { useFidelityBondMutations } from './useFidelityBondMutations'
|
||||
|
||||
/**
|
||||
* Sweeps a fidelity bond UTXO to a destination address: freezes all other
|
||||
* UTXOs in the source jar, unfreezes the bond if needed, direct-sends the
|
||||
* whole jar balance, then restores the frozen UTXOs. Shared by the renew
|
||||
* and move-to-jar flows.
|
||||
*/
|
||||
export function useFidelityBondSweep({
|
||||
walletFileName,
|
||||
utxo,
|
||||
unfreezeErrorKey,
|
||||
sendErrorKey,
|
||||
}: {
|
||||
walletFileName: WalletFileName
|
||||
utxo: FidelityBondUtxo
|
||||
unfreezeErrorKey: string
|
||||
sendErrorKey: string
|
||||
}) {
|
||||
const { jars, refetch: walletInfoRefetch } = useJamWalletInfoContext()
|
||||
const { freezeUtxo, unfreezeUtxo, directSend, error, setError } = useFidelityBondMutations({
|
||||
unfreezeErrorKey,
|
||||
sendErrorKey,
|
||||
})
|
||||
|
||||
const sourceJar = jars.find((jar) => jar.jarIndex === utxo.mixdepth)
|
||||
|
||||
// UTXOs in the source jar that are NOT this FB — they need to be frozen during sweep
|
||||
const utxosToFreeze = useMemo(() => {
|
||||
if (!sourceJar) return []
|
||||
return sourceJar.utxos.filter((u) => u.utxo !== utxo.utxo && !u.frozen)
|
||||
}, [sourceJar, utxo.utxo])
|
||||
|
||||
/**
|
||||
* Runs the sweep. `onBroadcast` fires as soon as the transaction is sent,
|
||||
* before the frozen UTXOs are restored. Returns false when the sweep failed
|
||||
* (after a best-effort rollback).
|
||||
*/
|
||||
const { isPending, mutateAsync: sweep } = useMutation({
|
||||
mutationFn: async ({
|
||||
destination,
|
||||
tryFreezeAfterBroadcast,
|
||||
onBroadcastSuccess,
|
||||
}: {
|
||||
destination: string
|
||||
tryFreezeAfterBroadcast: boolean
|
||||
onBroadcastSuccess?: (result: DirectSendResponse) => Promise<void>
|
||||
}): Promise<DirectSendResponse | undefined> => {
|
||||
setError(undefined)
|
||||
|
||||
const frozen: Utxo[] = []
|
||||
let bondWasUnfrozen = false
|
||||
let sweepBroadcasted = false
|
||||
|
||||
try {
|
||||
// Freeze other UTXOs in the source jar so only the FB gets swept
|
||||
for (const u of utxosToFreeze) {
|
||||
await freezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: true },
|
||||
})
|
||||
frozen.push(u)
|
||||
}
|
||||
|
||||
if (utxo.frozen) {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: false },
|
||||
})
|
||||
bondWasUnfrozen = true
|
||||
}
|
||||
|
||||
const result = await directSend.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: {
|
||||
mixdepth: utxo.mixdepth,
|
||||
amount_sats: 0, // 0 := sweep!
|
||||
destination,
|
||||
},
|
||||
})
|
||||
sweepBroadcasted = true
|
||||
|
||||
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
|
||||
for (const u of frozen) {
|
||||
try {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: false },
|
||||
throwOnError: true,
|
||||
})
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
// only debug output
|
||||
console.debug('Error while unfreezing previously frozen UTXO.')
|
||||
}
|
||||
}
|
||||
|
||||
if (tryFreezeAfterBroadcast) {
|
||||
try {
|
||||
await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY)
|
||||
|
||||
const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output
|
||||
await freezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': fbUtxoId, freeze: true },
|
||||
throwOnError: true,
|
||||
})
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
console.warn('Error while freezing Fidelity Bond UTXO - continuing.')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await onBroadcastSuccess?.(result)
|
||||
} catch (error: unknown) {
|
||||
console.warn('onBroadcastSuccess failed', error)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
// Best-effort rollback — unfreeze UTXOs that were frozen before the error
|
||||
for (const u of frozen) {
|
||||
try {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: false },
|
||||
throwOnError: true,
|
||||
})
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
// only debug output
|
||||
console.debug('Error while unfreezing previously frozen UTXO in error handling.')
|
||||
}
|
||||
}
|
||||
|
||||
// re-freeze the bond if it was unfrozen before the error. skip once the
|
||||
// sweep already broadcast, the bond utxo is spent by then
|
||||
if (bondWasUnfrozen && !sweepBroadcasted) {
|
||||
try {
|
||||
await freezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: true },
|
||||
throwOnError: true,
|
||||
})
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
console.debug('Error while re-freezing fidelity bond UTXO in error handling.')
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
} finally {
|
||||
// refetch only if the sweep broadcast, and don't let a refetch error
|
||||
// get treated as a sweep failure
|
||||
if (sweepBroadcasted) {
|
||||
try {
|
||||
await walletInfoRefetch()
|
||||
} catch (error: unknown) {
|
||||
console.warn('Error while refetching wallet info after sweep.', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
return { sweep, isLoading: isPending, error, setError, sourceJar }
|
||||
}
|
||||
|
|
@ -118,24 +118,49 @@ describe('EarnReportContent', () => {
|
|||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders loading and empty report states', () => {
|
||||
it('renders loading and report states', () => {
|
||||
mocks.isLoading = true
|
||||
mocks.isRefetching = false
|
||||
mocks.entries = []
|
||||
const { rerender } = render(<EarnReportContent enabled />)
|
||||
|
||||
expect(screen.getByText('spinner')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'global.refresh' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'global.download' })).not.toBeInTheDocument()
|
||||
|
||||
mocks.isLoading = false
|
||||
mocks.isRefetching = true
|
||||
mocks.entries = []
|
||||
rerender(<EarnReportContent enabled />)
|
||||
|
||||
expect(screen.getByText('earn.alert_empty_report')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'global.refresh' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'global.download' })).toBeDisabled()
|
||||
|
||||
mocks.isLoading = false
|
||||
mocks.isRefetching = false
|
||||
mocks.entries = []
|
||||
rerender(<EarnReportContent enabled />)
|
||||
|
||||
expect(screen.queryByText('earn.alert_empty_report')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'global.refresh' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'global.download' })).toBeDisabled()
|
||||
|
||||
mocks.isLoading = false
|
||||
mocks.isRefetching = false
|
||||
mocks.entries = [entry({ earnedAmount: 100, notes: 'first maker note' })]
|
||||
rerender(<EarnReportContent enabled />)
|
||||
|
||||
expect(screen.queryByText('earn.alert_empty_report')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'global.refresh' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'global.download' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('summarizes, filters, refreshes, paginates, and exports report rows', async () => {
|
||||
const anchorClick = vi.fn()
|
||||
const createElement = vi.spyOn(document, 'createElement')
|
||||
createElement.mockImplementation((tagName: string) => {
|
||||
// eslint-disable-next-line unicorn/prefer-https -- XHTML namespace URI is an identifier that must be http
|
||||
const element = document.createElementNS('http://www.w3.org/1999/xhtml', tagName)
|
||||
if (tagName === 'a') {
|
||||
Object.defineProperty(element, 'click', {
|
||||
|
|
@ -151,37 +176,78 @@ describe('EarnReportContent', () => {
|
|||
expect(screen.getByText('chart:3')).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:600')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('balance:300').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":3\}/u)).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.report.text_report_summary:{"count":3}')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/earn\.report\.text_report_summary_filtered:/u)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('first maker note')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('earn.report.placeholder_search'), {
|
||||
target: { value: 'second' },
|
||||
})
|
||||
expect(screen.getByText(/earn\.report\.text_report_summary_filtered/u)).toBeInTheDocument()
|
||||
|
||||
expect(screen.queryByText(/earn\.report\.text_report_summary:/u)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('earn.report.text_report_summary_filtered:{"count":1}')).toBeInTheDocument()
|
||||
expect(screen.queryByText('first maker note')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('second maker note')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u }))
|
||||
const downloadButton = screen.getByRole('button', { name: 'global.download' })
|
||||
expect(downloadButton).toBeEnabled()
|
||||
|
||||
fireEvent.click(downloadButton)
|
||||
expect(mocks.createObjectURL).toHaveBeenCalled()
|
||||
expect(anchorClick).toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'first-page' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'show-all' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'show-page-size' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '' }))
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: 'global.refresh' })
|
||||
expect(refreshButton).toBeEnabled()
|
||||
|
||||
fireEvent.click(refreshButton)
|
||||
|
||||
await waitFor(() => expect(mocks.refetch).toHaveBeenCalled())
|
||||
createElement.mockRestore()
|
||||
})
|
||||
|
||||
it('exports all filtered rows regardless of pagination', async () => {
|
||||
mocks.entries = Array.from({ length: 30 }, (_, index) =>
|
||||
entry({
|
||||
notes: `maker note ${index + 1}`,
|
||||
timestamp: new Date(Date.UTC(2026, 0, index + 1, 12)),
|
||||
}),
|
||||
)
|
||||
|
||||
render(<EarnReportContent enabled />)
|
||||
|
||||
expect(screen.getByText('pagination:30')).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/maker note/u)).toHaveLength(25)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'global.download' }))
|
||||
|
||||
const blob = mocks.createObjectURL.mock.calls[0]?.[0]
|
||||
expect(blob).toBeInstanceOf(Blob)
|
||||
if (!blob) {
|
||||
throw new Error('CSV blob was not created')
|
||||
}
|
||||
|
||||
const csv = await blob.text()
|
||||
const lines = csv.trim().split('\n')
|
||||
|
||||
expect(lines).toHaveLength(31)
|
||||
expect(lines[1]).toContain('2026-01-30T12:00:00.000Z')
|
||||
expect(lines.at(-1)).toContain('2026-01-01T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('adds demo rows in developer mode', () => {
|
||||
mocks.developerMode = true
|
||||
|
||||
render(<EarnReportContent enabled />)
|
||||
|
||||
expect(screen.getByText('dev-badge')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.report.text_report_summary:{"count":3}')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_generate_demo_report/u }))
|
||||
|
||||
expect(screen.getByText('dev-badge')).toBeInTheDocument()
|
||||
expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":4\}/u)).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.report.text_report_summary:{"count":4}')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ import {
|
|||
type SortingState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon } from 'lucide-react'
|
||||
import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon, XIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DevBadge } from '@/components/dev/DevBadge'
|
||||
import { useQueryYieldgenReport, type EarnReportEntry } from '@/components/earn/report/hooks/useQueryYieldgenReport'
|
||||
import { Alert, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { SortIcon } from '@/components/ui/jam/SortIcon'
|
||||
import { TablePagination } from '@/components/ui/jam/TablePagination'
|
||||
|
|
@ -144,11 +144,11 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
}, [isShowAll, allEntries.length, table])
|
||||
|
||||
const visibleRows = table.getRowModel().rows
|
||||
const exportRows = table.getSortedRowModel().rows
|
||||
|
||||
// Export visible entries as CSV
|
||||
const downloadCsv = useCallback(() => {
|
||||
const header = 'timestamp,cj_amount,input_count,input_amount,fee,earned,confirm_minutes,notes'
|
||||
const rows = visibleRows.map((row) =>
|
||||
const rows = exportRows.map((row) =>
|
||||
[
|
||||
row.original.timestamp.toISOString(),
|
||||
row.original.cjTotalAmount ?? '',
|
||||
|
|
@ -170,7 +170,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url)
|
||||
}, 0)
|
||||
}, [visibleRows])
|
||||
}, [exportRows])
|
||||
|
||||
const earnedTotal = useMemo(() => sumEarned(allEntries, BITCOIN_GENESIS_DATE), [allEntries])
|
||||
|
||||
|
|
@ -180,7 +180,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
const earned24Hours = useMemo(() => sumEarned(allEntries, new Date(now - MILLISECONDS_IN_A_DAY)), [allEntries, now])
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto flex-1 space-y-3', className)}>
|
||||
<div className={cn('mx-auto min-w-0 flex-1 space-y-3', className)}>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner />
|
||||
|
|
@ -188,7 +188,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{(
|
||||
[
|
||||
{ label: t('earn.report.stats.earned_total'), value: earnedTotal },
|
||||
|
|
@ -212,23 +212,46 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
<EarnReportChart entries={allEntries} />
|
||||
|
||||
{/* Toolbar: search + refresh */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder={t('earn.report.placeholder_search')}
|
||||
value={globalFilter}
|
||||
onChange={(event) => table.setGlobalFilter(event.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2">
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||
<InputGroup className="min-w-[120px] flex-1 sm:max-w-[360px] sm:min-w-[220px]">
|
||||
<InputGroupInput
|
||||
className="text-xs sm:text-sm"
|
||||
value={globalFilter}
|
||||
onChange={(event) => table.setGlobalFilter(event.target.value)}
|
||||
placeholder={t('earn.report.placeholder_search')}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => table.setGlobalFilter('')}
|
||||
title={t('global.clear')}
|
||||
className={globalFilter.length === 0 ? 'invisible' : undefined}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<Button variant="outline" onClick={() => void refetch()} disabled={!enabled || isRefetching}>
|
||||
<RefreshCwIcon className={isRefetching ? 'animate-spin' : undefined} />
|
||||
<span className="hidden sm:inline">{t('global.refresh')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
className="hover:[&>svg]:motion-safe:animate-bounce"
|
||||
variant="outline"
|
||||
onClick={downloadCsv}
|
||||
disabled={!enabled || allEntries.length === 0}
|
||||
>
|
||||
<DownloadIcon className="group/download" />
|
||||
<span className="hidden sm:inline">{t('global.download')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={() => void refetch()} disabled={isRefetching}>
|
||||
<RefreshCwIcon className={isRefetching ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={downloadCsv} disabled={allEntries.length === 0}>
|
||||
<DownloadIcon />
|
||||
{t('earn.report.text_button_download_csv')}
|
||||
</Button>
|
||||
|
||||
<span className="text-muted-foreground ml-2 text-sm font-normal">
|
||||
{globalFilter === ''
|
||||
|
|
@ -236,7 +259,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
: t('earn.report.text_report_summary_filtered', { count: table.getFilteredRowModel().rows.length })}
|
||||
</span>
|
||||
{isDeveloperMode ? (
|
||||
<Button variant="outline" size="sm" onClick={addDemoEntry}>
|
||||
<Button className="min-w-0 shrink overflow-hidden" variant="outline" onClick={addDemoEntry}>
|
||||
<PlusIcon />
|
||||
{t('earn.report.text_button_generate_demo_report')}
|
||||
<DevBadge />
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const EarnReportOverlay = ({ open, onOpenChange, ...dialogProps }: EarnRe
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} {...dialogProps}>
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<PageTitle title={t('earn.report.title')} />
|
||||
|
|
|
|||
|
|
@ -57,12 +57,12 @@ describe('yieldgenReportToEarnReportEntries', () => {
|
|||
it('should handle unexpected/malformed data in a sane way', () => {
|
||||
const unexpectedHeader = EXPECTED_HEADER_LINE + ',foo,bar'
|
||||
const emptyLine = ''
|
||||
const onlyNewLine = '\n'
|
||||
const onlyNewline = '\n'
|
||||
const shortLine = '2009/01/03 04:04:04,,,'
|
||||
const longLine = '2009/01/03 05:05:05,,,,,,,,,,,,,,,,,,,,,,,'
|
||||
const malformedLine = 'this,is,a,malformed,line,with,some,unexpected,data'
|
||||
|
||||
const exampleData = [unexpectedHeader, emptyLine, onlyNewLine, shortLine, longLine, malformedLine]
|
||||
const exampleData = [unexpectedHeader, emptyLine, onlyNewline, shortLine, longLine, malformedLine]
|
||||
|
||||
const entries = yieldgenReportToEarnReportEntries(exampleData)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ vi.mock('@tanstack/react-query', () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
yieldgenreportQueryKey: () => ['yieldgenreport'],
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({
|
||||
yieldgenreport: () => yieldgenreportMock(),
|
||||
}))
|
||||
|
||||
|
|
@ -45,11 +45,12 @@ describe('useQueryYieldgenReport', () => {
|
|||
expect(parserMock).toHaveBeenCalledWith(['a', 'b'])
|
||||
})
|
||||
|
||||
it('parses a plain string array response', async () => {
|
||||
yieldgenreportMock.mockResolvedValue({ data: ['x'] })
|
||||
it('empty array if yigen_data is undefined', async () => {
|
||||
yieldgenreportMock.mockResolvedValue({ data: { yigen_data: undefined } })
|
||||
renderHook(() => useQueryYieldgenReport())
|
||||
|
||||
await expect(runQueryFn()).resolves.toEqual(['x'])
|
||||
await expect(runQueryFn()).resolves.toEqual([])
|
||||
expect(parserMock).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('returns an empty array on a 404 error', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { yieldgenreportQueryKey } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
|
||||
import { yieldgenreport } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import type { ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import { yieldgenreportQueryKey } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import { yieldgenreport } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { yieldgenReportToEarnReportEntries } from '@/components/earn/report/hooks/earnReportParser'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
|
|
@ -11,23 +10,14 @@ export type { EarnReportEntry } from '@/components/earn/report/hooks/earnReportP
|
|||
export function useQueryYieldgenReport({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
const client = useApiClient()
|
||||
|
||||
return useQuery<ReturnType<typeof yieldgenReportToEarnReportEntries>, ErrorMessage>({
|
||||
return useQuery<ReturnType<typeof yieldgenReportToEarnReportEntries>>({
|
||||
queryKey: yieldgenreportQueryKey(),
|
||||
queryFn: async ({ signal }) => {
|
||||
try {
|
||||
const response = await yieldgenreport({ client, signal })
|
||||
|
||||
let lines: string[]
|
||||
// Wrong type generated by API! Try to parse actual format `{ yigen_data: [] }`!
|
||||
// see: https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1836 (last checked: 2026-03-09)
|
||||
const data = response.data as unknown
|
||||
if (data && typeof data === 'object' && 'yigen_data' in data && Array.isArray(data.yigen_data)) {
|
||||
lines = (data.yigen_data ?? []) as string[]
|
||||
} else {
|
||||
lines = response.data ?? []
|
||||
}
|
||||
const lines = response.data?.yigen_data ?? []
|
||||
|
||||
// API returns string[] (CSV lines with header)
|
||||
return yieldgenReportToEarnReportEntries(lines)
|
||||
} catch (error: unknown) {
|
||||
// 404 is returned until the maker is started at least once
|
||||
|
|
|
|||
|
|
@ -18,20 +18,6 @@ vi.mock('react-i18next', () => ({
|
|||
Trans: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/alert', () => ({
|
||||
Alert: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/PageTitle', () => ({
|
||||
default: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<span>{title}</span>
|
||||
<span>{subtitle}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ErrorPage', () => {
|
||||
it('renders error details when error is an Error instance', () => {
|
||||
const error = new Error('boom')
|
||||
|
|
@ -64,6 +50,15 @@ describe('ErrorPage', () => {
|
|||
expect(screen.getByText('global.errors.reason_unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to reason_unknown for a error without message', () => {
|
||||
routeError.mockReturnValue({ message: '' })
|
||||
|
||||
render(<ErrorPage />)
|
||||
|
||||
expect(screen.getByText('error_page.unknown_error.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('global.errors.reason_unknown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to reason_unknown when object has no message', () => {
|
||||
routeError.mockReturnValue({ foo: 'bar' })
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next'
|
|||
import { useRouteError } from 'react-router-dom'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import PageTitle from '@/components/ui/jam/PageTitle'
|
||||
import { JAM_REPO_URL } from '@/constants/jam'
|
||||
|
||||
interface ErrorViewProps {
|
||||
title: string
|
||||
|
|
@ -19,7 +20,7 @@ function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) {
|
|||
<Trans i18nKey="error_page.report_bug">
|
||||
Please{' '}
|
||||
<a
|
||||
href="https://github.com/joinmarket-webui/jam/issues/new?labels=bug&template=bug_report.md"
|
||||
href={`${JAM_REPO_URL}/issues/new?labels=bug&template=bug_report.md`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold underline hover:no-underline"
|
||||
|
|
@ -32,15 +33,15 @@ function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) {
|
|||
|
||||
<div className="my-4">
|
||||
<h6 className="font-semibold">{t('error_page.heading_reason')}</h6>
|
||||
<Alert variant="destructive" className="overflow-scroll">
|
||||
<AlertDescription className="flex items-center justify-between">{reason}</AlertDescription>
|
||||
<Alert variant="destructive" className="overflow-x-auto">
|
||||
<AlertDescription className="min-w-0 break-words">{reason}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{stacktrace && (
|
||||
<div className="my-4">
|
||||
<h6 className="font-semibold">{t('error_page.heading_stacktrace')}</h6>
|
||||
<pre className="overflow-scroll rounded-lg border p-2">
|
||||
<pre className="max-w-full overflow-x-auto rounded-lg border p-2">
|
||||
<code>{stacktrace}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DUMMY_SEED_PHRASE, pseudoRandomInteger } from '@/lib/utils'
|
||||
import { flushActUpdates } from '@/test/flushActUpdates'
|
||||
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
|
||||
import type { BlockHeight } from '@/types/global'
|
||||
import { ImportDetailsForm } from './ImportDetailsForm'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
|
|
@ -10,8 +14,6 @@ vi.mock('react-i18next', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../dev/DevBadge', () => ({ DevBadge: () => <span data-testid="dev-badge" /> }))
|
||||
|
||||
vi.mock('../ui/accordion', () => ({
|
||||
Accordion: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
AccordionItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
|
|
@ -19,95 +21,79 @@ vi.mock('../ui/accordion', () => ({
|
|||
AccordionContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/alert', () => ({
|
||||
Alert: ({ children, variant }: { children?: ReactNode; variant?: string }) => (
|
||||
<div data-variant={variant}>{children}</div>
|
||||
),
|
||||
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
AlertTitle: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/field', () => ({
|
||||
Field: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
FieldDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
FieldLabel: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/input-group', () => ({
|
||||
InputGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
InputGroupAddon: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
InputGroupInput: (props: Record<string, unknown>) => <input {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/textarea', () => ({
|
||||
Textarea: (props: Record<string, unknown>) => <textarea {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
type,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit'
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled} type={type}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/spinner', () => ({ Spinner: () => <div data-testid="spinner" /> }))
|
||||
|
||||
const VALID_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||
const VALID_MNEMONIC = DUMMY_SEED_PHRASE.join(' ')
|
||||
|
||||
const typeMnemonic = (value: string) => {
|
||||
fireEvent.change(screen.getByPlaceholderText('import_wallet.import_details.placeholder_menmonic_phrase'), {
|
||||
fireEvent.change(screen.getByPlaceholderText('import_wallet.import_details.placeholder_mnemonic_phrase'), {
|
||||
target: { value },
|
||||
})
|
||||
}
|
||||
|
||||
const typeBlockheight = (value: BlockHeight) => {
|
||||
fireEvent.change(screen.getByPlaceholderText('import_wallet.import_details.placeholder_blockheight'), {
|
||||
target: { value },
|
||||
})
|
||||
}
|
||||
|
||||
const mockedSessionInfo: SessionResponse = { session: false, maker_running: false, coinjoin_in_process: false }
|
||||
|
||||
describe('ImportDetailsForm', () => {
|
||||
it('renders the mnemonic, blockheight and gaplimit fields', async () => {
|
||||
render(<ImportDetailsForm onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('import_wallet.import_details.label_menmonic_phrase')).toBeInTheDocument()
|
||||
render(<ImportDetailsForm onSubmit={vi.fn()} sessionInfo={mockedSessionInfo} />)
|
||||
expect(screen.getByText('import_wallet.import_details.label_mnemonic_phrase')).toBeInTheDocument()
|
||||
expect(document.querySelector('#blockheight')).toBeInTheDocument()
|
||||
expect(document.querySelector('#gaplimit')).toBeInTheDocument()
|
||||
await flushActUpdates()
|
||||
})
|
||||
|
||||
it('shows a success alert when the mnemonic is a valid BIP-39 phrase', async () => {
|
||||
render(<ImportDetailsForm onSubmit={vi.fn()} />)
|
||||
render(<ImportDetailsForm onSubmit={vi.fn()} sessionInfo={mockedSessionInfo} />)
|
||||
typeMnemonic(VALID_MNEMONIC)
|
||||
expect(screen.getByText('Mnemonic phrase is valid')).toBeInTheDocument()
|
||||
expect(screen.getByText('import_wallet.import_details.text_mnemonic_valid')).toBeInTheDocument()
|
||||
await flushActUpdates()
|
||||
})
|
||||
|
||||
it('warns and does not submit when the mnemonic is not recognized', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<ImportDetailsForm onSubmit={onSubmit} />)
|
||||
render(<ImportDetailsForm onSubmit={onSubmit} sessionInfo={mockedSessionInfo} />)
|
||||
typeMnemonic('not a real seed phrase at all')
|
||||
fireEvent.submit(document.querySelector('form')!)
|
||||
await waitFor(() => expect(screen.getByText('Mnemonic phrase is not recognized')).toBeInTheDocument())
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('import_wallet.import_details.alert_mnemonic_not_recognized_title')).toBeInTheDocument(),
|
||||
)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns and does not submit when the blockheight is larger than current blockheight', async () => {
|
||||
await withRuntimeLocale('en-US', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const currentBlockHeight = pseudoRandomInteger(1, Number.MAX_SAFE_INTEGER - 1)
|
||||
render(
|
||||
<ImportDetailsForm
|
||||
onSubmit={onSubmit}
|
||||
sessionInfo={{ ...mockedSessionInfo, block_height: currentBlockHeight }}
|
||||
/>,
|
||||
)
|
||||
typeBlockheight(currentBlockHeight + 1)
|
||||
fireEvent.submit(document.querySelector('form')!)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
`import_wallet.import_details.feedback_invalid_blockheight {"min":"0","max":"${currentBlockHeight.toLocaleString()}"}`,
|
||||
),
|
||||
).toBeInTheDocument(),
|
||||
)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits when all values are valid', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<ImportDetailsForm
|
||||
onSubmit={onSubmit}
|
||||
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700000, gaplimit: 50 }}
|
||||
sessionInfo={mockedSessionInfo}
|
||||
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700_000, gaplimit: 50 }}
|
||||
/>,
|
||||
)
|
||||
fireEvent.submit(document.querySelector('form')!)
|
||||
|
|
@ -118,7 +104,8 @@ describe('ImportDetailsForm', () => {
|
|||
render(
|
||||
<ImportDetailsForm
|
||||
onSubmit={vi.fn()}
|
||||
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700000, gaplimit: 9999 }}
|
||||
sessionInfo={mockedSessionInfo}
|
||||
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700_000, gaplimit: 9_999 }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('import_wallet.import_details.alert_high_gaplimit_value')).toBeInTheDocument()
|
||||
|
|
@ -126,7 +113,7 @@ describe('ImportDetailsForm', () => {
|
|||
})
|
||||
|
||||
it('renders a disabled submit button when disabled', async () => {
|
||||
render(<ImportDetailsForm onSubmit={vi.fn()} disabled />)
|
||||
render(<ImportDetailsForm onSubmit={vi.fn()} sessionInfo={mockedSessionInfo} disabled />)
|
||||
const submit = document.querySelector('button[type="submit"]')
|
||||
expect(submit).toBeDisabled()
|
||||
await flushActUpdates()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { validateMnemonic } from '@scure/bip39'
|
||||
import { wordlist } from '@scure/bip39/wordlists/english.js'
|
||||
import type { TFunction } from 'i18next'
|
||||
|
|
@ -15,12 +16,14 @@ import { useForm, useWatch, type Mode, type SubmitHandler } from 'react-hook-for
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import * as yup from 'yup'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
|
||||
import { GAPLIMIT_WARN_THRESHOLD } from '@/constants/jam'
|
||||
import { JM_GAPLIMIT_DEFAULT } from '@/constants/jm'
|
||||
import { blockHeightField, INPUT_BLOCK_HEIGHT_MIN } from '@/lib/formValidation'
|
||||
import { cn, DUMMY_SEED_PHRASE, isValidInteger, SEGWIT_ACTIVATION_BLOCK } from '@/lib/utils'
|
||||
import type { BlockHeight } from '@/types/global'
|
||||
import { DevBadge } from '../dev/DevBadge'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'
|
||||
import { Alert, AlertDescription, AlertTitle } from '../ui/alert'
|
||||
|
|
@ -37,17 +40,6 @@ const GAPLIMIT_SUGGESTIONS = {
|
|||
heavy: JM_GAPLIMIT_DEFAULT * 4,
|
||||
}
|
||||
|
||||
const MIN_BLOCKHEIGHT_VALUE = 1
|
||||
/**
|
||||
* Maximum blockheight value.
|
||||
* Value choosen based on estimation of blockheight in tge year 2140 (plus some buffer):
|
||||
* 365 × 144 × (2140 - 2009) = 6_885_360 = ~7_000_000
|
||||
* This is necessary because javascript does not handle large values too well,
|
||||
* and the `/rescanblockchain` errors. Not to mention that a value beyond the current
|
||||
* height does not make any sense in the first place.
|
||||
*/
|
||||
const MAX_BLOCKHEIGHT_VALUE = 10_000_000
|
||||
|
||||
const MIN_GAPLIMIT_VALUE = 1
|
||||
/**
|
||||
* Maximum gaplimit value for importing an existing wallet.
|
||||
|
|
@ -70,7 +62,7 @@ interface ImportDetailsFormValues {
|
|||
const defaultImportDetailsFormValues: ImportDetailsFormValues = isDevMode()
|
||||
? {
|
||||
mnemonicPhrase: '',
|
||||
blockheight: MIN_BLOCKHEIGHT_VALUE,
|
||||
blockheight: INPUT_BLOCK_HEIGHT_MIN,
|
||||
gaplimit: GAPLIMIT_SUGGESTIONS.heavy,
|
||||
}
|
||||
: {
|
||||
|
|
@ -93,10 +85,7 @@ const isBip39Mnemonic = (value: string) => {
|
|||
return validateMnemonic(normalized, wordlist)
|
||||
}
|
||||
|
||||
const importDetailsFormSchema = (t: TFunction) => {
|
||||
const invalidBlockheightMessage = t('import_wallet.import_details.feedback_invalid_blockheight', {
|
||||
min: MIN_BLOCKHEIGHT_VALUE.toLocaleString(),
|
||||
})
|
||||
const importDetailsFormSchema = (currentBlockHeight: BlockHeight | undefined, t: TFunction) => {
|
||||
const invalidGaplimitMessage = t('import_wallet.import_details.feedback_invalid_gaplimit', {
|
||||
min: MIN_GAPLIMIT_VALUE.toLocaleString(),
|
||||
max: MAX_GAPLIMIT_VALUE.toLocaleString(),
|
||||
|
|
@ -106,21 +95,24 @@ const importDetailsFormSchema = (t: TFunction) => {
|
|||
mnemonicPhrase: yup
|
||||
.string()
|
||||
.transform((currentValue: string | undefined) => normalizeSeedPhrase(currentValue))
|
||||
.required(t('import_wallet.import_details.feedback_invalid_menmonic_phrase'))
|
||||
.required(t('import_wallet.import_details.feedback_invalid_mnemonic_phrase'))
|
||||
.test(
|
||||
'valid-bip39-mnemonic-phrase-test',
|
||||
t('import_wallet.import_details.feedback_invalid_menmonic_phrase'),
|
||||
t('import_wallet.import_details.feedback_invalid_mnemonic_phrase'),
|
||||
(value) => {
|
||||
return isBip39Mnemonic(value)
|
||||
},
|
||||
),
|
||||
blockheight: yup
|
||||
.number()
|
||||
.transform((value) => (isValidInteger(value) ? value : null))
|
||||
.integer(invalidBlockheightMessage)
|
||||
.min(MIN_BLOCKHEIGHT_VALUE, invalidBlockheightMessage)
|
||||
.max(MAX_BLOCKHEIGHT_VALUE, invalidBlockheightMessage)
|
||||
.required(invalidBlockheightMessage),
|
||||
blockheight: blockHeightField({
|
||||
currentBlockHeight: currentBlockHeight,
|
||||
messages: {
|
||||
invalid: ({ min, max }) =>
|
||||
t('import_wallet.import_details.feedback_invalid_blockheight', {
|
||||
min: min.toLocaleString(),
|
||||
max: max.toLocaleString(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
gaplimit: yup
|
||||
.number()
|
||||
.transform((value) => (isValidInteger(value) ? value : null))
|
||||
|
|
@ -134,6 +126,7 @@ const importDetailsFormSchema = (t: TFunction) => {
|
|||
|
||||
type ImportDetailsFormProps = {
|
||||
className?: string
|
||||
sessionInfo: SessionResponse | undefined
|
||||
onSubmit: SubmitHandler<ImportDetailsFormValues>
|
||||
initialValues?: ImportDetailsFormValues
|
||||
disabled?: boolean
|
||||
|
|
@ -142,6 +135,7 @@ type ImportDetailsFormProps = {
|
|||
|
||||
export const ImportDetailsForm = ({
|
||||
className,
|
||||
sessionInfo,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
disabled,
|
||||
|
|
@ -149,7 +143,10 @@ export const ImportDetailsForm = ({
|
|||
}: ImportDetailsFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const schema = useMemo(() => importDetailsFormSchema(t), [t])
|
||||
const schema = useMemo(
|
||||
() => importDetailsFormSchema(sessionInfo?.block_height ?? undefined, t),
|
||||
[sessionInfo?.block_height, t],
|
||||
)
|
||||
|
||||
const {
|
||||
register,
|
||||
|
|
@ -183,50 +180,40 @@ export const ImportDetailsForm = ({
|
|||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.mnemonicPhrase !== undefined}>
|
||||
<FieldLabel htmlFor="import-wallet-seed">
|
||||
{t('import_wallet.import_details.label_menmonic_phrase')}
|
||||
{t('import_wallet.import_details.label_mnemonic_phrase')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="text-muted-foreground ml-1 inline size-3.5 align-text-bottom" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{/* TODO: i18n */}
|
||||
<p>Expects {SEED_WORD_COUNT_HINT} words.</p>
|
||||
<p>{t('import_wallet.import_details.hint_seed_word_count', { wordCounts: SEED_WORD_COUNT_HINT })}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id="import-wallet-seed"
|
||||
rows={4}
|
||||
placeholder={t('import_wallet.import_details.placeholder_menmonic_phrase')}
|
||||
placeholder={t('import_wallet.import_details.placeholder_mnemonic_phrase')}
|
||||
{...register('mnemonicPhrase', {
|
||||
required: true,
|
||||
})}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.mnemonicPhrase?.message && <FieldError>{errors.mnemonicPhrase.message}</FieldError>}
|
||||
</Field>
|
||||
{errors.mnemonicPhrase?.message && (
|
||||
<div className="text-destructive text-xs">{errors.mnemonicPhrase.message}</div>
|
||||
)}
|
||||
{isSeedPhraseBip39Valid && (
|
||||
<Alert variant="success" className="py-2">
|
||||
<CheckCircle2Icon />
|
||||
<AlertDescription>
|
||||
{/* TODO: i18n */}
|
||||
Mnemonic phrase is valid
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t('import_wallet.import_details.text_mnemonic_valid')}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isSubmitted && !isSubmitSuccessful && !isValid && !isSeedPhraseBip39Valid && (
|
||||
<Alert variant="warning" className="py-2">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>
|
||||
{/* TODO: i18n */}
|
||||
Mnemonic phrase is not recognized
|
||||
</AlertTitle>
|
||||
<AlertTitle>{t('import_wallet.import_details.alert_mnemonic_not_recognized_title')}</AlertTitle>
|
||||
<AlertDescription className="text-sm">
|
||||
{/* TODO: i18n */}
|
||||
Only BIP-39 compliant mnemonic phrases can be imported. Please review your inputs carefully.
|
||||
{t('import_wallet.import_details.alert_mnemonic_not_recognized_description')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -253,7 +240,8 @@ export const ImportDetailsForm = ({
|
|||
<AccordionTrigger
|
||||
className={cn({
|
||||
'text-destructive': hasImportDetailsSectionErrors,
|
||||
'light:text-yellow-800 text-yellow-200/90': !hasImportDetailsSectionErrors && showGaplimitWarning,
|
||||
'text-brand-warning light:text-brand-warning-foreground':
|
||||
!hasImportDetailsSectionErrors && showGaplimitWarning,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -263,33 +251,28 @@ export const ImportDetailsForm = ({
|
|||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className={cn('space-y-2', 'mx-1' /* add x-spacing for input component focus state*/)}>
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.blockheight !== undefined}>
|
||||
<FieldLabel htmlFor="blockheight">{t('import_wallet.import_details.label_blockheight')}</FieldLabel>
|
||||
<FieldDescription className="text-xs">
|
||||
{t('import_wallet.import_details.description_blockheight')}
|
||||
</FieldDescription>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="blockheight"
|
||||
{...register('blockheight', {
|
||||
required: true,
|
||||
disabled,
|
||||
})}
|
||||
type="number"
|
||||
min={MIN_BLOCKHEIGHT_VALUE}
|
||||
max={MAX_BLOCKHEIGHT_VALUE}
|
||||
step={1}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<BlocksIcon />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{errors.blockheight?.message && (
|
||||
<div className="text-destructive text-xs">{errors.blockheight.message}</div>
|
||||
)}
|
||||
</div>
|
||||
<Field data-invalid={errors.blockheight !== undefined}>
|
||||
<FieldLabel htmlFor="blockheight">{t('import_wallet.import_details.label_blockheight')}</FieldLabel>
|
||||
<FieldDescription className="text-xs">
|
||||
{t('import_wallet.import_details.description_blockheight')}
|
||||
</FieldDescription>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="blockheight"
|
||||
placeholder={t('import_wallet.import_details.placeholder_blockheight')}
|
||||
{...register('blockheight', {
|
||||
required: true,
|
||||
disabled,
|
||||
})}
|
||||
type="number"
|
||||
step={1}
|
||||
/>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<BlocksIcon />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{errors.blockheight?.message && <FieldError>{errors.blockheight.message}</FieldError>}
|
||||
</Field>
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.gaplimit !== undefined}>
|
||||
<FieldLabel htmlFor="gaplimit">{t('import_wallet.import_details.label_gaplimit')}</FieldLabel>
|
||||
|
|
@ -299,6 +282,7 @@ export const ImportDetailsForm = ({
|
|||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="gaplimit"
|
||||
placeholder={t('import_wallet.import_details.placeholder_gaplimit')}
|
||||
{...register('gaplimit', {
|
||||
required: true,
|
||||
disabled,
|
||||
|
|
@ -312,8 +296,8 @@ export const ImportDetailsForm = ({
|
|||
<UnfoldHorizontalIcon />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{errors.gaplimit?.message && <FieldError>{errors.gaplimit.message}</FieldError>}
|
||||
</Field>
|
||||
{errors.gaplimit?.message && <div className="text-destructive text-xs">{errors.gaplimit.message}</div>}
|
||||
|
||||
{showGaplimitWarning && (
|
||||
<Alert variant="warning">
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export const ImportStepConfirm = ({
|
|||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="mnemonic">
|
||||
<AccordionTrigger>{t('import_wallet.import_details.label_menmonic_phrase')}</AccordionTrigger>
|
||||
<AccordionTrigger>{t('import_wallet.import_details.label_mnemonic_phrase')}</AccordionTrigger>
|
||||
<AccordionContent className="bg-muted mb-2 rounded-lg p-2">
|
||||
<SeedPhraseGrid value={importDetails.mnemonicPhrase.split(/\s+/)} masked={!revealSensitiveInfo} />
|
||||
</AccordionContent>
|
||||
|
|
@ -113,7 +113,7 @@ export const ImportStepConfirm = ({
|
|||
<AccordionItem value="options">
|
||||
<AccordionTrigger
|
||||
className={cn({
|
||||
'light:text-yellow-800 text-yellow-200/90': showGaplimitWarning,
|
||||
'text-brand-warning light:text-brand-warning-foreground': showGaplimitWarning,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -127,14 +127,14 @@ export const ImportStepConfirm = ({
|
|||
<div className="text-muted-foreground text-xs">
|
||||
{t('import_wallet.import_details.description_blockheight')}
|
||||
</div>
|
||||
<div className="text-xl">{importDetails.blockheight}</div>
|
||||
<div className="text-xl">{importDetails.blockheight.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>{t('import_wallet.import_details.label_gaplimit')}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('import_wallet.import_details.description_gaplimit')}
|
||||
</div>
|
||||
<div className="text-xl">{importDetails.gaplimit}</div>
|
||||
<div className="text-xl">{importDetails.gaplimit.toLocaleString()}</div>
|
||||
</div>
|
||||
{showGaplimitWarning && (
|
||||
<Alert variant="warning">
|
||||
|
|
@ -150,15 +150,22 @@ export const ImportStepConfirm = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.revealSensitiveInfo !== undefined} orientation="horizontal">
|
||||
<Field
|
||||
data-invalid={errors.revealSensitiveInfo !== undefined}
|
||||
orientation="horizontal"
|
||||
className="items-start"
|
||||
>
|
||||
<Switch
|
||||
className="mt-0.5 shrink-0"
|
||||
id="switch-reveal-seed"
|
||||
checked={revealSensitiveInfo}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('revealSensitiveInfo', checked, { shouldValidate: true, shouldTouch: true })
|
||||
}
|
||||
/>
|
||||
<FieldLabel htmlFor="switch-reveal-seed">{t('create_wallet.confirmation_toggle_reveal_info')}</FieldLabel>
|
||||
<FieldLabel htmlFor="switch-reveal-seed" className="min-w-0 flex-1">
|
||||
{t('create_wallet.confirmation_toggle_reveal_info')}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
{errors.revealSensitiveInfo?.message && (
|
||||
<div className="text-destructive text-xs">{errors.revealSensitiveInfo.message}</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { ComponentProps } from 'react'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -9,7 +8,6 @@ import { ImportDetailsForm } from './ImportDetailsForm'
|
|||
import { RescanActiveAlert } from './RescanActiveAlert'
|
||||
|
||||
type ImportStepImportDetailsProps = ComponentProps<typeof ImportDetailsForm> & {
|
||||
sessionInfo: SessionResponse | undefined
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
|
|
@ -21,10 +19,13 @@ export const ImportStepImportDetails = ({ sessionInfo, onBack, ...importFormProp
|
|||
return (
|
||||
<div className="space-y-2">
|
||||
{isSessionActive && (
|
||||
<OtherWalletActiveAlert linkTarget={'login'} walletFileName={sessionInfo.wallet_name as WalletFileName} />
|
||||
<OtherWalletActiveAlert
|
||||
linkTarget={'login'}
|
||||
walletFileName={sessionInfo.wallet_name as WalletFileName | undefined}
|
||||
/>
|
||||
)}
|
||||
{isRescanActive && <RescanActiveAlert linkTarget={'login'} />}
|
||||
{showForm && <ImportDetailsForm {...importFormProps} />}
|
||||
{showForm && <ImportDetailsForm sessionInfo={sessionInfo} {...importFormProps} />}
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
<ChevronLeftIcon />
|
||||
{t('global.back')}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ComponentProps } from 'react'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CreateWalletForm } from '@/components/create/CreateWalletForm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -13,23 +14,26 @@ type ImportStepWalletDetailsProps = ComponentProps<typeof CreateWalletForm> & {
|
|||
}
|
||||
|
||||
export const ImportStepWalletDetails = ({ sessionInfo, ...createFormProps }: ImportStepWalletDetailsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const isSessionActive = sessionInfo?.session === true
|
||||
const isRescanActive = sessionInfo?.rescanning === true
|
||||
const showForm = !isSessionActive && !isRescanActive
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{isSessionActive && (
|
||||
<OtherWalletActiveAlert linkTarget={'login'} walletFileName={sessionInfo.wallet_name as WalletFileName} />
|
||||
<OtherWalletActiveAlert
|
||||
linkTarget={'login'}
|
||||
walletFileName={sessionInfo.wallet_name as WalletFileName | undefined}
|
||||
/>
|
||||
)}
|
||||
{isRescanActive && <RescanActiveAlert linkTarget={'login'} />}
|
||||
{showForm && <CreateWalletForm {...createFormProps} />}
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{/* TODO: i18n */}
|
||||
Already have a wallet?{' '}
|
||||
{t('create_wallet.text_login_hint')}{' '}
|
||||
<Button variant="link" asChild>
|
||||
<Link to={routes.login} className="font-semibold">
|
||||
Sign in here
|
||||
{t('create_wallet.button_login_link')}
|
||||
</Link>
|
||||
</Button>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => ({
|
|||
|
||||
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
configgetMutation: vi.fn(() => ({ mutationFn: mocks.configGet })),
|
||||
configsettingMutation: vi.fn(() => ({ mutationFn: mocks.configSet })),
|
||||
listwalletsOptions: vi.fn(() => ({ queryKey: ['wallets'], queryFn: vi.fn() })),
|
||||
|
|
@ -32,7 +32,7 @@ vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
|
|||
unlockwalletMutation: vi.fn(() => ({ mutationFn: mocks.unlockWallet })),
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({
|
||||
lockwallet: mocks.lockWallet,
|
||||
rescanblockchain: mocks.rescanBlockchain,
|
||||
session: mocks.session,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import {
|
|||
listwalletsOptions,
|
||||
recoverwalletMutation,
|
||||
unlockwalletMutation,
|
||||
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
|
||||
import { lockwallet, rescanblockchain, session } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
} from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
|
||||
import { lockwallet, rescanblockchain, session } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { CircleCheckBigIcon, KeyRoundIcon, WalletIcon, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
@ -50,8 +50,8 @@ const ImportWalletCard = ({
|
|||
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon className="text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-bold">{title}</CardTitle>
|
||||
{description !== undefined && <CardDescription>{description}</CardDescription>}
|
||||
<CardTitle className="text-center text-xl font-bold break-words">{title}</CardTitle>
|
||||
{description !== undefined && <CardDescription className="text-center">{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">{children}</CardContent>
|
||||
|
|
@ -253,7 +253,7 @@ const ImportWalletPage = () => {
|
|||
})
|
||||
toast.success(t('rescan_chain.success_rescan_started'))
|
||||
} catch (error: unknown) {
|
||||
const reason = getErrorReason(error, 'Unknown error.')
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
console.warn('Non-critical error while fetching session after wallet import. Continuing with import...', reason)
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +276,7 @@ const ImportWalletPage = () => {
|
|||
token: authState.auth.token,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const reason = getErrorReason(error, 'Unknown error.')
|
||||
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
|
||||
console.warn('Locking wallet attempt failed after import error.', reason)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@ import { BlocksIcon, BookOpenIcon, FileQuestionMarkIcon, ScrollTextIcon } from '
|
|||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { JmWebsocketInfo } from '@/components/ui/jam/JmWebsocketInfo'
|
||||
import { JAM_REPO_URL } from '@/constants/jam'
|
||||
import type { JmWebsocket } from '@/hooks/useJmWebsocket'
|
||||
import { BetaWarningDialog } from './footer/BetaWarningDialog'
|
||||
|
||||
type JmWebsocketInfo = Pick<JmWebsocket, 'isOpen' | 'isAuthenticated'>
|
||||
|
||||
type AppFooterProps = Pick<ComponentProps<typeof BetaWarningDialog>, 'jamVersion' | 'joinmarketVersion'> & {
|
||||
type AppFooterProps = Pick<
|
||||
ComponentProps<typeof BetaWarningDialog>,
|
||||
'jamVersion' | 'backendVersion' | 'backendName'
|
||||
> & {
|
||||
blockHeight?: number
|
||||
websocketInfo?: JmWebsocketInfo
|
||||
onClickCheatsheet: () => void
|
||||
|
|
@ -20,7 +24,8 @@ export function AppFooter({
|
|||
blockHeight,
|
||||
websocketInfo,
|
||||
jamVersion,
|
||||
joinmarketVersion,
|
||||
backendVersion,
|
||||
backendName,
|
||||
onClickCheatsheet,
|
||||
onClickOrderbook,
|
||||
onClickLogs,
|
||||
|
|
@ -34,27 +39,46 @@ export function AppFooter({
|
|||
<BetaWarningDialog
|
||||
open={isShowBetaWarning}
|
||||
onOpenChange={setShowBetaWarning}
|
||||
joinmarketVersion={joinmarketVersion}
|
||||
backendVersion={backendVersion}
|
||||
backendName={backendName}
|
||||
jamVersion={jamVersion}
|
||||
/>
|
||||
<footer>
|
||||
<div className="flex flex-col items-center justify-between gap-8 px-4 py-12 sm:flex-row sm:gap-2 sm:p-4">
|
||||
<div
|
||||
className="flex flex-col justify-start gap-2 sm:flex-row sm:items-center sm:justify-center"
|
||||
className="flex w-full flex-col justify-start gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-center"
|
||||
data-tour-id="footer-tools"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={onClickCheatsheet} title={t('footer.cheatsheet')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onClickCheatsheet}
|
||||
title={t('footer.cheatsheet')}
|
||||
>
|
||||
<FileQuestionMarkIcon />
|
||||
<span className="">{t('footer.cheatsheet')}</span>
|
||||
<span>{t('footer.cheatsheet')}</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClickOrderbook} title={t('footer.orderbook')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onClickOrderbook}
|
||||
title={t('footer.orderbook')}
|
||||
>
|
||||
<BookOpenIcon />
|
||||
<span className="">{t('footer.orderbook')}</span>
|
||||
<span>{t('footer.orderbook')}</span>
|
||||
</Button>
|
||||
{onClickLogs && (
|
||||
<Button variant="outline" size="sm" onClick={onClickLogs} title={t('footer.logs')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onClickLogs}
|
||||
title={t('footer.logs')}
|
||||
>
|
||||
<ScrollTextIcon />
|
||||
<span className="">{t('footer.logs')}</span>
|
||||
<span>{t('footer.logs')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -75,7 +99,7 @@ export function AppFooter({
|
|||
</span>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/joinmarket-webui/jam/tags"
|
||||
href={`${JAM_REPO_URL}/tags`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground underline"
|
||||
|
|
@ -84,7 +108,7 @@ export function AppFooter({
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="order-last flex-1 text-xs sm:order-first">
|
||||
<div className="order-last min-w-0 flex-1 text-center text-xs break-words sm:order-first sm:text-left">
|
||||
<Trans i18nKey="footer.warning">
|
||||
This is pre-alpha software.
|
||||
<Button
|
||||
|
|
|
|||
85
src/components/layout/AppNavbar.test.tsx
Normal file
85
src/components/layout/AppNavbar.test.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AppNavbar } from './AppNavbar'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={String(to)}>{children}</a>,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: () => ({ mutateAsync: vi.fn().mockResolvedValue(undefined), isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
|
||||
}))
|
||||
|
||||
const LOCK_WALLET_LABEL = 'settings.button_lock_wallet'
|
||||
const LOGOUT_LABEL = 'navbar.button_logout'
|
||||
|
||||
const defaults: ComponentProps<typeof AppNavbar> = {
|
||||
theme: 'dark',
|
||||
toggleTheme: vi.fn(),
|
||||
onLogout: vi.fn().mockResolvedValue(undefined),
|
||||
onLockWallet: vi.fn().mockResolvedValue(undefined),
|
||||
walletName: 'Satoshi',
|
||||
totalBalance: 21_000_000,
|
||||
}
|
||||
|
||||
const queryLockWalletButton = () => screen.queryByRole('button', { name: LOCK_WALLET_LABEL })
|
||||
const queryLogoutButton = () => screen.queryByRole('button', { name: LOGOUT_LABEL })
|
||||
|
||||
describe('AppNavbar lock/logout toggle', () => {
|
||||
it('shows "lock wallet" and hides "logout" when idle', () => {
|
||||
render(<AppNavbar {...defaults} />)
|
||||
|
||||
expect(queryLockWalletButton()).toBeInTheDocument()
|
||||
expect(queryLogoutButton()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "logout" and hides "lock wallet" while the maker is running', () => {
|
||||
render(
|
||||
<AppNavbar
|
||||
{...defaults}
|
||||
sessionInfo={{ maker_running: true, coinjoin_in_process: false, schedule: undefined }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(queryLogoutButton()).toBeInTheDocument()
|
||||
expect(queryLockWalletButton()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "logout" and hides "lock wallet" while a taker coinjoin is running', () => {
|
||||
render(
|
||||
<AppNavbar
|
||||
{...defaults}
|
||||
sessionInfo={{ maker_running: false, coinjoin_in_process: true, schedule: undefined }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(queryLogoutButton()).toBeInTheDocument()
|
||||
expect(queryLockWalletButton()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "logout" and hides "lock wallet" while the scheduler is running', () => {
|
||||
render(<AppNavbar {...defaults} sessionInfo={{ maker_running: false, coinjoin_in_process: true, schedule: [] }} />)
|
||||
|
||||
expect(queryLogoutButton()).toBeInTheDocument()
|
||||
expect(queryLockWalletButton()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "logout" and hides "lock wallet" while a rescan is in progress', () => {
|
||||
render(<AppNavbar {...defaults} rescanInfo={{ rescanning: true, progress: undefined, updatedAt: Date.now() }} />)
|
||||
|
||||
expect(queryLogoutButton()).toBeInTheDocument()
|
||||
expect(queryLockWalletButton()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { LockKeyholeIcon, LogOutIcon, PackageSearchIcon, SettingsIcon, ShuffleIcon, WalletIcon } from 'lucide-react'
|
||||
|
|
@ -6,16 +7,15 @@ import { useTranslation } from 'react-i18next'
|
|||
import { Link, useNavigate, type NavigateFunction } from 'react-router-dom'
|
||||
import { DevBadge } from '@/components/dev/DevBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
import { ThemeToggleButton } from '@/components/ui/jam/ThemeToggleButton'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SidebarContextProps } from '@/components/ui/use-sidebar'
|
||||
import { isDevMode } from '@/constants/debugFeatures'
|
||||
import { routes } from '@/constants/routes'
|
||||
import type { RescanInfo } from '@/context/JamSessionInfoContext'
|
||||
import { cn, shortenStringMiddle } from '@/lib/utils'
|
||||
import type { AmountSats } from '@/types/global'
|
||||
import { WithActivityIndicator } from '../ui/jam/ActivityIndicator'
|
||||
import { Balance } from '../ui/jam/Balance'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ type WalletPreviewProps = {
|
|||
walletName: string | null
|
||||
totalBalance: AmountSats
|
||||
rescanInfo?: RescanInfo
|
||||
development?: boolean
|
||||
}
|
||||
|
||||
const WalletPreview = ({
|
||||
|
|
@ -33,13 +34,14 @@ const WalletPreview = ({
|
|||
isLoading = false,
|
||||
isReloading = false,
|
||||
rescanInfo,
|
||||
development = false,
|
||||
}: WalletPreviewProps) => {
|
||||
const { t } = useTranslation()
|
||||
const walletNameTitle = shortenStringMiddle(walletName ?? '...', 12)
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center">
|
||||
<Link to={routes.home} className="flex items-center gap-2" data-tour-id="wallet-preview">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Link to={routes.home} className="flex min-w-0 items-center gap-2" data-tour-id="wallet-preview">
|
||||
<div className="flex h-8 w-8 items-center">
|
||||
{isLoading || isReloading ? (
|
||||
<Spinner className="text-muted-foreground size-6 motion-reduce:hidden" strokeWidth={3} />
|
||||
|
|
@ -47,7 +49,7 @@ const WalletPreview = ({
|
|||
<WalletIcon strokeWidth={1} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.25 leading-none">
|
||||
<div className="flex min-w-0 flex-col gap-0.25 leading-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="font-semibold tracking-tight"
|
||||
|
|
@ -55,21 +57,21 @@ const WalletPreview = ({
|
|||
>
|
||||
{walletNameTitle}
|
||||
</div>
|
||||
{isDevMode() && <DevBadge />}
|
||||
{development && <DevBadge />}
|
||||
</div>
|
||||
<div className="flex min-h-5 min-w-[150px] items-center">
|
||||
<div className="flex min-h-5 min-w-0 items-center sm:min-w-[150px]">
|
||||
{rescanInfo?.rescanning === true ? (
|
||||
<div className="cursor-wait motion-safe:animate-pulse">
|
||||
{rescanInfo.progress !== undefined
|
||||
? t('navbar.text_rescan_in_progress_with_progress', {
|
||||
progress: Math.floor(rescanInfo.progress * 100),
|
||||
})
|
||||
: t('navbar.text_rescan_in_progress')}
|
||||
{rescanInfo.progressInPercentage === undefined
|
||||
? t('navbar.text_rescan_in_progress')
|
||||
: t('navbar.text_rescan_in_progress_with_progress', {
|
||||
progress: rescanInfo.progressInPercentage,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-4 w-full bg-neutral-200 dark:bg-neutral-600" />
|
||||
<Skeleton className="bg-brand-nav-foreground/25 h-4 w-full" />
|
||||
) : (
|
||||
<Balance valueString={String(totalBalance)} />
|
||||
)}
|
||||
|
|
@ -94,6 +96,7 @@ type AppNavbarProps = WalletPreviewProps & {
|
|||
sessionInfo?: SessionInfo
|
||||
sidebarInfo?: SidebarInfo
|
||||
rescanInfo?: RescanInfo
|
||||
tooltipSide?: ComponentProps<typeof TooltipContent>['side']
|
||||
}
|
||||
|
||||
export function AppNavbar({
|
||||
|
|
@ -109,6 +112,8 @@ export function AppNavbar({
|
|||
sessionInfo,
|
||||
sidebarInfo,
|
||||
rescanInfo,
|
||||
tooltipSide,
|
||||
development,
|
||||
}: AppNavbarProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -120,6 +125,10 @@ export function AppNavbar({
|
|||
const singleCoinJoinRunning = sessionInfo?.coinjoin_in_process === true && !sessionInfo?.schedule
|
||||
const schedulerRunning = sessionInfo?.coinjoin_in_process === true && !!sessionInfo?.schedule
|
||||
|
||||
// While a maker/taker/rescan is in progress the wallet cannot be locked, so
|
||||
// only offer "logout"; otherwise offer "lock wallet".
|
||||
const serviceRunning = makerRunning || sessionInfo?.coinjoin_in_process === true || rescanInfo?.rescanning === true
|
||||
|
||||
const joiningRoute = (() => {
|
||||
if (schedulerRunning) return routes.sweep
|
||||
if (singleCoinJoinRunning) return routes.send
|
||||
|
|
@ -145,17 +154,18 @@ export function AppNavbar({
|
|||
})
|
||||
|
||||
return (
|
||||
<header className="light:bg-gray-100 light:text-black flex items-center justify-between bg-[#23262b] px-4 py-2 text-white transition-colors duration-300">
|
||||
<header className="bg-brand-nav text-brand-nav-foreground flex items-center justify-between px-4 py-2 transition-colors duration-300">
|
||||
<WalletPreview
|
||||
isLoading={isLoading}
|
||||
isReloading={isReloading}
|
||||
walletName={walletName}
|
||||
totalBalance={totalBalance}
|
||||
rescanInfo={rescanInfo}
|
||||
development={development}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'hidden min-w-0 flex-1 items-center justify-center text-sm font-semibold md:gap-6 lg:gap-12 lg:text-base',
|
||||
'text-brand-nav-foreground/80 hidden min-w-0 flex-1 items-center justify-center text-sm font-semibold md:gap-6 lg:gap-12 lg:text-base',
|
||||
{
|
||||
'md:flex': !isSidebarOpen,
|
||||
'lg:flex': isSidebarOpen,
|
||||
|
|
@ -163,20 +173,17 @@ export function AppNavbar({
|
|||
},
|
||||
)}
|
||||
>
|
||||
<Link to={rescanningRoute ? '#' : routes.receive} className="text-muted-foreground hover:text-foreground">
|
||||
<Link to={rescanningRoute ? '#' : routes.receive} className="hover:text-brand-nav-foreground">
|
||||
{t('navbar.tab_receive')}
|
||||
</Link>
|
||||
<Link to={rescanningRoute ? '#' : routes.send} className="text-muted-foreground hover:text-foreground relative">
|
||||
<Link to={rescanningRoute ? '#' : routes.send} className="hover:text-brand-nav-foreground relative">
|
||||
<WithActivityIndicator active={singleCoinJoinRunning}>{t('navbar.tab_send')}</WithActivityIndicator>
|
||||
</Link>
|
||||
<Link to={rescanningRoute ? '#' : routes.earn} className="text-muted-foreground hover:text-foreground relative">
|
||||
<Link to={rescanningRoute ? '#' : routes.earn} className="hover:text-brand-nav-foreground relative">
|
||||
<WithActivityIndicator active={makerRunning}>{t('navbar.tab_earn')}</WithActivityIndicator>
|
||||
</Link>
|
||||
<span className="text-gray-400 dark:text-gray-600">|</span>
|
||||
<Link
|
||||
to={rescanningRoute ? '#' : routes.sweep}
|
||||
className="text-muted-foreground hover:text-foreground relative"
|
||||
>
|
||||
<span className="text-border select-none">|</span>
|
||||
<Link to={rescanningRoute ? '#' : routes.sweep} className="hover:text-brand-nav-foreground relative">
|
||||
<WithActivityIndicator active={schedulerRunning}>{t('navbar.tab_sweep')}</WithActivityIndicator>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -185,7 +192,7 @@ export function AppNavbar({
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="light:text-green-600 text-green-300"
|
||||
className="text-brand-success/80 hover:text-brand-success"
|
||||
variant="ghost-navbar"
|
||||
size="icon"
|
||||
onClick={() => void navigate(rescanningRoute)}
|
||||
|
|
@ -194,14 +201,14 @@ export function AppNavbar({
|
|||
<PackageSearchIcon className="motion-safe:animate-pulse" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('navbar.text_rescan_in_progress')}</TooltipContent>
|
||||
<TooltipContent side={tooltipSide}>{t('navbar.text_rescan_in_progress')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{joiningRoute && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="light:text-green-600 text-green-300"
|
||||
className="text-brand-success/80 hover:text-brand-success"
|
||||
variant="ghost-navbar"
|
||||
size="icon"
|
||||
onClick={() => void navigate(joiningRoute)}
|
||||
|
|
@ -210,10 +217,17 @@ export function AppNavbar({
|
|||
<ShuffleIcon className="motion-safe:animate-pulse" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('navbar.joining_in_progress')}</TooltipContent>
|
||||
<TooltipContent side={tooltipSide}>{t('navbar.joining_in_progress')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ThemeToggleButton className="hidden sm:flex" variant="ghost-navbar" theme={theme} onClick={toggleTheme} />
|
||||
|
||||
<ThemeToggleButton
|
||||
className="hidden sm:flex"
|
||||
variant="ghost-navbar"
|
||||
theme={theme}
|
||||
onClick={toggleTheme}
|
||||
tooltipSide={tooltipSide}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -227,38 +241,43 @@ export function AppNavbar({
|
|||
<SettingsIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('navbar.menu_mobile_settings')}</TooltipContent>
|
||||
<TooltipContent side={tooltipSide}>{t('navbar.menu_mobile_settings')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="hidden sm:flex"
|
||||
variant="ghost-navbar"
|
||||
size="icon"
|
||||
onClick={() => void lockWalletMutation.mutateAsync({ navigate, t })}
|
||||
aria-label={t('settings.button_lock_wallet')}
|
||||
disabled={lockWalletMutation.isPending}
|
||||
>
|
||||
{lockWalletMutation.isPending ? <Spinner /> : <LockKeyholeIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('settings.button_lock_wallet')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="hidden sm:flex"
|
||||
variant="ghost-navbar"
|
||||
size="icon"
|
||||
onClick={() => void logoutMutation.mutateAsync({ navigate })}
|
||||
aria-label={/* TODO: i18n */ 'Logout'}
|
||||
>
|
||||
<LogOutIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{/* TODO: i18n */ 'Logout'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{!serviceRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="hidden sm:flex"
|
||||
variant="ghost-navbar"
|
||||
size="icon"
|
||||
onClick={() => void lockWalletMutation.mutateAsync({ navigate, t })}
|
||||
aria-label={t('settings.button_lock_wallet')}
|
||||
disabled={lockWalletMutation.isPending}
|
||||
>
|
||||
{lockWalletMutation.isPending ? <Spinner /> : <LockKeyholeIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>{t('settings.button_lock_wallet')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{serviceRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="hidden sm:flex"
|
||||
variant="ghost-navbar"
|
||||
size="icon"
|
||||
onClick={() => void logoutMutation.mutateAsync({ navigate })}
|
||||
aria-label={t('navbar.button_logout')}
|
||||
disabled={logoutMutation.isPending}
|
||||
>
|
||||
{logoutMutation.isPending ? <Spinner /> : <LogOutIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>{t('navbar.button_logout')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{sidebarTrigger}
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ vi.mock('@/hooks/useFeatures', () => ({
|
|||
|
||||
vi.mock('@/store/jamSettingsStore', () => ({
|
||||
useDeveloperMode: () => ({ enabled: mocks.developerMode }),
|
||||
usePreviewFeatures: () => ({
|
||||
'tx-history': true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../dev/DevBadge', () => ({
|
||||
|
|
@ -98,22 +101,22 @@ describe('AppSidebar', () => {
|
|||
|
||||
render(<AppSidebar side="left" />)
|
||||
|
||||
expect(screen.getByText('navbar.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Home')).toBeInTheDocument()
|
||||
expect(screen.getByText('sidebar.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('sidebar.item_home.label')).toBeInTheDocument()
|
||||
expect(screen.getByText('navbar.tab_receive')).toBeInTheDocument()
|
||||
expect(screen.getByText('navbar.tab_send')).toBeInTheDocument()
|
||||
expect(screen.getByText('navbar.tab_earn')).toBeInTheDocument()
|
||||
expect(screen.getByText('Earn Report')).toBeInTheDocument()
|
||||
expect(screen.getByText('Orderbook')).toBeInTheDocument()
|
||||
expect(screen.getByText('navbar.menu_mobile_settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('settings.rescan_chain')).toBeInTheDocument()
|
||||
expect(screen.queryByText('settings.show_logs')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('sidebar.item_earn_report.label')).toBeInTheDocument()
|
||||
expect(screen.getByText('sidebar.item_orderbook.label')).toBeInTheDocument()
|
||||
expect(screen.getByText('sidebar.item_settings.label')).toBeInTheDocument()
|
||||
expect(screen.getByText('sidebar.item_rescan.label')).toBeInTheDocument()
|
||||
expect(screen.queryByText('sidebar.item_logs.label')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Development')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTitle('global.close'))
|
||||
expect(mocks.toggleSidebar).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByText('Tour'))
|
||||
fireEvent.click(screen.getByText('sidebar.item_tour.label'))
|
||||
expect(mocks.toggleSidebar).toHaveBeenCalledTimes(2)
|
||||
expect(onTour).toHaveBeenCalledTimes(1)
|
||||
|
||||
|
|
@ -128,7 +131,7 @@ describe('AppSidebar', () => {
|
|||
|
||||
render(<AppSidebar side="right" />)
|
||||
|
||||
expect(screen.getByText('settings.show_logs')).toBeInTheDocument()
|
||||
expect(screen.getByText('sidebar.item_logs.label')).toBeInTheDocument()
|
||||
expect(screen.getByText('Development')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dev Page')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dev Setup')).toBeInTheDocument()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
CurlyBracesIcon,
|
||||
DownloadIcon,
|
||||
HandCoinsIcon,
|
||||
HistoryIcon,
|
||||
LogsIcon,
|
||||
MilkIcon,
|
||||
NotebookTabsIcon,
|
||||
|
|
@ -40,68 +41,80 @@ import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
|
|||
import { POST_LOGIN_TOUR_EVENT } from '@/constants/onboarding'
|
||||
import { routes } from '@/constants/routes'
|
||||
import { useFeatures } from '@/hooks/useFeatures'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import { useDeveloperMode, usePreviewFeatures } from '@/store/jamSettingsStore'
|
||||
import { DevBadge } from '../dev/DevBadge'
|
||||
import { Badge } from '../ui/badge'
|
||||
|
||||
export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>, 'side'>) {
|
||||
const { t } = useTranslation()
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
const { enabled: isDeveloperMode } = useDeveloperMode()
|
||||
const previewFeatures = usePreviewFeatures()
|
||||
|
||||
const { isFeatureEnabled } = useFeatures()
|
||||
const mainItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_home.label')*/ 'Home',
|
||||
title: t('sidebar.item_home.label'),
|
||||
url: routes.home,
|
||||
icon: WalletIcon,
|
||||
},
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_receive'),
|
||||
title: t('navbar.tab_receive'),
|
||||
url: routes.receive,
|
||||
icon: DownloadIcon,
|
||||
},
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_send'),
|
||||
title: t('navbar.tab_send'),
|
||||
url: routes.send,
|
||||
icon: UploadIcon,
|
||||
},
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_earn'),
|
||||
title: t('navbar.tab_earn'),
|
||||
url: routes.earn,
|
||||
icon: HandCoinsIcon,
|
||||
subitems: [
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_earn_report.label')*/ 'Earn Report',
|
||||
title: t('sidebar.item_earn_report.label'),
|
||||
url: routes.earnReport,
|
||||
icon: NotebookTabsIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_sweep'),
|
||||
title: t('navbar.tab_sweep'),
|
||||
url: routes.sweep,
|
||||
icon: BrushCleaningIcon,
|
||||
},
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_orderbook.label')*/ 'Orderbook',
|
||||
title: t('sidebar.item_orderbook.label'),
|
||||
url: routes.orderbook,
|
||||
icon: BookOpenIcon,
|
||||
},
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_jars.label')*/ 'Jars',
|
||||
title: t('sidebar.item_jars.label'),
|
||||
url: routes.walletJarsDetails,
|
||||
icon: MilkIcon,
|
||||
},
|
||||
...(previewFeatures?.['tx-history'] !== true
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: t('sidebar.item_history.label'),
|
||||
url: routes.txHistory,
|
||||
icon: HistoryIcon,
|
||||
preview: true,
|
||||
},
|
||||
]),
|
||||
],
|
||||
[t],
|
||||
[t, previewFeatures],
|
||||
)
|
||||
|
||||
const settingsItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_tour.label')*/ 'Tour',
|
||||
title: t('sidebar.item_tour.label'),
|
||||
url: routes.home,
|
||||
icon: SparklesIcon,
|
||||
onClick: () => {
|
||||
|
|
@ -110,7 +123,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
|
|||
},
|
||||
},
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_rescan.label')*/ t('settings.rescan_chain'),
|
||||
title: t('sidebar.item_rescan.label'),
|
||||
url: routes.rescan,
|
||||
icon: PackageSearchIcon,
|
||||
},
|
||||
|
|
@ -118,7 +131,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
|
|||
? []
|
||||
: [
|
||||
{
|
||||
title: /*TODO: i18n t('sidebar.item_settings.label')*/ t('settings.show_logs'),
|
||||
title: t('sidebar.item_logs.label'),
|
||||
url: routes.logs,
|
||||
icon: LogsIcon,
|
||||
},
|
||||
|
|
@ -167,10 +180,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
|
|||
<Sidebar side={side} variant="sidebar" collapsible="offcanvas">
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
{/*TODO: i18n t('sidebar.title')*/}
|
||||
{t('navbar.title')}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t('sidebar.title')}</SidebarGroupLabel>
|
||||
<SidebarGroupAction className="cursor-pointer" title={t('global.close')} onClick={() => toggleSidebar()}>
|
||||
<XIcon />
|
||||
<span className="sr-only">{t('global.close')}</span>
|
||||
|
|
@ -183,6 +193,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
|
|||
<Link to={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
{item.preview ? <Badge variant="muted">{/* TODO: i18n */ 'Preview'}</Badge> : null}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{item.subitems?.length && (
|
||||
|
|
@ -212,8 +223,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
|
|||
<SidebarMenuButton asChild>
|
||||
<Link to={routes.settings}>
|
||||
<SettingsIcon />
|
||||
{/*TODO: i18n t('sidebar.item_settings.label')*/}
|
||||
<span>{t('navbar.menu_mobile_settings')}</span>
|
||||
<span>{t('sidebar.item_settings.label')}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuSub>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,6 @@ describe('AuthPageShell', () => {
|
|||
|
||||
const child = screen.getByTestId('child')
|
||||
expect(child).toBeInTheDocument()
|
||||
expect(child.parentElement).toHaveClass('from-background', 'to-muted', 'flex', 'min-h-screen')
|
||||
expect(child.parentElement).toHaveClass('from-background', 'to-muted', 'flex', 'flex-1')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react'
|
|||
|
||||
export const AuthPageShell = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className="from-background to-muted flex min-h-screen items-center justify-center bg-gradient-to-br p-4">
|
||||
<div className="from-background to-muted flex flex-1 items-start justify-center bg-gradient-to-br p-4 sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WalletFileName } from '@/lib/utils'
|
||||
import type { useQueryJamInfo } from '@/hooks/useQueryJamInfo'
|
||||
import { parseSemanticVersion, UNKNOWN_VERSION, type WalletFileName } from '@/lib/utils'
|
||||
import { Layout } from './Layout'
|
||||
|
||||
type SessionState = {
|
||||
|
|
@ -16,12 +17,14 @@ const mocks = vi.hoisted(() => ({
|
|||
pathname: '/',
|
||||
setTheme: vi.fn(),
|
||||
theme: 'dark',
|
||||
backendInfo: undefined as ReturnType<typeof useQueryJamInfo> | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
Trans: ({ i18nKey, children }: { i18nKey?: string; children?: React.ReactNode }) => children ?? i18nKey ?? null,
|
||||
}))
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
|
|
@ -88,19 +91,21 @@ vi.mock('@/components/layout/AppNavbar', () => ({
|
|||
vi.mock('@/components/layout/AppFooter', () => ({
|
||||
AppFooter: ({
|
||||
blockHeight,
|
||||
joinmarketVersion,
|
||||
backendVersion,
|
||||
backendName,
|
||||
onClickCheatsheet,
|
||||
onClickLogs,
|
||||
onClickOrderbook,
|
||||
}: {
|
||||
blockHeight?: number
|
||||
joinmarketVersion?: string
|
||||
backendVersion?: { raw?: string }
|
||||
backendName?: string
|
||||
onClickCheatsheet: () => void
|
||||
onClickLogs?: () => void
|
||||
onClickOrderbook: () => void
|
||||
}) => (
|
||||
<footer>
|
||||
footer:{blockHeight}:{joinmarketVersion}
|
||||
footer;blockHeight:={blockHeight};backenVersion:={backendVersion?.raw};backendName:={backendName}
|
||||
<button type="button" onClick={onClickCheatsheet}>
|
||||
open-cheatsheet
|
||||
</button>
|
||||
|
|
@ -166,10 +171,15 @@ vi.mock('@/hooks/useFeatures', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useQueryJmInfo', () => ({
|
||||
useQueryJmInfo: () => ({
|
||||
version: 'jm-version',
|
||||
}),
|
||||
vi.mock('@/hooks/useQueryJamInfo', () => ({
|
||||
useQueryJamInfo: () => {
|
||||
return (
|
||||
mocks.backendInfo ?? {
|
||||
backendName: 'mocked',
|
||||
backendVersion: UNKNOWN_VERSION,
|
||||
}
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/Cheatsheet', () => ({
|
||||
|
|
@ -203,6 +213,7 @@ describe('Layout', () => {
|
|||
mocks.navigate.mockReset()
|
||||
mocks.onCheatsheetOpenChange.mockReset()
|
||||
mocks.setTheme.mockReset()
|
||||
mocks.backendInfo = undefined
|
||||
})
|
||||
|
||||
it('wires navbar, footer actions, overlays, and shortcuts', async () => {
|
||||
|
|
@ -216,8 +227,7 @@ describe('Layout', () => {
|
|||
)
|
||||
|
||||
expect(screen.getByText('page-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('navbar:dark:test-wallet:9876:123')).toBeInTheDocument()
|
||||
expect(screen.getByText('footer:123:jm-version')).toBeInTheDocument()
|
||||
expect(screen.getByText('footer;blockHeight:=123;backenVersion:=unknown;backendName:=mocked')).toBeInTheDocument()
|
||||
expect(screen.getByText('sidebar:right')).toBeInTheDocument()
|
||||
expect(screen.getByText('tour-enabled:true')).toBeInTheDocument()
|
||||
|
||||
|
|
@ -245,6 +255,20 @@ describe('Layout', () => {
|
|||
expect(screen.getByText('logs-overlay:false')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses standalone backend label with native version fallback when standalone version is not semantic', () => {
|
||||
mocks.backendInfo = { backendName: 'joinmarket-ng', backendVersion: parseSemanticVersion('0.34.2') }
|
||||
|
||||
render(
|
||||
<Layout walletFileName={walletFileName} onLogout={vi.fn()} onLockWallet={vi.fn()}>
|
||||
<section>page-content</section>
|
||||
</Layout>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText('footer;blockHeight:=123;backenVersion:=0.34.2;backendName:=joinmarket-ng'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables logs and onboarding when the route or feature state requires it', () => {
|
||||
mocks.cheatsheetOpen = true
|
||||
mocks.logsFeature = false
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue