initialize Jam V2
Co-authored-by: theborakompanioni <theborakompanioni+github@gmail.com>
2
.env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
VITE_JM_API_BASE_URL=/api/v1
|
||||
VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS=1800
|
||||
1
.env.development
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS=15
|
||||
21
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -4,7 +4,6 @@ about: Create a report to help us improve
|
|||
title: ''
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Hey there! Thank you for reporting an issue – your feedback is invaluable to open-source software development, and all contributions are greatly appreciated.
|
||||
|
|
@ -12,14 +11,14 @@ Hey there! Thank you for reporting an issue – your feedback is invaluable to o
|
|||
🚨 Important warning: Be cautious of scammers in the comments. 🚨
|
||||
|
||||
Red flags to watch out for:
|
||||
|
||||
- Requests to email "official support" or links to external websites
|
||||
- Responses from recently created accounts, often lacking profile pictures
|
||||
- Inquiries for personal information that is not relevant to resolving your issue
|
||||
|
||||
As long as you avoid sharing sensitive information (such as your seed phrase), you should be safe.
|
||||
As long as you avoid sharing sensitive information (such as your seed phrase), you should be safe.
|
||||
However, it's best to avoid engaging with any suspicious comments.
|
||||
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
|
@ -29,17 +28,19 @@ A clear and concise description of what you expected to happen.
|
|||
A clear and concise description of what the bug is and what actually happens.
|
||||
|
||||
**Steps to reproduce the problem**
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Specifications**
|
||||
- Version:
|
||||
- Platform:
|
||||
- Browser:
|
||||
|
||||
- Version:
|
||||
- Platform:
|
||||
- Browser:
|
||||
|
||||
**Additional context**
|
||||
|
||||
Add any other context about the problem here to help explain your problem, e.g. error logs or screenshots if applicable.
|
||||
Add any other context about the problem here to help explain your problem, e.g. error logs or screenshots if applicable.
|
||||
|
||||
⚠️ Make sure to remove any sensitive information before sharing screenshots or logs. ⚠️
|
||||
|
|
|
|||
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -4,7 +4,6 @@ about: Suggest an idea
|
|||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Hey there! Thank you for proposing a new feature – your feedback is invaluable to open-source software development, and all contributions are greatly appreciated.
|
||||
|
|
@ -12,14 +11,14 @@ Hey there! Thank you for proposing a new feature – your feedback is invaluable
|
|||
🚨 Important warning: Be cautious of scammers in the comments. 🚨
|
||||
|
||||
Red flags to watch out for:
|
||||
|
||||
- Requests to email "official support" or links to external websites
|
||||
- Responses from recently created accounts, often lacking profile pictures
|
||||
- Inquiries for personal information that is not relevant to resolving your issue
|
||||
|
||||
As long as you avoid sharing sensitive information (such as your seed phrase), you should be safe.
|
||||
As long as you avoid sharing sensitive information (such as your seed phrase), you should be safe.
|
||||
However, it's best to avoid engaging with any suspicious comments.
|
||||
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
|
||||
A clear and concise description of what the problem is. Example: I'm always frustrated when [...]
|
||||
|
|
|
|||
15
.github/workflows/build.yml
vendored
|
|
@ -28,9 +28,22 @@ jobs:
|
|||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
# Install
|
||||
- name: Install
|
||||
run: npm ci
|
||||
- name: Get installed Playwright version
|
||||
id: playwright-version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').dependencies['@playwright/test'].version)")" >> $GITHUB_ENV
|
||||
- name: Cache Playwright
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||
- run: npx playwright install --with-deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
- run: npx playwright install-deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
# Checks
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
|
|
|||
50
.gitignore
vendored
|
|
@ -1,23 +1,31 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
.idea/
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
|
|
|||
1
.husky/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
_
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
npx lint-staged && npm test
|
||||
|
|
|
|||
2
.npmrc
|
|
@ -1,2 +0,0 @@
|
|||
git-tag-version = false
|
||||
engine-strict = true
|
||||
1
.nvmrc
|
|
@ -1 +0,0 @@
|
|||
v22.11.0
|
||||
11
.storybook/main.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { StorybookConfig } from '@storybook/react-vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: ['@chromatic-com/storybook', '@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-vitest'],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
}
|
||||
export default config
|
||||
22
.storybook/preview.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import '../src/index.css'
|
||||
import type { Preview } from '@storybook/react-vite'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default preview
|
||||
7
.storybook/vitest.setup.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'
|
||||
import { setProjectAnnotations } from '@storybook/react-vite'
|
||||
import * as projectAnnotations from './preview'
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations])
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
git:
|
||||
filters:
|
||||
- filter_type: file
|
||||
file_format: KEYVALUEJSON
|
||||
source_file: src/i18n/locales/en/translation.json
|
||||
source_language: en
|
||||
translation_files_expression: src/i18n/locales/<lang>/translation.json
|
||||
settings:
|
||||
pr_branch_name: tx_translations_<br_unique_id>
|
||||
431
CHANGELOG.md
|
|
@ -9,363 +9,364 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
#### Added
|
||||
|
||||
* ability to select jar when unlocking fidelity bond ([#574](https://github.com/joinmarket-webui/jam/issues/574)) ([#885](https://github.com/joinmarket-webui/jam/issues/885)) ([1f2acd2](https://github.com/joinmarket-webui/jam/commit/1f2acd2322f099b6a21eb30c14764b87e0f1ee45))
|
||||
* add color-coded checkboxes for UTXO states in Send view ([#878](https://github.com/joinmarket-webui/jam/issues/878)) ([6baf92e](https://github.com/joinmarket-webui/jam/commit/6baf92ecf459e1c4bd6398a28abf3e01bd46c721))
|
||||
* fast theme toggle in navbar ([#904](https://github.com/joinmarket-webui/jam/issues/904)) ([69f65fa](https://github.com/joinmarket-webui/jam/commit/69f65fa3bd376e10245c3bd9034f43cd2b20fddd))
|
||||
* orderbook hard refresh ([#906](https://github.com/joinmarket-webui/jam/issues/906)) ([83ef6d2](https://github.com/joinmarket-webui/jam/commit/83ef6d2505b2c35ebe2e364cb92424c74a9fa7d6))
|
||||
* show progress during rescan ([#894](https://github.com/joinmarket-webui/jam/issues/894)) ([c608c58](https://github.com/joinmarket-webui/jam/commit/c608c583aa0ba53bd470743a09a0005bc12935bf))
|
||||
- ability to select jar when unlocking fidelity bond ([#574](https://github.com/joinmarket-webui/jam/issues/574)) ([#885](https://github.com/joinmarket-webui/jam/issues/885)) ([1f2acd2](https://github.com/joinmarket-webui/jam/commit/1f2acd2322f099b6a21eb30c14764b87e0f1ee45))
|
||||
- add color-coded checkboxes for UTXO states in Send view ([#878](https://github.com/joinmarket-webui/jam/issues/878)) ([6baf92e](https://github.com/joinmarket-webui/jam/commit/6baf92ecf459e1c4bd6398a28abf3e01bd46c721))
|
||||
- fast theme toggle in navbar ([#904](https://github.com/joinmarket-webui/jam/issues/904)) ([69f65fa](https://github.com/joinmarket-webui/jam/commit/69f65fa3bd376e10245c3bd9034f43cd2b20fddd))
|
||||
- orderbook hard refresh ([#906](https://github.com/joinmarket-webui/jam/issues/906)) ([83ef6d2](https://github.com/joinmarket-webui/jam/commit/83ef6d2505b2c35ebe2e364cb92424c74a9fa7d6))
|
||||
- show progress during rescan ([#894](https://github.com/joinmarket-webui/jam/issues/894)) ([c608c58](https://github.com/joinmarket-webui/jam/commit/c608c583aa0ba53bd470743a09a0005bc12935bf))
|
||||
|
||||
#### Fixed
|
||||
|
||||
* relative fee greater than zero ([#862](https://github.com/joinmarket-webui/jam/issues/862)) ([5c3306b](https://github.com/joinmarket-webui/jam/commit/5c3306bd573696989f2e506be64187582fac7ed8))
|
||||
* **ui:** fidelity bond utxo row color in dark mode ([#898](https://github.com/joinmarket-webui/jam/issues/898)) ([3019285](https://github.com/joinmarket-webui/jam/commit/30192857f81910db1142e2a02f08f3b23dd24136))
|
||||
* **ui:** fix button alignment in jar selection modal ([#897](https://github.com/joinmarket-webui/jam/issues/897)) ([10ad2a6](https://github.com/joinmarket-webui/jam/commit/10ad2a61d4c18dc4a6d3a6f6fc31d2a88b852e9f))
|
||||
- relative fee greater than zero ([#862](https://github.com/joinmarket-webui/jam/issues/862)) ([5c3306b](https://github.com/joinmarket-webui/jam/commit/5c3306bd573696989f2e506be64187582fac7ed8))
|
||||
- **ui:** fidelity bond utxo row color in dark mode ([#898](https://github.com/joinmarket-webui/jam/issues/898)) ([3019285](https://github.com/joinmarket-webui/jam/commit/30192857f81910db1142e2a02f08f3b23dd24136))
|
||||
- **ui:** fix button alignment in jar selection modal ([#897](https://github.com/joinmarket-webui/jam/issues/897)) ([10ad2a6](https://github.com/joinmarket-webui/jam/commit/10ad2a61d4c18dc4a6d3a6f6fc31d2a88b852e9f))
|
||||
|
||||
## [0.3.0](https://github.com/joinmarket-webui/jam/compare/v0.2.0...v0.3.0) (2024-10-01)
|
||||
|
||||
#### Added
|
||||
|
||||
* **config:** ability to customize "max sweep fee change" setting ([#793](https://github.com/joinmarket-webui/jam/issues/793)) ([b4f8a56](https://github.com/joinmarket-webui/jam/commit/b4f8a56baa5e1f4bbad1cd437a3f08172d9db026))
|
||||
* display warning on fidelity bond with same expiry date ([#741](https://github.com/joinmarket-webui/jam/issues/741)) ([c04007d](https://github.com/joinmarket-webui/jam/commit/c04007d2968dd587f6fdc0d85798412c2d892f97))
|
||||
* **earn:** add simple stats to earn report ([#731](https://github.com/joinmarket-webui/jam/issues/731)) ([24a9026](https://github.com/joinmarket-webui/jam/commit/24a9026a24579ba8ba5e99117b58dc6c31c3c3bc))
|
||||
* **orderbook:** show fidelity bond value and locktime ([#766](https://github.com/joinmarket-webui/jam/issues/766)) ([3bcbdee](https://github.com/joinmarket-webui/jam/commit/3bcbdeed191486047ae58913d9017f58b88245a4))
|
||||
* **rescan:** ability to rescan timechain ([#838](https://github.com/joinmarket-webui/jam/issues/838)) ([3ec72b1](https://github.com/joinmarket-webui/jam/commit/3ec72b187d78def597399ccb49573bfeae855a9a))
|
||||
* **ui**: unit toggle on main wallet view ([#806](https://github.com/joinmarket-webui/jam/issues/806)) ([09aa113](https://github.com/joinmarket-webui/jam/commit/09aa1133a6851356cfa10733f46166a743cfeb9e))
|
||||
* **import**: validate mnemonic phrase against BIP39 wordlist ([#739](https://github.com/joinmarket-webui/jam/issues/739)) ([69e8fa7](https://github.com/joinmarket-webui/jam/commit/69e8fa7f1218fffb83d1c372d39c2264de909b63))
|
||||
* **send**: quick freeze/unfreeze UTXOs on send page ([#771](https://github.com/joinmarket-webui/jam/issues/771)) ([5c8f81e](https://github.com/joinmarket-webui/jam/commit/5c8f81e92d59b38b83c21739fba0ef7c16505e82))
|
||||
* **send**: show "selected" UTXOs before performing transaction ([#773](https://github.com/joinmarket-webui/jam/issues/773)) ([96844d9](https://github.com/joinmarket-webui/jam/commit/96844d982f895406dbc23ed329491fe499e9a625))
|
||||
- **config:** ability to customize "max sweep fee change" setting ([#793](https://github.com/joinmarket-webui/jam/issues/793)) ([b4f8a56](https://github.com/joinmarket-webui/jam/commit/b4f8a56baa5e1f4bbad1cd437a3f08172d9db026))
|
||||
- display warning on fidelity bond with same expiry date ([#741](https://github.com/joinmarket-webui/jam/issues/741)) ([c04007d](https://github.com/joinmarket-webui/jam/commit/c04007d2968dd587f6fdc0d85798412c2d892f97))
|
||||
- **earn:** add simple stats to earn report ([#731](https://github.com/joinmarket-webui/jam/issues/731)) ([24a9026](https://github.com/joinmarket-webui/jam/commit/24a9026a24579ba8ba5e99117b58dc6c31c3c3bc))
|
||||
- **orderbook:** show fidelity bond value and locktime ([#766](https://github.com/joinmarket-webui/jam/issues/766)) ([3bcbdee](https://github.com/joinmarket-webui/jam/commit/3bcbdeed191486047ae58913d9017f58b88245a4))
|
||||
- **rescan:** ability to rescan timechain ([#838](https://github.com/joinmarket-webui/jam/issues/838)) ([3ec72b1](https://github.com/joinmarket-webui/jam/commit/3ec72b187d78def597399ccb49573bfeae855a9a))
|
||||
- **ui**: unit toggle on main wallet view ([#806](https://github.com/joinmarket-webui/jam/issues/806)) ([09aa113](https://github.com/joinmarket-webui/jam/commit/09aa1133a6851356cfa10733f46166a743cfeb9e))
|
||||
- **import**: validate mnemonic phrase against BIP39 wordlist ([#739](https://github.com/joinmarket-webui/jam/issues/739)) ([69e8fa7](https://github.com/joinmarket-webui/jam/commit/69e8fa7f1218fffb83d1c372d39c2264de909b63))
|
||||
- **send**: quick freeze/unfreeze UTXOs on send page ([#771](https://github.com/joinmarket-webui/jam/issues/771)) ([5c8f81e](https://github.com/joinmarket-webui/jam/commit/5c8f81e92d59b38b83c21739fba0ef7c16505e82))
|
||||
- **send**: show "selected" UTXOs before performing transaction ([#773](https://github.com/joinmarket-webui/jam/issues/773)) ([96844d9](https://github.com/joinmarket-webui/jam/commit/96844d982f895406dbc23ed329491fe499e9a625))
|
||||
|
||||
#### Fixed
|
||||
|
||||
* allow absolute maker fee of zero ([#727](https://github.com/joinmarket-webui/jam/issues/727)) ([c04a830](https://github.com/joinmarket-webui/jam/commit/c04a8304dbbce6a2dbfdfbada22317a97815dc3e))
|
||||
* amount input field to properly interpret BTC ([#800](https://github.com/joinmarket-webui/jam/issues/800)) ([072a419](https://github.com/joinmarket-webui/jam/commit/072a41950d7efab144bab079be3641df8095c985))
|
||||
* **earn:** validate offer minsize ([#745](https://github.com/joinmarket-webui/jam/issues/745)) ([7aef192](https://github.com/joinmarket-webui/jam/commit/7aef192e72de95c8cc3b20e0a76f369e98208318))
|
||||
* **fb:** months order across timezones ([#853](https://github.com/joinmarket-webui/jam/issues/853)) ([d2116f5](https://github.com/joinmarket-webui/jam/commit/d2116f5ce703f8e7790e55d6179899cfa925fe14))
|
||||
* **fb:** display error alert in modal ([#777](https://github.com/joinmarket-webui/jam/pull/776)) ([fef6260](https://github.com/joinmarket-webui/jam/commit/fef6260be03d1fe0bf65e1b6fafb8f10dbf8d2af))
|
||||
* **i18n:** description values for Chinese translations ([#829](https://github.com/joinmarket-webui/jam/issues/829)) ([ba84d59](https://github.com/joinmarket-webui/jam/commit/ba84d59e84ce6f8b785954eda25636587278fef5))
|
||||
* **main:** properly fade out jar tooltips ([#848](https://github.com/joinmarket-webui/jam/issues/848)) ([1f6eddf](https://github.com/joinmarket-webui/jam/commit/1f6eddfe1740d267950499c4b9967cd5869b432f))
|
||||
- allow absolute maker fee of zero ([#727](https://github.com/joinmarket-webui/jam/issues/727)) ([c04a830](https://github.com/joinmarket-webui/jam/commit/c04a8304dbbce6a2dbfdfbada22317a97815dc3e))
|
||||
- amount input field to properly interpret BTC ([#800](https://github.com/joinmarket-webui/jam/issues/800)) ([072a419](https://github.com/joinmarket-webui/jam/commit/072a41950d7efab144bab079be3641df8095c985))
|
||||
- **earn:** validate offer minsize ([#745](https://github.com/joinmarket-webui/jam/issues/745)) ([7aef192](https://github.com/joinmarket-webui/jam/commit/7aef192e72de95c8cc3b20e0a76f369e98208318))
|
||||
- **fb:** months order across timezones ([#853](https://github.com/joinmarket-webui/jam/issues/853)) ([d2116f5](https://github.com/joinmarket-webui/jam/commit/d2116f5ce703f8e7790e55d6179899cfa925fe14))
|
||||
- **fb:** display error alert in modal ([#777](https://github.com/joinmarket-webui/jam/pull/776)) ([fef6260](https://github.com/joinmarket-webui/jam/commit/fef6260be03d1fe0bf65e1b6fafb8f10dbf8d2af))
|
||||
- **i18n:** description values for Chinese translations ([#829](https://github.com/joinmarket-webui/jam/issues/829)) ([ba84d59](https://github.com/joinmarket-webui/jam/commit/ba84d59e84ce6f8b785954eda25636587278fef5))
|
||||
- **main:** properly fade out jar tooltips ([#848](https://github.com/joinmarket-webui/jam/issues/848)) ([1f6eddf](https://github.com/joinmarket-webui/jam/commit/1f6eddfe1740d267950499c4b9967cd5869b432f))
|
||||
|
||||
## [0.2.0](https://github.com/joinmarket-webui/jam/compare/v0.1.6...v0.2.0) (2024-02-24)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* **fee-randomization:** fix fee range in PaymentConfirmModal ([#655](https://github.com/joinmarket-webui/jam/issues/655)) ([31f54c8](https://github.com/joinmarket-webui/jam/commit/31f54c8cb8bf1474406328be7c5a26b5b5263a44))
|
||||
* selectable ui elements ([#714](https://github.com/joinmarket-webui/jam/issues/714)) ([378daf5](https://github.com/joinmarket-webui/jam/commit/378daf5c936b56a401deeefb9d847bf2ed14f7c7))
|
||||
* show warning on missing fee config values ([#674](https://github.com/joinmarket-webui/jam/issues/674)) ([5900a8c](https://github.com/joinmarket-webui/jam/commit/5900a8c6209aac9d1113186616f72efe023fa3c4))
|
||||
* **ui:** adapt payment confirm size ([#712](https://github.com/joinmarket-webui/jam/issues/712)) ([11e2c2a](https://github.com/joinmarket-webui/jam/commit/11e2c2a88c8dc95bd47e64b0f315213ddd355ca6))
|
||||
* use orderbook.json instead of parsing html table ([#687](https://github.com/joinmarket-webui/jam/issues/687)) ([df81804](https://github.com/joinmarket-webui/jam/commit/df8180432be414840b71ea970eef42316c085941))
|
||||
- **fee-randomization:** fix fee range in PaymentConfirmModal ([#655](https://github.com/joinmarket-webui/jam/issues/655)) ([31f54c8](https://github.com/joinmarket-webui/jam/commit/31f54c8cb8bf1474406328be7c5a26b5b5263a44))
|
||||
- selectable ui elements ([#714](https://github.com/joinmarket-webui/jam/issues/714)) ([378daf5](https://github.com/joinmarket-webui/jam/commit/378daf5c936b56a401deeefb9d847bf2ed14f7c7))
|
||||
- show warning on missing fee config values ([#674](https://github.com/joinmarket-webui/jam/issues/674)) ([5900a8c](https://github.com/joinmarket-webui/jam/commit/5900a8c6209aac9d1113186616f72efe023fa3c4))
|
||||
- **ui:** adapt payment confirm size ([#712](https://github.com/joinmarket-webui/jam/issues/712)) ([11e2c2a](https://github.com/joinmarket-webui/jam/commit/11e2c2a88c8dc95bd47e64b0f315213ddd355ca6))
|
||||
- use orderbook.json instead of parsing html table ([#687](https://github.com/joinmarket-webui/jam/issues/687)) ([df81804](https://github.com/joinmarket-webui/jam/commit/df8180432be414840b71ea970eef42316c085941))
|
||||
|
||||
#### Added
|
||||
|
||||
* align amount input fields ([#711](https://github.com/joinmarket-webui/jam/issues/711)) ([7006e57](https://github.com/joinmarket-webui/jam/commit/7006e575f48c62d761dafe08e0fc2317e2e6c1b4))
|
||||
* check for existing wallet ([#720](https://github.com/joinmarket-webui/jam/issues/720)) ([ac383dc](https://github.com/joinmarket-webui/jam/commit/ac383dcdd8f3bee4e63e3236537c5e945c2edd69))
|
||||
* custom tx fee on direct and collaborative send ([#706](https://github.com/joinmarket-webui/jam/issues/706)) ([dc95e64](https://github.com/joinmarket-webui/jam/commit/dc95e646e7095d0ae8e0a312830455fc81e1a6bc))
|
||||
* display ui/backend version ([#668](https://github.com/joinmarket-webui/jam/issues/668)) ([69e61e0](https://github.com/joinmarket-webui/jam/commit/69e61e06c135549c1812f20456c9cbbd46853e2b))
|
||||
* renew fidelity bond ([#678](https://github.com/joinmarket-webui/jam/issues/678)) ([b4948ef](https://github.com/joinmarket-webui/jam/commit/b4948ef9f013c6e127251d85def0096e9d4f21f2))
|
||||
* **ui:** autofocus next input when confirming seed phrase backup ([#718](https://github.com/joinmarket-webui/jam/issues/718)) ([27e687c](https://github.com/joinmarket-webui/jam/commit/27e687c9cf34fdd1f92b8c675c6579b4759f9389))
|
||||
- align amount input fields ([#711](https://github.com/joinmarket-webui/jam/issues/711)) ([7006e57](https://github.com/joinmarket-webui/jam/commit/7006e575f48c62d761dafe08e0fc2317e2e6c1b4))
|
||||
- check for existing wallet ([#720](https://github.com/joinmarket-webui/jam/issues/720)) ([ac383dc](https://github.com/joinmarket-webui/jam/commit/ac383dcdd8f3bee4e63e3236537c5e945c2edd69))
|
||||
- custom tx fee on direct and collaborative send ([#706](https://github.com/joinmarket-webui/jam/issues/706)) ([dc95e64](https://github.com/joinmarket-webui/jam/commit/dc95e646e7095d0ae8e0a312830455fc81e1a6bc))
|
||||
- display ui/backend version ([#668](https://github.com/joinmarket-webui/jam/issues/668)) ([69e61e0](https://github.com/joinmarket-webui/jam/commit/69e61e06c135549c1812f20456c9cbbd46853e2b))
|
||||
- renew fidelity bond ([#678](https://github.com/joinmarket-webui/jam/issues/678)) ([b4948ef](https://github.com/joinmarket-webui/jam/commit/b4948ef9f013c6e127251d85def0096e9d4f21f2))
|
||||
- **ui:** autofocus next input when confirming seed phrase backup ([#718](https://github.com/joinmarket-webui/jam/issues/718)) ([27e687c](https://github.com/joinmarket-webui/jam/commit/27e687c9cf34fdd1f92b8c675c6579b4759f9389))
|
||||
|
||||
### [0.1.6](https://github.com/joinmarket-webui/jam/compare/v0.1.5...v0.1.6) (2023-09-22)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* typo on orderbook page ([#661](https://github.com/joinmarket-webui/jam/issues/661)) ([a0fa434](https://github.com/joinmarket-webui/jam/commit/a0fa434eabea269e87b1e1786e9144a07d893269))
|
||||
* **ui:** consistent button styles ([#656](https://github.com/joinmarket-webui/jam/issues/656)) ([a559e1e](https://github.com/joinmarket-webui/jam/commit/a559e1e92c9e245a48f1e698c109b8f039e9b443))
|
||||
- typo on orderbook page ([#661](https://github.com/joinmarket-webui/jam/issues/661)) ([a0fa434](https://github.com/joinmarket-webui/jam/commit/a0fa434eabea269e87b1e1786e9144a07d893269))
|
||||
- **ui:** consistent button styles ([#656](https://github.com/joinmarket-webui/jam/issues/656)) ([a559e1e](https://github.com/joinmarket-webui/jam/commit/a559e1e92c9e245a48f1e698c109b8f039e9b443))
|
||||
|
||||
#### Added
|
||||
|
||||
* backend version based feature toggles ([#647](https://github.com/joinmarket-webui/jam/issues/647)) ([6b45718](https://github.com/joinmarket-webui/jam/commit/6b457187593a37d34faf23e6d4898cb2a7fb59b7))
|
||||
* **balance:** display frozen balance on jars ([#635](https://github.com/joinmarket-webui/jam/issues/635)) ([b029c92](https://github.com/joinmarket-webui/jam/commit/b029c923823e6f9f441289f61b39c2d83b5bb983))
|
||||
* **i18n:** add Chinese translation (zh-Hans and zh-Hant) ([#628](https://github.com/joinmarket-webui/jam/issues/628)) ([550a435](https://github.com/joinmarket-webui/jam/commit/550a43588b1b003a52a4ed3ba00b4623b9e7dd1d))
|
||||
* **i18n:** add Italian translation (it) ([#627](https://github.com/joinmarket-webui/jam/issues/627)) ([068042f](https://github.com/joinmarket-webui/jam/commit/068042fceb5ff192b99bbf479bd1a225d52d1b61))
|
||||
* **i18n:** adding translation in Brazilian Portuguese (pt-BR) ([#615](https://github.com/joinmarket-webui/jam/issues/615)) ([4d9171e](https://github.com/joinmarket-webui/jam/commit/4d9171e15fe45100dd26db621576739af9b7e012))
|
||||
* **i18n:** update german translation (de) ([#659](https://github.com/joinmarket-webui/jam/issues/659)) ([8b54f28](https://github.com/joinmarket-webui/jam/commit/8b54f28644fc04c58716102d3bcc6583e32b104a))
|
||||
* **i18n:** update to Chinese translations ([#660](https://github.com/joinmarket-webui/jam/issues/660)) ([c40938a](https://github.com/joinmarket-webui/jam/commit/c40938a2fab147695a03ef58ff0692bdd9fc1354))
|
||||
* import wallet ([#621](https://github.com/joinmarket-webui/jam/issues/621)) ([028f321](https://github.com/joinmarket-webui/jam/commit/028f32166a21778796dd10e2d1d7fbce5e07ed8f))
|
||||
* **Send:** Fee breakdown table ([#606](https://github.com/joinmarket-webui/jam/issues/606)) ([7a6b920](https://github.com/joinmarket-webui/jam/commit/7a6b920fd2f145b9e47f29ef0f27a2786bb87525))
|
||||
- backend version based feature toggles ([#647](https://github.com/joinmarket-webui/jam/issues/647)) ([6b45718](https://github.com/joinmarket-webui/jam/commit/6b457187593a37d34faf23e6d4898cb2a7fb59b7))
|
||||
- **balance:** display frozen balance on jars ([#635](https://github.com/joinmarket-webui/jam/issues/635)) ([b029c92](https://github.com/joinmarket-webui/jam/commit/b029c923823e6f9f441289f61b39c2d83b5bb983))
|
||||
- **i18n:** add Chinese translation (zh-Hans and zh-Hant) ([#628](https://github.com/joinmarket-webui/jam/issues/628)) ([550a435](https://github.com/joinmarket-webui/jam/commit/550a43588b1b003a52a4ed3ba00b4623b9e7dd1d))
|
||||
- **i18n:** add Italian translation (it) ([#627](https://github.com/joinmarket-webui/jam/issues/627)) ([068042f](https://github.com/joinmarket-webui/jam/commit/068042fceb5ff192b99bbf479bd1a225d52d1b61))
|
||||
- **i18n:** adding translation in Brazilian Portuguese (pt-BR) ([#615](https://github.com/joinmarket-webui/jam/issues/615)) ([4d9171e](https://github.com/joinmarket-webui/jam/commit/4d9171e15fe45100dd26db621576739af9b7e012))
|
||||
- **i18n:** update german translation (de) ([#659](https://github.com/joinmarket-webui/jam/issues/659)) ([8b54f28](https://github.com/joinmarket-webui/jam/commit/8b54f28644fc04c58716102d3bcc6583e32b104a))
|
||||
- **i18n:** update to Chinese translations ([#660](https://github.com/joinmarket-webui/jam/issues/660)) ([c40938a](https://github.com/joinmarket-webui/jam/commit/c40938a2fab147695a03ef58ff0692bdd9fc1354))
|
||||
- import wallet ([#621](https://github.com/joinmarket-webui/jam/issues/621)) ([028f321](https://github.com/joinmarket-webui/jam/commit/028f32166a21778796dd10e2d1d7fbce5e07ed8f))
|
||||
- **Send:** Fee breakdown table ([#606](https://github.com/joinmarket-webui/jam/issues/606)) ([7a6b920](https://github.com/joinmarket-webui/jam/commit/7a6b920fd2f145b9e47f29ef0f27a2786bb87525))
|
||||
|
||||
### [0.1.5](https://github.com/joinmarket-webui/jam/compare/v0.1.4...v0.1.5) (2023-02-08)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* construct dates with timestamp to please safari ([#584](https://github.com/joinmarket-webui/jam/issues/584)) ([9ebe168](https://github.com/joinmarket-webui/jam/commit/9ebe168b5dced4553bae942a8d8836e9b103c815))
|
||||
* **fb:** correctly display success screen after unlocking fb ([#601](https://github.com/joinmarket-webui/jam/issues/601)) ([0eb6649](https://github.com/joinmarket-webui/jam/commit/0eb6649b7a21ab03824e562edec92d3080cf8e16))
|
||||
* **fee:** allow input for relative fee limit of 0.0001% ([#603](https://github.com/joinmarket-webui/jam/issues/603)) ([1c87a6d](https://github.com/joinmarket-webui/jam/commit/1c87a6de06db3974a8f0e307329fc3f13dd69954))
|
||||
* **fees:** allow min tx fee of 1sat/vbyte ([#604](https://github.com/joinmarket-webui/jam/issues/604)) ([4121c9b](https://github.com/joinmarket-webui/jam/commit/4121c9bd4510cef9af5345fd921d040933b915e9))
|
||||
* install python3-venv in regtest environment ([#585](https://github.com/joinmarket-webui/jam/issues/585)) ([d9b0415](https://github.com/joinmarket-webui/jam/commit/d9b0415dffb397c9d602b42408916ce8daeef63c))
|
||||
- construct dates with timestamp to please safari ([#584](https://github.com/joinmarket-webui/jam/issues/584)) ([9ebe168](https://github.com/joinmarket-webui/jam/commit/9ebe168b5dced4553bae942a8d8836e9b103c815))
|
||||
- **fb:** correctly display success screen after unlocking fb ([#601](https://github.com/joinmarket-webui/jam/issues/601)) ([0eb6649](https://github.com/joinmarket-webui/jam/commit/0eb6649b7a21ab03824e562edec92d3080cf8e16))
|
||||
- **fee:** allow input for relative fee limit of 0.0001% ([#603](https://github.com/joinmarket-webui/jam/issues/603)) ([1c87a6d](https://github.com/joinmarket-webui/jam/commit/1c87a6de06db3974a8f0e307329fc3f13dd69954))
|
||||
- **fees:** allow min tx fee of 1sat/vbyte ([#604](https://github.com/joinmarket-webui/jam/issues/604)) ([4121c9b](https://github.com/joinmarket-webui/jam/commit/4121c9bd4510cef9af5345fd921d040933b915e9))
|
||||
- install python3-venv in regtest environment ([#585](https://github.com/joinmarket-webui/jam/issues/585)) ([d9b0415](https://github.com/joinmarket-webui/jam/commit/d9b0415dffb397c9d602b42408916ce8daeef63c))
|
||||
|
||||
#### Added
|
||||
|
||||
* add dedicated error page ([#586](https://github.com/joinmarket-webui/jam/issues/586)) ([42c9f5d](https://github.com/joinmarket-webui/jam/commit/42c9f5d773a74a41e4f32c145c3512d7d9405d6d))
|
||||
* **Jam:** add success message ([#599](https://github.com/joinmarket-webui/jam/issues/599)) ([531adb6](https://github.com/joinmarket-webui/jam/commit/531adb6ffa82183534c7e8f08c7a87d05adad59c))
|
||||
* quick freeze/unfreeze utxos ([#591](https://github.com/joinmarket-webui/jam/issues/591)) ([f3f6e84](https://github.com/joinmarket-webui/jam/commit/f3f6e847abfe43db6ae6d63656d15b81122b66e2))
|
||||
* Show jar total amount in detail view ([#551](https://github.com/joinmarket-webui/jam/issues/551)) ([90f22e5](https://github.com/joinmarket-webui/jam/commit/90f22e59eb317ae7280357f0c00455ae9c46bf68))
|
||||
- add dedicated error page ([#586](https://github.com/joinmarket-webui/jam/issues/586)) ([42c9f5d](https://github.com/joinmarket-webui/jam/commit/42c9f5d773a74a41e4f32c145c3512d7d9405d6d))
|
||||
- **Jam:** add success message ([#599](https://github.com/joinmarket-webui/jam/issues/599)) ([531adb6](https://github.com/joinmarket-webui/jam/commit/531adb6ffa82183534c7e8f08c7a87d05adad59c))
|
||||
- quick freeze/unfreeze utxos ([#591](https://github.com/joinmarket-webui/jam/issues/591)) ([f3f6e84](https://github.com/joinmarket-webui/jam/commit/f3f6e847abfe43db6ae6d63656d15b81122b66e2))
|
||||
- Show jar total amount in detail view ([#551](https://github.com/joinmarket-webui/jam/issues/551)) ([90f22e5](https://github.com/joinmarket-webui/jam/commit/90f22e59eb317ae7280357f0c00455ae9c46bf68))
|
||||
|
||||
### [0.1.4](https://github.com/joinmarket-webui/jam/compare/v0.1.3...v0.1.4) (2022-12-13)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* **send:** parse number of collaborators as integer ([#572](https://github.com/joinmarket-webui/jam/issues/572)) ([4fca8f1](https://github.com/joinmarket-webui/jam/commit/4fca8f1fd1ba5d9e9f7f1289c9db35c1f03aaf34))
|
||||
* **performance:** speed up initial page load ([#566](https://github.com/joinmarket-webui/jam/pull/566)) ([e2bad18](https://github.com/joinmarket-webui/jam/commit/e2bad188f116b63bf68b308106bba33ac8fc7164))
|
||||
- **send:** parse number of collaborators as integer ([#572](https://github.com/joinmarket-webui/jam/issues/572)) ([4fca8f1](https://github.com/joinmarket-webui/jam/commit/4fca8f1fd1ba5d9e9f7f1289c9db35c1f03aaf34))
|
||||
- **performance:** speed up initial page load ([#566](https://github.com/joinmarket-webui/jam/pull/566)) ([e2bad18](https://github.com/joinmarket-webui/jam/commit/e2bad188f116b63bf68b308106bba33ac8fc7164))
|
||||
|
||||
#### Added
|
||||
|
||||
* Settings subsections ([#573](https://github.com/joinmarket-webui/jam/issues/573)) ([495dc2b](https://github.com/joinmarket-webui/jam/commit/495dc2b169ddccedb0adff3f6b9f5caf03873cbf)), closes [#524](https://github.com/joinmarket-webui/jam/issues/524)
|
||||
* spend fidelity bond ([#556](https://github.com/joinmarket-webui/jam/issues/556)) ([9f42dac](https://github.com/joinmarket-webui/jam/commit/9f42dac19ad1897c51be535636ff64b8d2bbb125))
|
||||
- Settings subsections ([#573](https://github.com/joinmarket-webui/jam/issues/573)) ([495dc2b](https://github.com/joinmarket-webui/jam/commit/495dc2b169ddccedb0adff3f6b9f5caf03873cbf)), closes [#524](https://github.com/joinmarket-webui/jam/issues/524)
|
||||
- spend fidelity bond ([#556](https://github.com/joinmarket-webui/jam/issues/556)) ([9f42dac](https://github.com/joinmarket-webui/jam/commit/9f42dac19ad1897c51be535636ff64b8d2bbb125))
|
||||
|
||||
### [0.1.3](https://github.com/joinmarket-webui/jam/compare/v0.1.2...v0.1.3) (2022-11-10)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* **docker**: wait for bitcoind to accept RPC calls ([#559](https://github.com/joinmarket-webui/jam/pull/559)) ([6e2ee47](https://github.com/joinmarket-webui/jam/commit/6e2ee47538fe225f7b84eb2de245993a90dfd042))
|
||||
* **pagination:** colors of option element in dark mode ([#554](https://github.com/joinmarket-webui/jam/issues/554)) ([86dc2c5](https://github.com/joinmarket-webui/jam/commit/86dc2c5d545037e2a1a593049ac6b96e31910d07))
|
||||
- **docker**: wait for bitcoind to accept RPC calls ([#559](https://github.com/joinmarket-webui/jam/pull/559)) ([6e2ee47](https://github.com/joinmarket-webui/jam/commit/6e2ee47538fe225f7b84eb2de245993a90dfd042))
|
||||
- **pagination:** colors of option element in dark mode ([#554](https://github.com/joinmarket-webui/jam/issues/554)) ([86dc2c5](https://github.com/joinmarket-webui/jam/commit/86dc2c5d545037e2a1a593049ac6b96e31910d07))
|
||||
|
||||
#### Added
|
||||
|
||||
* quickly review/adapt fee settings before sweeping ([#565](https://github.com/joinmarket-webui/jam/issues/565)) ([0d4dd0d](https://github.com/joinmarket-webui/jam/commit/0d4dd0d4d550a7052b6958326cd70adebea6cd60))
|
||||
* **orderbook:** improve readability with alternating colors ([#563](https://github.com/joinmarket-webui/jam/pull/563)) ([691faf7](https://github.com/joinmarket-webui/jam/pull/563))
|
||||
- quickly review/adapt fee settings before sweeping ([#565](https://github.com/joinmarket-webui/jam/issues/565)) ([0d4dd0d](https://github.com/joinmarket-webui/jam/commit/0d4dd0d4d550a7052b6958326cd70adebea6cd60))
|
||||
- **orderbook:** improve readability with alternating colors ([#563](https://github.com/joinmarket-webui/jam/pull/563)) ([691faf7](https://github.com/joinmarket-webui/jam/pull/563))
|
||||
|
||||
### [0.1.2](https://github.com/joinmarket-webui/jam/compare/v0.1.1...v0.1.2) (2022-10-28)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* display error message if backend is unreachable ([#540](https://github.com/joinmarket-webui/jam/issues/540)) ([f2e346e](https://github.com/joinmarket-webui/jam/commit/f2e346e87296c53c5dee89aa4610cfeed39065b8))
|
||||
* do not enable debug features on `npm start` ([#549](https://github.com/joinmarket-webui/jam/issues/549)) ([fda77c2](https://github.com/joinmarket-webui/jam/commit/fda77c202d32f32b5ca39a60f983748038cc0cf5))
|
||||
- display error message if backend is unreachable ([#540](https://github.com/joinmarket-webui/jam/issues/540)) ([f2e346e](https://github.com/joinmarket-webui/jam/commit/f2e346e87296c53c5dee89aa4610cfeed39065b8))
|
||||
- do not enable debug features on `npm start` ([#549](https://github.com/joinmarket-webui/jam/issues/549)) ([fda77c2](https://github.com/joinmarket-webui/jam/commit/fda77c202d32f32b5ca39a60f983748038cc0cf5))
|
||||
|
||||
### [0.1.1](https://github.com/joinmarket-webui/jam/compare/v0.1.0...v0.1.1) (2022-10-07)
|
||||
|
||||
#### Added
|
||||
|
||||
* basic fee settings ([#522](https://github.com/joinmarket-webui/jam/issues/522)) ([54dd396](https://github.com/joinmarket-webui/jam/commit/54dd3969d954112af46d746e7fcc6e78db2ef32f))
|
||||
* display fee settings on Send page ([#532](https://github.com/joinmarket-webui/jam/issues/532)) ([26f911a](https://github.com/joinmarket-webui/jam/commit/26f911aea80f472d6f9484a1187e27f7dffe1ec2))
|
||||
* **jar:** show sum of selected utxos ([#514](https://github.com/joinmarket-webui/jam/issues/514)) ([85d131c](https://github.com/joinmarket-webui/jam/commit/85d131cf75ba614245de450492bdde1905a6b51c))
|
||||
* **send:** show txid on successful direct-send ([#510](https://github.com/joinmarket-webui/jam/issues/510)) ([13496a0](https://github.com/joinmarket-webui/jam/commit/13496a0a066bebb6f8d8b4ef72e25a00f7f347ac))
|
||||
- basic fee settings ([#522](https://github.com/joinmarket-webui/jam/issues/522)) ([54dd396](https://github.com/joinmarket-webui/jam/commit/54dd3969d954112af46d746e7fcc6e78db2ef32f))
|
||||
- display fee settings on Send page ([#532](https://github.com/joinmarket-webui/jam/issues/532)) ([26f911a](https://github.com/joinmarket-webui/jam/commit/26f911aea80f472d6f9484a1187e27f7dffe1ec2))
|
||||
- **jar:** show sum of selected utxos ([#514](https://github.com/joinmarket-webui/jam/issues/514)) ([85d131c](https://github.com/joinmarket-webui/jam/commit/85d131cf75ba614245de450492bdde1905a6b51c))
|
||||
- **send:** show txid on successful direct-send ([#510](https://github.com/joinmarket-webui/jam/issues/510)) ([13496a0](https://github.com/joinmarket-webui/jam/commit/13496a0a066bebb6f8d8b4ef72e25a00f7f347ac))
|
||||
|
||||
#### Fixed
|
||||
|
||||
* **fees:** mitigate construction of non-forwardable transactions ([#536](https://github.com/joinmarket-webui/jam/issues/536)) ([f2f3944](https://github.com/joinmarket-webui/jam/commit/f2f39444ccd59d4a5a440286a425d1b1fd7c238d))
|
||||
* **navbar:** send before earn ([#507](https://github.com/joinmarket-webui/jam/issues/507)) ([c1fb2bc](https://github.com/joinmarket-webui/jam/commit/c1fb2bc68008980087581d4a0d845624f6816b85))
|
||||
* **readme:** link to development heading ([0351367](https://github.com/joinmarket-webui/jam/commit/0351367e2971b9717221fa705953d444ff37c587))
|
||||
* **settings:** consistent case ([#511](https://github.com/joinmarket-webui/jam/issues/511)) ([e4bd89c](https://github.com/joinmarket-webui/jam/commit/e4bd89c648ea27cd9fb4dd996248eacff0172586))
|
||||
* **sweep:** reload wallet info after scheduled sweep ([#530](https://github.com/joinmarket-webui/jam/issues/530)) ([0757280](https://github.com/joinmarket-webui/jam/commit/0757280bf1d1e3b46fdf50c6bcd6268ae379714d))
|
||||
* **sweep:** wait for scheduler start/stop ([#529](https://github.com/joinmarket-webui/jam/issues/529)) ([509a15e](https://github.com/joinmarket-webui/jam/commit/509a15e90a5dc58971256eab9cf7d8be1fe796ad))
|
||||
- **fees:** mitigate construction of non-forwardable transactions ([#536](https://github.com/joinmarket-webui/jam/issues/536)) ([f2f3944](https://github.com/joinmarket-webui/jam/commit/f2f39444ccd59d4a5a440286a425d1b1fd7c238d))
|
||||
- **navbar:** send before earn ([#507](https://github.com/joinmarket-webui/jam/issues/507)) ([c1fb2bc](https://github.com/joinmarket-webui/jam/commit/c1fb2bc68008980087581d4a0d845624f6816b85))
|
||||
- **readme:** link to development heading ([0351367](https://github.com/joinmarket-webui/jam/commit/0351367e2971b9717221fa705953d444ff37c587))
|
||||
- **settings:** consistent case ([#511](https://github.com/joinmarket-webui/jam/issues/511)) ([e4bd89c](https://github.com/joinmarket-webui/jam/commit/e4bd89c648ea27cd9fb4dd996248eacff0172586))
|
||||
- **sweep:** reload wallet info after scheduled sweep ([#530](https://github.com/joinmarket-webui/jam/issues/530)) ([0757280](https://github.com/joinmarket-webui/jam/commit/0757280bf1d1e3b46fdf50c6bcd6268ae379714d))
|
||||
- **sweep:** wait for scheduler start/stop ([#529](https://github.com/joinmarket-webui/jam/issues/529)) ([509a15e](https://github.com/joinmarket-webui/jam/commit/509a15e90a5dc58971256eab9cf7d8be1fe796ad))
|
||||
|
||||
## [0.1.0](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.10...v0.1.0) (2022-09-16)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* create non-descriptor wallet ([#487](https://github.com/joinmarket-webui/joinmarket-webui/issues/487)) ([0d70415](https://github.com/joinmarket-webui/joinmarket-webui/commit/0d704158f4b41f1a1dc0adef058c13f6d6932190))
|
||||
* pass api token to session request ([#456](https://github.com/joinmarket-webui/joinmarket-webui/issues/456)) ([27e1a10](https://github.com/joinmarket-webui/joinmarket-webui/commit/27e1a10f63382b757baed53e98869b3ffbd2191d))
|
||||
* pass mixdepth prop as number in request body ([#457](https://github.com/joinmarket-webui/joinmarket-webui/issues/457)) ([155f9bd](https://github.com/joinmarket-webui/joinmarket-webui/commit/155f9bd55b4e385f49d49295ff52461f069f6b51))
|
||||
* precondition for collaborative transactions ([#485](https://github.com/joinmarket-webui/joinmarket-webui/issues/485)) ([db29235](https://github.com/joinmarket-webui/joinmarket-webui/commit/db292356f59de24bd80622d4a1eb57414d514e49))
|
||||
* proper margin for sweep button on invalid inputs ([#471](https://github.com/joinmarket-webui/joinmarket-webui/issues/471)) ([4a20c9f](https://github.com/joinmarket-webui/joinmarket-webui/commit/4a20c9fdd2eff40a2856d5c461dc4f18dba8f4f2))
|
||||
* re-add Joining icon ([#474](https://github.com/joinmarket-webui/joinmarket-webui/issues/474)) ([1d0f0cc](https://github.com/joinmarket-webui/joinmarket-webui/commit/1d0f0ccf5fc5e516d6781ee041588a43b7135070))
|
||||
* redirect to home if no wallet is active on route `/wallet` ([#492](https://github.com/joinmarket-webui/joinmarket-webui/issues/492)) ([2c3d6f7](https://github.com/joinmarket-webui/joinmarket-webui/commit/2c3d6f76feafd4400905dd66b5436a1fe93a0f09))
|
||||
* refresh orderbook ([#462](https://github.com/joinmarket-webui/joinmarket-webui/issues/462)) ([505e960](https://github.com/joinmarket-webui/joinmarket-webui/commit/505e9606f50d13d6d2b67f1002662254f94953bd))
|
||||
* reload wallet info after stopping scheduler manually ([#494](https://github.com/joinmarket-webui/joinmarket-webui/issues/494)) ([89698f2](https://github.com/joinmarket-webui/joinmarket-webui/commit/89698f27f56bb8d12894a9a9f0e6827cbf120837))
|
||||
* remove jar source from scheduler options ([#465](https://github.com/joinmarket-webui/joinmarket-webui/issues/465)) ([b743357](https://github.com/joinmarket-webui/joinmarket-webui/commit/b7433571e9f3fdc3eee4acdf454cd578895b71ec))
|
||||
* serialize values of `/maker/start` request body as strings ([#458](https://github.com/joinmarket-webui/joinmarket-webui/issues/458)) ([dd7943b](https://github.com/joinmarket-webui/joinmarket-webui/commit/dd7943b979d546821e1cd824e533e0b803688ce8))
|
||||
* **settings:** matrix link ([#473](https://github.com/joinmarket-webui/joinmarket-webui/issues/473)) ([250f523](https://github.com/joinmarket-webui/joinmarket-webui/commit/250f523422c292232ec4db0c376d7582e29c8862))
|
||||
- create non-descriptor wallet ([#487](https://github.com/joinmarket-webui/joinmarket-webui/issues/487)) ([0d70415](https://github.com/joinmarket-webui/joinmarket-webui/commit/0d704158f4b41f1a1dc0adef058c13f6d6932190))
|
||||
- pass api token to session request ([#456](https://github.com/joinmarket-webui/joinmarket-webui/issues/456)) ([27e1a10](https://github.com/joinmarket-webui/joinmarket-webui/commit/27e1a10f63382b757baed53e98869b3ffbd2191d))
|
||||
- pass mixdepth prop as number in request body ([#457](https://github.com/joinmarket-webui/joinmarket-webui/issues/457)) ([155f9bd](https://github.com/joinmarket-webui/joinmarket-webui/commit/155f9bd55b4e385f49d49295ff52461f069f6b51))
|
||||
- precondition for collaborative transactions ([#485](https://github.com/joinmarket-webui/joinmarket-webui/issues/485)) ([db29235](https://github.com/joinmarket-webui/joinmarket-webui/commit/db292356f59de24bd80622d4a1eb57414d514e49))
|
||||
- proper margin for sweep button on invalid inputs ([#471](https://github.com/joinmarket-webui/joinmarket-webui/issues/471)) ([4a20c9f](https://github.com/joinmarket-webui/joinmarket-webui/commit/4a20c9fdd2eff40a2856d5c461dc4f18dba8f4f2))
|
||||
- re-add Joining icon ([#474](https://github.com/joinmarket-webui/joinmarket-webui/issues/474)) ([1d0f0cc](https://github.com/joinmarket-webui/joinmarket-webui/commit/1d0f0ccf5fc5e516d6781ee041588a43b7135070))
|
||||
- redirect to home if no wallet is active on route `/wallet` ([#492](https://github.com/joinmarket-webui/joinmarket-webui/issues/492)) ([2c3d6f7](https://github.com/joinmarket-webui/joinmarket-webui/commit/2c3d6f76feafd4400905dd66b5436a1fe93a0f09))
|
||||
- refresh orderbook ([#462](https://github.com/joinmarket-webui/joinmarket-webui/issues/462)) ([505e960](https://github.com/joinmarket-webui/joinmarket-webui/commit/505e9606f50d13d6d2b67f1002662254f94953bd))
|
||||
- reload wallet info after stopping scheduler manually ([#494](https://github.com/joinmarket-webui/joinmarket-webui/issues/494)) ([89698f2](https://github.com/joinmarket-webui/joinmarket-webui/commit/89698f27f56bb8d12894a9a9f0e6827cbf120837))
|
||||
- remove jar source from scheduler options ([#465](https://github.com/joinmarket-webui/joinmarket-webui/issues/465)) ([b743357](https://github.com/joinmarket-webui/joinmarket-webui/commit/b7433571e9f3fdc3eee4acdf454cd578895b71ec))
|
||||
- serialize values of `/maker/start` request body as strings ([#458](https://github.com/joinmarket-webui/joinmarket-webui/issues/458)) ([dd7943b](https://github.com/joinmarket-webui/joinmarket-webui/commit/dd7943b979d546821e1cd824e533e0b803688ce8))
|
||||
- **settings:** matrix link ([#473](https://github.com/joinmarket-webui/joinmarket-webui/issues/473)) ([250f523](https://github.com/joinmarket-webui/joinmarket-webui/commit/250f523422c292232ec4db0c376d7582e29c8862))
|
||||
|
||||
#### Added
|
||||
|
||||
* ability to retrieve logs ([#478](https://github.com/joinmarket-webui/joinmarket-webui/issues/478)) ([ace3734](https://github.com/joinmarket-webui/joinmarket-webui/commit/ace3734712518bec3868d6fefc921726a5c18b76))
|
||||
* abort collaborative transaction ([#497](https://github.com/joinmarket-webui/joinmarket-webui/issues/497)) ([80e40ff](https://github.com/joinmarket-webui/joinmarket-webui/commit/80e40ff51086e00e73de0fcf5cc977e8a1720cfc))
|
||||
* **cheatsheet:** update order ([#496](https://github.com/joinmarket-webui/joinmarket-webui/issues/496)) ([ff50e25](https://github.com/joinmarket-webui/joinmarket-webui/commit/ff50e25c11cc4600bc98b5799af0d0db4503186c))
|
||||
* click on active "joining" icon opens relevant screen ([#463](https://github.com/joinmarket-webui/joinmarket-webui/issues/463)) ([033babd](https://github.com/joinmarket-webui/joinmarket-webui/commit/033babd1ac1149343d9ef628aead754ac796208b))
|
||||
* colored jars with names ([#476](https://github.com/joinmarket-webui/joinmarket-webui/issues/476)) ([6a050f4](https://github.com/joinmarket-webui/joinmarket-webui/commit/6a050f4ccc60a74bb2f7cabf4792caea2a6267f5))
|
||||
* highlight own orders in orderbook ([#472](https://github.com/joinmarket-webui/joinmarket-webui/issues/472)) ([b19689d](https://github.com/joinmarket-webui/joinmarket-webui/commit/b19689de0c96b58d26931fb5cedfdd781bcc1d2b))
|
||||
* **jam:** remove "keep funds in jam" ([#484](https://github.com/joinmarket-webui/joinmarket-webui/issues/484)) ([5ada591](https://github.com/joinmarket-webui/joinmarket-webui/commit/5ada5911e1c161c1f949ada5cd2c0a851df02f68))
|
||||
* **navbar:** align app flow ([#490](https://github.com/joinmarket-webui/joinmarket-webui/issues/490)) ([6322c44](https://github.com/joinmarket-webui/joinmarket-webui/commit/6322c44440e149409bfb84945f598c890d815150))
|
||||
* rename "Joining" to "Jamming" ([#475](https://github.com/joinmarket-webui/joinmarket-webui/issues/475)) ([077b62a](https://github.com/joinmarket-webui/joinmarket-webui/commit/077b62ab802ebf9c94eaf12312273795f53efc67))
|
||||
* **send:** warn users with send button if preconditions not met ([#498](https://github.com/joinmarket-webui/joinmarket-webui/issues/498)) ([5dd6ce6](https://github.com/joinmarket-webui/joinmarket-webui/commit/5dd6ce62d54fd2b6d8b5618ddc58bb90cacb623f))
|
||||
* show active offers ([#461](https://github.com/joinmarket-webui/joinmarket-webui/issues/461)) ([c355d41](https://github.com/joinmarket-webui/joinmarket-webui/commit/c355d41f8b4420d3746116094a2be1b58a33b8fb))
|
||||
- ability to retrieve logs ([#478](https://github.com/joinmarket-webui/joinmarket-webui/issues/478)) ([ace3734](https://github.com/joinmarket-webui/joinmarket-webui/commit/ace3734712518bec3868d6fefc921726a5c18b76))
|
||||
- abort collaborative transaction ([#497](https://github.com/joinmarket-webui/joinmarket-webui/issues/497)) ([80e40ff](https://github.com/joinmarket-webui/joinmarket-webui/commit/80e40ff51086e00e73de0fcf5cc977e8a1720cfc))
|
||||
- **cheatsheet:** update order ([#496](https://github.com/joinmarket-webui/joinmarket-webui/issues/496)) ([ff50e25](https://github.com/joinmarket-webui/joinmarket-webui/commit/ff50e25c11cc4600bc98b5799af0d0db4503186c))
|
||||
- click on active "joining" icon opens relevant screen ([#463](https://github.com/joinmarket-webui/joinmarket-webui/issues/463)) ([033babd](https://github.com/joinmarket-webui/joinmarket-webui/commit/033babd1ac1149343d9ef628aead754ac796208b))
|
||||
- colored jars with names ([#476](https://github.com/joinmarket-webui/joinmarket-webui/issues/476)) ([6a050f4](https://github.com/joinmarket-webui/joinmarket-webui/commit/6a050f4ccc60a74bb2f7cabf4792caea2a6267f5))
|
||||
- highlight own orders in orderbook ([#472](https://github.com/joinmarket-webui/joinmarket-webui/issues/472)) ([b19689d](https://github.com/joinmarket-webui/joinmarket-webui/commit/b19689de0c96b58d26931fb5cedfdd781bcc1d2b))
|
||||
- **jam:** remove "keep funds in jam" ([#484](https://github.com/joinmarket-webui/joinmarket-webui/issues/484)) ([5ada591](https://github.com/joinmarket-webui/joinmarket-webui/commit/5ada5911e1c161c1f949ada5cd2c0a851df02f68))
|
||||
- **navbar:** align app flow ([#490](https://github.com/joinmarket-webui/joinmarket-webui/issues/490)) ([6322c44](https://github.com/joinmarket-webui/joinmarket-webui/commit/6322c44440e149409bfb84945f598c890d815150))
|
||||
- rename "Joining" to "Jamming" ([#475](https://github.com/joinmarket-webui/joinmarket-webui/issues/475)) ([077b62a](https://github.com/joinmarket-webui/joinmarket-webui/commit/077b62ab802ebf9c94eaf12312273795f53efc67))
|
||||
- **send:** warn users with send button if preconditions not met ([#498](https://github.com/joinmarket-webui/joinmarket-webui/issues/498)) ([5dd6ce6](https://github.com/joinmarket-webui/joinmarket-webui/commit/5dd6ce62d54fd2b6d8b5618ddc58bb90cacb623f))
|
||||
- show active offers ([#461](https://github.com/joinmarket-webui/joinmarket-webui/issues/461)) ([c355d41](https://github.com/joinmarket-webui/joinmarket-webui/commit/c355d41f8b4420d3746116094a2be1b58a33b8fb))
|
||||
|
||||
### [0.0.10](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.9...v0.0.10) (2022-08-05)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* accordion bg color ([#449](https://github.com/joinmarket-webui/joinmarket-webui/issues/449)) ([c3159a2](https://github.com/joinmarket-webui/joinmarket-webui/commit/c3159a252e55f754fbbe4a0f40fc05ddcd0b5fe2))
|
||||
* cheatsheet icon ([#447](https://github.com/joinmarket-webui/joinmarket-webui/issues/447)) ([1113e21](https://github.com/joinmarket-webui/joinmarket-webui/commit/1113e21147a171a778e3439ee8df5474dce840dd))
|
||||
* color of light-button in dark mode ([#442](https://github.com/joinmarket-webui/joinmarket-webui/issues/442)) ([f27a27a](https://github.com/joinmarket-webui/joinmarket-webui/commit/f27a27a93df4f070b9f308d75409290dd9357eff))
|
||||
* color of selected collaborators-selector-input ([#410](https://github.com/joinmarket-webui/joinmarket-webui/issues/410)) ([a51cdbe](https://github.com/joinmarket-webui/joinmarket-webui/commit/a51cdbe53e30fc3436764f1cdd5d81c0b7cc6e0d))
|
||||
* do not display freeze info when all utxos have been selected ([#420](https://github.com/joinmarket-webui/joinmarket-webui/issues/420)) ([662faf2](https://github.com/joinmarket-webui/joinmarket-webui/commit/662faf2c5523c073385a81053ff5bd861429d039))
|
||||
* docs icon in settings ([#439](https://github.com/joinmarket-webui/joinmarket-webui/issues/439)) ([c4a76fa](https://github.com/joinmarket-webui/joinmarket-webui/commit/c4a76fa967365c6d39cbf974c9838c7d17163a15))
|
||||
* invalid DOM property 'class' on Earn page ([#431](https://github.com/joinmarket-webui/joinmarket-webui/issues/431)) ([3d3706f](https://github.com/joinmarket-webui/joinmarket-webui/commit/3d3706f5b2df715dd20e3b03d1be034b3de6d67e))
|
||||
* jar spacing ([#417](https://github.com/joinmarket-webui/joinmarket-webui/issues/417)) ([484afbc](https://github.com/joinmarket-webui/joinmarket-webui/commit/484afbc8eabee75fc8896e868ff559c479dc1380))
|
||||
* remove logs ([#452](https://github.com/joinmarket-webui/joinmarket-webui/issues/452)) ([2097b35](https://github.com/joinmarket-webui/joinmarket-webui/commit/2097b35a5bd4a5cba40864e52814bc10927bfceb))
|
||||
* spacing in jar overlay header and `onKeyDown` ([#421](https://github.com/joinmarket-webui/joinmarket-webui/issues/421)) ([24c0107](https://github.com/joinmarket-webui/joinmarket-webui/commit/24c01070365cbfcfc52d6cddad73d866bbbcf0dc))
|
||||
* text and border colors after bootstrap update ([#432](https://github.com/joinmarket-webui/joinmarket-webui/issues/432)) ([96a401c](https://github.com/joinmarket-webui/joinmarket-webui/commit/96a401ccadaa5609c61162f739bf8eca5b5367c0))
|
||||
- accordion bg color ([#449](https://github.com/joinmarket-webui/joinmarket-webui/issues/449)) ([c3159a2](https://github.com/joinmarket-webui/joinmarket-webui/commit/c3159a252e55f754fbbe4a0f40fc05ddcd0b5fe2))
|
||||
- cheatsheet icon ([#447](https://github.com/joinmarket-webui/joinmarket-webui/issues/447)) ([1113e21](https://github.com/joinmarket-webui/joinmarket-webui/commit/1113e21147a171a778e3439ee8df5474dce840dd))
|
||||
- color of light-button in dark mode ([#442](https://github.com/joinmarket-webui/joinmarket-webui/issues/442)) ([f27a27a](https://github.com/joinmarket-webui/joinmarket-webui/commit/f27a27a93df4f070b9f308d75409290dd9357eff))
|
||||
- color of selected collaborators-selector-input ([#410](https://github.com/joinmarket-webui/joinmarket-webui/issues/410)) ([a51cdbe](https://github.com/joinmarket-webui/joinmarket-webui/commit/a51cdbe53e30fc3436764f1cdd5d81c0b7cc6e0d))
|
||||
- do not display freeze info when all utxos have been selected ([#420](https://github.com/joinmarket-webui/joinmarket-webui/issues/420)) ([662faf2](https://github.com/joinmarket-webui/joinmarket-webui/commit/662faf2c5523c073385a81053ff5bd861429d039))
|
||||
- docs icon in settings ([#439](https://github.com/joinmarket-webui/joinmarket-webui/issues/439)) ([c4a76fa](https://github.com/joinmarket-webui/joinmarket-webui/commit/c4a76fa967365c6d39cbf974c9838c7d17163a15))
|
||||
- invalid DOM property 'class' on Earn page ([#431](https://github.com/joinmarket-webui/joinmarket-webui/issues/431)) ([3d3706f](https://github.com/joinmarket-webui/joinmarket-webui/commit/3d3706f5b2df715dd20e3b03d1be034b3de6d67e))
|
||||
- jar spacing ([#417](https://github.com/joinmarket-webui/joinmarket-webui/issues/417)) ([484afbc](https://github.com/joinmarket-webui/joinmarket-webui/commit/484afbc8eabee75fc8896e868ff559c479dc1380))
|
||||
- remove logs ([#452](https://github.com/joinmarket-webui/joinmarket-webui/issues/452)) ([2097b35](https://github.com/joinmarket-webui/joinmarket-webui/commit/2097b35a5bd4a5cba40864e52814bc10927bfceb))
|
||||
- spacing in jar overlay header and `onKeyDown` ([#421](https://github.com/joinmarket-webui/joinmarket-webui/issues/421)) ([24c0107](https://github.com/joinmarket-webui/joinmarket-webui/commit/24c01070365cbfcfc52d6cddad73d866bbbcf0dc))
|
||||
- text and border colors after bootstrap update ([#432](https://github.com/joinmarket-webui/joinmarket-webui/issues/432)) ([96a401c](https://github.com/joinmarket-webui/joinmarket-webui/commit/96a401ccadaa5609c61162f739bf8eca5b5367c0))
|
||||
|
||||
#### Added
|
||||
|
||||
* ability to sort and filter orderbook ([#434](https://github.com/joinmarket-webui/joinmarket-webui/issues/434)) ([952e24b](https://github.com/joinmarket-webui/joinmarket-webui/commit/952e24b2e9e4362a05e2eba2b2b9e19c398586e9))
|
||||
* add description for second fidelity bond ([#414](https://github.com/joinmarket-webui/joinmarket-webui/issues/414)) ([ad50747](https://github.com/joinmarket-webui/joinmarket-webui/commit/ad5074781831918156869bb51c6aa967b1d5c88e))
|
||||
* add sorting and filtering to earn report ([#451](https://github.com/joinmarket-webui/joinmarket-webui/issues/451)) ([bd8fd97](https://github.com/joinmarket-webui/joinmarket-webui/commit/bd8fd974f565ca525ac94aa9e1f51f7592ce7f6e))
|
||||
* **cheatsheet:** link to jamdocs.org ([#427](https://github.com/joinmarket-webui/joinmarket-webui/issues/427)) ([b442d58](https://github.com/joinmarket-webui/joinmarket-webui/commit/b442d58b61c8ff24f65bb6c12c295cdda195e0a1))
|
||||
* **cheatsheet:** link to jamdocs.org ([#429](https://github.com/joinmarket-webui/joinmarket-webui/issues/429)) ([05c908d](https://github.com/joinmarket-webui/joinmarket-webui/commit/05c908d6f6a671e19d41021cd883cf9483dc4052))
|
||||
* **cheatsheet:** re-word to remove 'yield' ([#426](https://github.com/joinmarket-webui/joinmarket-webui/issues/426)) ([893dfd0](https://github.com/joinmarket-webui/joinmarket-webui/commit/893dfd03978909ecff36a9aa0aedd084e9b1c669)), closes [#326](https://github.com/joinmarket-webui/joinmarket-webui/issues/326)
|
||||
* enable orderbook for all users ([#445](https://github.com/joinmarket-webui/joinmarket-webui/issues/445)) ([2d9d13e](https://github.com/joinmarket-webui/joinmarket-webui/commit/2d9d13ea907bde1c5f49a1344fca80defcfd4b57))
|
||||
* human readable locktime duration for fidelity bonds ([#450](https://github.com/joinmarket-webui/joinmarket-webui/issues/450)) ([9d8e656](https://github.com/joinmarket-webui/joinmarket-webui/commit/9d8e65698ef44b46ddf7bc4b1c32490a135781af))
|
||||
* improve earn report ([#409](https://github.com/joinmarket-webui/joinmarket-webui/issues/409)) ([dc36271](https://github.com/joinmarket-webui/joinmarket-webui/commit/dc36271aa61d2790eae8fcf7b79e06cd2610a3f3))
|
||||
* Orderbook ([#422](https://github.com/joinmarket-webui/joinmarket-webui/issues/422)) ([2406c04](https://github.com/joinmarket-webui/joinmarket-webui/commit/2406c04a7aad901393df7eb65dc2427587a3a8bc))
|
||||
* payment confirm modal ([#446](https://github.com/joinmarket-webui/joinmarket-webui/issues/446)) ([29eca37](https://github.com/joinmarket-webui/joinmarket-webui/commit/29eca37535b026d2c3eddb87170c8345bf287bc2))
|
||||
* prevent address reuse on Jam page ([#433](https://github.com/joinmarket-webui/joinmarket-webui/issues/433)) ([6a8830f](https://github.com/joinmarket-webui/joinmarket-webui/commit/6a8830f28d01aa6312dff6a53f70c7df3882f827))
|
||||
* **settings:** add link to Matrix and Jam's twitter ([#436](https://github.com/joinmarket-webui/joinmarket-webui/issues/436)) ([ca3cc20](https://github.com/joinmarket-webui/joinmarket-webui/commit/ca3cc20e15c41176b2aff1fedb8f871b63764ba9))
|
||||
* **settings:** add links to docs ([#437](https://github.com/joinmarket-webui/joinmarket-webui/issues/437)) ([01515d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/01515d7f1432b10c5d532ac20446be720507cabd))
|
||||
* show address reuse warning ([#411](https://github.com/joinmarket-webui/joinmarket-webui/issues/411)) ([b2faeb7](https://github.com/joinmarket-webui/joinmarket-webui/commit/b2faeb747613beb74060e55b15c7f701e2ff6caa))
|
||||
* utxo list ([#430](https://github.com/joinmarket-webui/joinmarket-webui/issues/430)) ([61a3956](https://github.com/joinmarket-webui/joinmarket-webui/commit/61a39566f657353f4fd08851e1479724433e888a))
|
||||
- ability to sort and filter orderbook ([#434](https://github.com/joinmarket-webui/joinmarket-webui/issues/434)) ([952e24b](https://github.com/joinmarket-webui/joinmarket-webui/commit/952e24b2e9e4362a05e2eba2b2b9e19c398586e9))
|
||||
- add description for second fidelity bond ([#414](https://github.com/joinmarket-webui/joinmarket-webui/issues/414)) ([ad50747](https://github.com/joinmarket-webui/joinmarket-webui/commit/ad5074781831918156869bb51c6aa967b1d5c88e))
|
||||
- add sorting and filtering to earn report ([#451](https://github.com/joinmarket-webui/joinmarket-webui/issues/451)) ([bd8fd97](https://github.com/joinmarket-webui/joinmarket-webui/commit/bd8fd974f565ca525ac94aa9e1f51f7592ce7f6e))
|
||||
- **cheatsheet:** link to jamdocs.org ([#427](https://github.com/joinmarket-webui/joinmarket-webui/issues/427)) ([b442d58](https://github.com/joinmarket-webui/joinmarket-webui/commit/b442d58b61c8ff24f65bb6c12c295cdda195e0a1))
|
||||
- **cheatsheet:** link to jamdocs.org ([#429](https://github.com/joinmarket-webui/joinmarket-webui/issues/429)) ([05c908d](https://github.com/joinmarket-webui/joinmarket-webui/commit/05c908d6f6a671e19d41021cd883cf9483dc4052))
|
||||
- **cheatsheet:** re-word to remove 'yield' ([#426](https://github.com/joinmarket-webui/joinmarket-webui/issues/426)) ([893dfd0](https://github.com/joinmarket-webui/joinmarket-webui/commit/893dfd03978909ecff36a9aa0aedd084e9b1c669)), closes [#326](https://github.com/joinmarket-webui/joinmarket-webui/issues/326)
|
||||
- enable orderbook for all users ([#445](https://github.com/joinmarket-webui/joinmarket-webui/issues/445)) ([2d9d13e](https://github.com/joinmarket-webui/joinmarket-webui/commit/2d9d13ea907bde1c5f49a1344fca80defcfd4b57))
|
||||
- human readable locktime duration for fidelity bonds ([#450](https://github.com/joinmarket-webui/joinmarket-webui/issues/450)) ([9d8e656](https://github.com/joinmarket-webui/joinmarket-webui/commit/9d8e65698ef44b46ddf7bc4b1c32490a135781af))
|
||||
- improve earn report ([#409](https://github.com/joinmarket-webui/joinmarket-webui/issues/409)) ([dc36271](https://github.com/joinmarket-webui/joinmarket-webui/commit/dc36271aa61d2790eae8fcf7b79e06cd2610a3f3))
|
||||
- Orderbook ([#422](https://github.com/joinmarket-webui/joinmarket-webui/issues/422)) ([2406c04](https://github.com/joinmarket-webui/joinmarket-webui/commit/2406c04a7aad901393df7eb65dc2427587a3a8bc))
|
||||
- payment confirm modal ([#446](https://github.com/joinmarket-webui/joinmarket-webui/issues/446)) ([29eca37](https://github.com/joinmarket-webui/joinmarket-webui/commit/29eca37535b026d2c3eddb87170c8345bf287bc2))
|
||||
- prevent address reuse on Jam page ([#433](https://github.com/joinmarket-webui/joinmarket-webui/issues/433)) ([6a8830f](https://github.com/joinmarket-webui/joinmarket-webui/commit/6a8830f28d01aa6312dff6a53f70c7df3882f827))
|
||||
- **settings:** add link to Matrix and Jam's twitter ([#436](https://github.com/joinmarket-webui/joinmarket-webui/issues/436)) ([ca3cc20](https://github.com/joinmarket-webui/joinmarket-webui/commit/ca3cc20e15c41176b2aff1fedb8f871b63764ba9))
|
||||
- **settings:** add links to docs ([#437](https://github.com/joinmarket-webui/joinmarket-webui/issues/437)) ([01515d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/01515d7f1432b10c5d532ac20446be720507cabd))
|
||||
- show address reuse warning ([#411](https://github.com/joinmarket-webui/joinmarket-webui/issues/411)) ([b2faeb7](https://github.com/joinmarket-webui/joinmarket-webui/commit/b2faeb747613beb74060e55b15c7f701e2ff6caa))
|
||||
- utxo list ([#430](https://github.com/joinmarket-webui/joinmarket-webui/issues/430)) ([61a3956](https://github.com/joinmarket-webui/joinmarket-webui/commit/61a39566f657353f4fd08851e1479724433e888a))
|
||||
|
||||
### [0.0.9](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.8...v0.0.9) (2022-07-14)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* advanced wording and behavior ([#390](https://github.com/joinmarket-webui/joinmarket-webui/issues/390)) ([c25c3ce](https://github.com/joinmarket-webui/joinmarket-webui/commit/c25c3ce6259112c702e249e4af3396001f8bbe3e))
|
||||
* disable 'create wallet' link when unlocking wallet ([#334](https://github.com/joinmarket-webui/joinmarket-webui/issues/334)) ([e3083b9](https://github.com/joinmarket-webui/joinmarket-webui/commit/e3083b9c8ba5a99747f588188769b954ed03f0c2))
|
||||
* do not show FB create form when maker is running ([#384](https://github.com/joinmarket-webui/joinmarket-webui/issues/384)) ([e0f51fa](https://github.com/joinmarket-webui/joinmarket-webui/commit/e0f51faacbef44a3914c3f000722761362b47ee0))
|
||||
* do not show expired fidelity bonds as locked ([#378](https://github.com/joinmarket-webui/joinmarket-webui/issues/378)) ([0f7d590](https://github.com/joinmarket-webui/joinmarket-webui/commit/0f7d590e352d0dd1f8dce6e55a495b789aef2d31))
|
||||
* encode wallet name param in url path ([#389](https://github.com/joinmarket-webui/joinmarket-webui/issues/389)) ([a98317b](https://github.com/joinmarket-webui/joinmarket-webui/commit/a98317b34403d07ef8ca3b878e201458fcd9f8fe))
|
||||
* link to new fidelity bonds form in Cheatsheet component ([#376](https://github.com/joinmarket-webui/joinmarket-webui/issues/376)) ([21757e9](https://github.com/joinmarket-webui/joinmarket-webui/commit/21757e9588a38af21304f1109cc7573155162b3b))
|
||||
* pass body of confirm modal via child node ([#377](https://github.com/joinmarket-webui/joinmarket-webui/issues/377)) ([23f7383](https://github.com/joinmarket-webui/joinmarket-webui/commit/23f738355153df8f88164584bbde236426e5d30e))
|
||||
* reload utxos after creating a fidelity bond ([#380](https://github.com/joinmarket-webui/joinmarket-webui/issues/380)) ([72a3e8c](https://github.com/joinmarket-webui/joinmarket-webui/commit/72a3e8cff6bfa2cc6bbbd3f6afcefe51f4830d15))
|
||||
- advanced wording and behavior ([#390](https://github.com/joinmarket-webui/joinmarket-webui/issues/390)) ([c25c3ce](https://github.com/joinmarket-webui/joinmarket-webui/commit/c25c3ce6259112c702e249e4af3396001f8bbe3e))
|
||||
- disable 'create wallet' link when unlocking wallet ([#334](https://github.com/joinmarket-webui/joinmarket-webui/issues/334)) ([e3083b9](https://github.com/joinmarket-webui/joinmarket-webui/commit/e3083b9c8ba5a99747f588188769b954ed03f0c2))
|
||||
- do not show FB create form when maker is running ([#384](https://github.com/joinmarket-webui/joinmarket-webui/issues/384)) ([e0f51fa](https://github.com/joinmarket-webui/joinmarket-webui/commit/e0f51faacbef44a3914c3f000722761362b47ee0))
|
||||
- do not show expired fidelity bonds as locked ([#378](https://github.com/joinmarket-webui/joinmarket-webui/issues/378)) ([0f7d590](https://github.com/joinmarket-webui/joinmarket-webui/commit/0f7d590e352d0dd1f8dce6e55a495b789aef2d31))
|
||||
- encode wallet name param in url path ([#389](https://github.com/joinmarket-webui/joinmarket-webui/issues/389)) ([a98317b](https://github.com/joinmarket-webui/joinmarket-webui/commit/a98317b34403d07ef8ca3b878e201458fcd9f8fe))
|
||||
- link to new fidelity bonds form in Cheatsheet component ([#376](https://github.com/joinmarket-webui/joinmarket-webui/issues/376)) ([21757e9](https://github.com/joinmarket-webui/joinmarket-webui/commit/21757e9588a38af21304f1109cc7573155162b3b))
|
||||
- pass body of confirm modal via child node ([#377](https://github.com/joinmarket-webui/joinmarket-webui/issues/377)) ([23f7383](https://github.com/joinmarket-webui/joinmarket-webui/commit/23f738355153df8f88164584bbde236426e5d30e))
|
||||
- reload utxos after creating a fidelity bond ([#380](https://github.com/joinmarket-webui/joinmarket-webui/issues/380)) ([72a3e8c](https://github.com/joinmarket-webui/joinmarket-webui/commit/72a3e8cff6bfa2cc6bbbd3f6afcefe51f4830d15))
|
||||
|
||||
#### Added
|
||||
|
||||
* batch unfreeze UTXOs after creating fidelity bond ([#388](https://github.com/joinmarket-webui/joinmarket-webui/issues/388)) ([efa3361](https://github.com/joinmarket-webui/joinmarket-webui/commit/efa336117779b6f24a5165b41c9e863c85b90c7c))
|
||||
* move fidelity bonds to earn screen ([#361](https://github.com/joinmarket-webui/joinmarket-webui/issues/361)) ([8608329](https://github.com/joinmarket-webui/joinmarket-webui/commit/8608329f3e45f1cc6c5cae43f8960ae01a9e147c))
|
||||
* visual warning when selecting non cj-out UTXOs for fidelity bond ([#392](https://github.com/joinmarket-webui/joinmarket-webui/issues/392)) ([bad9a57](https://github.com/joinmarket-webui/joinmarket-webui/commit/bad9a5741ad15e9c8608582a99c963875ae51bfb))
|
||||
- batch unfreeze UTXOs after creating fidelity bond ([#388](https://github.com/joinmarket-webui/joinmarket-webui/issues/388)) ([efa3361](https://github.com/joinmarket-webui/joinmarket-webui/commit/efa336117779b6f24a5165b41c9e863c85b90c7c))
|
||||
- move fidelity bonds to earn screen ([#361](https://github.com/joinmarket-webui/joinmarket-webui/issues/361)) ([8608329](https://github.com/joinmarket-webui/joinmarket-webui/commit/8608329f3e45f1cc6c5cae43f8960ae01a9e147c))
|
||||
- visual warning when selecting non cj-out UTXOs for fidelity bond ([#392](https://github.com/joinmarket-webui/joinmarket-webui/issues/392)) ([bad9a57](https://github.com/joinmarket-webui/joinmarket-webui/commit/bad9a5741ad15e9c8608582a99c963875ae51bfb))
|
||||
|
||||
### [0.0.8](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.7...v0.0.8) (2022-06-28)
|
||||
|
||||
#### Added
|
||||
|
||||
* **jars:** add receive shortcut if jar 0 is empty ([#344](https://github.com/joinmarket-webui/joinmarket-webui/issues/344)) ([01afb88](https://github.com/joinmarket-webui/joinmarket-webui/commit/01afb88888c173b677f4979c5ae8eef350a9da19))
|
||||
* **jars:** destination jar selector on receive screen ([#346](https://github.com/joinmarket-webui/joinmarket-webui/issues/346)) ([911177c](https://github.com/joinmarket-webui/joinmarket-webui/commit/911177c899fe43fe2d889718dc644c6630629d8f))
|
||||
* **jars:** destination jar selector on send screen ([#345](https://github.com/joinmarket-webui/joinmarket-webui/issues/345)) ([a08c584](https://github.com/joinmarket-webui/joinmarket-webui/commit/a08c584f5ee51fd88e5b16c862626717955c7102))
|
||||
- **jars:** add receive shortcut if jar 0 is empty ([#344](https://github.com/joinmarket-webui/joinmarket-webui/issues/344)) ([01afb88](https://github.com/joinmarket-webui/joinmarket-webui/commit/01afb88888c173b677f4979c5ae8eef350a9da19))
|
||||
- **jars:** destination jar selector on receive screen ([#346](https://github.com/joinmarket-webui/joinmarket-webui/issues/346)) ([911177c](https://github.com/joinmarket-webui/joinmarket-webui/commit/911177c899fe43fe2d889718dc644c6630629d8f))
|
||||
- **jars:** destination jar selector on send screen ([#345](https://github.com/joinmarket-webui/joinmarket-webui/issues/345)) ([a08c584](https://github.com/joinmarket-webui/joinmarket-webui/commit/a08c584f5ee51fd88e5b16c862626717955c7102))
|
||||
|
||||
#### Fixed
|
||||
|
||||
* Check preconditions before send request ([#349](https://github.com/joinmarket-webui/joinmarket-webui/issues/349)) ([581184d](https://github.com/joinmarket-webui/joinmarket-webui/commit/581184d92fa4bab1054d1d6e34d7777abebe7f00))
|
||||
* checked state of ToggleSwitch can be controlled by caller ([#332](https://github.com/joinmarket-webui/joinmarket-webui/issues/332)) ([c9007f5](https://github.com/joinmarket-webui/joinmarket-webui/commit/c9007f5428ae4ba79d68cd39972565a1747120e1))
|
||||
* remove fidelity bond feature flag ([9fe84c8](https://github.com/joinmarket-webui/joinmarket-webui/commit/9fe84c8cda8e3959d13fa436ea9f650bf1d0b3ed))
|
||||
- Check preconditions before send request ([#349](https://github.com/joinmarket-webui/joinmarket-webui/issues/349)) ([581184d](https://github.com/joinmarket-webui/joinmarket-webui/commit/581184d92fa4bab1054d1d6e34d7777abebe7f00))
|
||||
- checked state of ToggleSwitch can be controlled by caller ([#332](https://github.com/joinmarket-webui/joinmarket-webui/issues/332)) ([c9007f5](https://github.com/joinmarket-webui/joinmarket-webui/commit/c9007f5428ae4ba79d68cd39972565a1747120e1))
|
||||
- remove fidelity bond feature flag ([9fe84c8](https://github.com/joinmarket-webui/joinmarket-webui/commit/9fe84c8cda8e3959d13fa436ea9f650bf1d0b3ed))
|
||||
|
||||
### [0.0.7](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.6...v0.0.7) (2022-06-20)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* loading state on Send page ([#300](https://github.com/joinmarket-webui/joinmarket-webui/issues/300)) ([db4f5ab](https://github.com/joinmarket-webui/joinmarket-webui/commit/db4f5ab18a6dcef6784c2f85adffc69a014570cc))
|
||||
* mobile layout issues ([#311](https://github.com/joinmarket-webui/joinmarket-webui/issues/311)) ([8f62a42](https://github.com/joinmarket-webui/joinmarket-webui/commit/8f62a42302e40672eeeb78dc896e6116501a5905))
|
||||
* prevent unnecessary session requests ([#298](https://github.com/joinmarket-webui/joinmarket-webui/issues/298)) ([bf627e7](https://github.com/joinmarket-webui/joinmarket-webui/commit/bf627e7ce128dea4679ba8e4035f578fad7a710b))
|
||||
* prevent unnecessary wallet info requests ([#297](https://github.com/joinmarket-webui/joinmarket-webui/issues/297)) ([9377b33](https://github.com/joinmarket-webui/joinmarket-webui/commit/9377b33a11abbbafefbc93e0e34f2df59dd1749a))
|
||||
* show balance in unit based on settings on Send screen ([#276](https://github.com/joinmarket-webui/joinmarket-webui/issues/276)) ([b0c8c4f](https://github.com/joinmarket-webui/joinmarket-webui/commit/b0c8c4f83c7e98b61be4f9a551a2b7d70c40aa43))
|
||||
* styles ([#329](https://github.com/joinmarket-webui/joinmarket-webui/issues/329)) ([f5e8227](https://github.com/joinmarket-webui/joinmarket-webui/commit/f5e822743e8ad9ac0e8da77c0deb98573fcb325a))
|
||||
- loading state on Send page ([#300](https://github.com/joinmarket-webui/joinmarket-webui/issues/300)) ([db4f5ab](https://github.com/joinmarket-webui/joinmarket-webui/commit/db4f5ab18a6dcef6784c2f85adffc69a014570cc))
|
||||
- mobile layout issues ([#311](https://github.com/joinmarket-webui/joinmarket-webui/issues/311)) ([8f62a42](https://github.com/joinmarket-webui/joinmarket-webui/commit/8f62a42302e40672eeeb78dc896e6116501a5905))
|
||||
- prevent unnecessary session requests ([#298](https://github.com/joinmarket-webui/joinmarket-webui/issues/298)) ([bf627e7](https://github.com/joinmarket-webui/joinmarket-webui/commit/bf627e7ce128dea4679ba8e4035f578fad7a710b))
|
||||
- prevent unnecessary wallet info requests ([#297](https://github.com/joinmarket-webui/joinmarket-webui/issues/297)) ([9377b33](https://github.com/joinmarket-webui/joinmarket-webui/commit/9377b33a11abbbafefbc93e0e34f2df59dd1749a))
|
||||
- show balance in unit based on settings on Send screen ([#276](https://github.com/joinmarket-webui/joinmarket-webui/issues/276)) ([b0c8c4f](https://github.com/joinmarket-webui/joinmarket-webui/commit/b0c8c4f83c7e98b61be4f9a551a2b7d70c40aa43))
|
||||
- styles ([#329](https://github.com/joinmarket-webui/joinmarket-webui/issues/329)) ([f5e8227](https://github.com/joinmarket-webui/joinmarket-webui/commit/f5e822743e8ad9ac0e8da77c0deb98573fcb325a))
|
||||
|
||||
#### Added
|
||||
|
||||
* add share button to receive screen ([#310](https://github.com/joinmarket-webui/joinmarket-webui/issues/310)) ([ed03476](https://github.com/joinmarket-webui/joinmarket-webui/commit/ed03476766eaf31eed4589aa25e594d13023c29d))
|
||||
* basic fidelity bonds ([#307](https://github.com/joinmarket-webui/joinmarket-webui/issues/307)) ([c68e4c5](https://github.com/joinmarket-webui/joinmarket-webui/commit/c68e4c5c64c8daa1f79307adf9b0d13b5ad6704c))
|
||||
* enable report overlay ([#305](https://github.com/joinmarket-webui/joinmarket-webui/issues/305)) ([69c4211](https://github.com/joinmarket-webui/joinmarket-webui/commit/69c4211b8271b9c0b2f77d74d9bf630180a50495))
|
||||
* first draft of jars on main wallet screen ([#324](https://github.com/joinmarket-webui/joinmarket-webui/issues/324)) ([216100a](https://github.com/joinmarket-webui/joinmarket-webui/commit/216100a3b973f4e91dedfbd866b40d9e268cca41))
|
||||
* improve wallet control in settings ([#325](https://github.com/joinmarket-webui/joinmarket-webui/issues/325)) ([9d00212](https://github.com/joinmarket-webui/joinmarket-webui/commit/9d0021287f75c1a7349363fb484ec4f36810b36d))
|
||||
* make jars interactive ([#331](https://github.com/joinmarket-webui/joinmarket-webui/issues/331)) ([95b3f09](https://github.com/joinmarket-webui/joinmarket-webui/commit/95b3f09696bcfb39bc9bd94aaacf8953542d5a90))
|
||||
* **navbar:** remove wallets item ([#316](https://github.com/joinmarket-webui/joinmarket-webui/issues/316)) ([da99a3e](https://github.com/joinmarket-webui/joinmarket-webui/commit/da99a3e5ea188480b4b74599a782b43e35e7e6b1)), closes [#315](https://github.com/joinmarket-webui/joinmarket-webui/issues/315)
|
||||
- add share button to receive screen ([#310](https://github.com/joinmarket-webui/joinmarket-webui/issues/310)) ([ed03476](https://github.com/joinmarket-webui/joinmarket-webui/commit/ed03476766eaf31eed4589aa25e594d13023c29d))
|
||||
- basic fidelity bonds ([#307](https://github.com/joinmarket-webui/joinmarket-webui/issues/307)) ([c68e4c5](https://github.com/joinmarket-webui/joinmarket-webui/commit/c68e4c5c64c8daa1f79307adf9b0d13b5ad6704c))
|
||||
- enable report overlay ([#305](https://github.com/joinmarket-webui/joinmarket-webui/issues/305)) ([69c4211](https://github.com/joinmarket-webui/joinmarket-webui/commit/69c4211b8271b9c0b2f77d74d9bf630180a50495))
|
||||
- first draft of jars on main wallet screen ([#324](https://github.com/joinmarket-webui/joinmarket-webui/issues/324)) ([216100a](https://github.com/joinmarket-webui/joinmarket-webui/commit/216100a3b973f4e91dedfbd866b40d9e268cca41))
|
||||
- improve wallet control in settings ([#325](https://github.com/joinmarket-webui/joinmarket-webui/issues/325)) ([9d00212](https://github.com/joinmarket-webui/joinmarket-webui/commit/9d0021287f75c1a7349363fb484ec4f36810b36d))
|
||||
- make jars interactive ([#331](https://github.com/joinmarket-webui/joinmarket-webui/issues/331)) ([95b3f09](https://github.com/joinmarket-webui/joinmarket-webui/commit/95b3f09696bcfb39bc9bd94aaacf8953542d5a90))
|
||||
- **navbar:** remove wallets item ([#316](https://github.com/joinmarket-webui/joinmarket-webui/issues/316)) ([da99a3e](https://github.com/joinmarket-webui/joinmarket-webui/commit/da99a3e5ea188480b4b74599a782b43e35e7e6b1)), closes [#315](https://github.com/joinmarket-webui/joinmarket-webui/issues/315)
|
||||
|
||||
### [0.0.6](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.5...v0.0.6) (2022-05-19)
|
||||
|
||||
#### Added
|
||||
|
||||
* add cheatsheet ([#211](https://github.com/joinmarket-webui/joinmarket-webui/issues/211)) ([825f725](https://github.com/joinmarket-webui/joinmarket-webui/commit/825f725053f4c11a696929516cc48f40ffc1aee5))
|
||||
* add French translation ([#216](https://github.com/joinmarket-webui/joinmarket-webui/issues/216)) ([69fcbaf](https://github.com/joinmarket-webui/joinmarket-webui/commit/69fcbaff42af41f7723fd46d318009e969a5ed12))
|
||||
* confirm password on Create Wallet screen ([#210](https://github.com/joinmarket-webui/joinmarket-webui/issues/210)) ([0c019db](https://github.com/joinmarket-webui/joinmarket-webui/commit/0c019db742e19efda1dab1f81f857d286c3ca1b5))
|
||||
* **footer:** show Jam version ([#281](https://github.com/joinmarket-webui/joinmarket-webui/issues/281)) ([3c886f3](https://github.com/joinmarket-webui/joinmarket-webui/commit/3c886f3e05f81377f6a4b06e2ada6a020b6ce2aa))
|
||||
* Individual balance toggle ([#247](https://github.com/joinmarket-webui/joinmarket-webui/issues/247)) ([e6c4cc1](https://github.com/joinmarket-webui/joinmarket-webui/commit/e6c4cc1c59925a630d0595148025ce784f3641ce))
|
||||
* prevent address reuse on Jam screen ([#272](https://github.com/joinmarket-webui/joinmarket-webui/issues/272)) ([c05b431](https://github.com/joinmarket-webui/joinmarket-webui/commit/c05b431191f49718f63032d3b0fc10ee8991596d))
|
||||
* rearrange order of tabs ([#258](https://github.com/joinmarket-webui/joinmarket-webui/issues/258)) ([8f527d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/8f527d7be83c9326e6583fa14888be84a37ecfff))
|
||||
* reload wallet data after send ([#236](https://github.com/joinmarket-webui/joinmarket-webui/issues/236)) ([edd5818](https://github.com/joinmarket-webui/joinmarket-webui/commit/edd5818b824276dd54a03d45103c810502e496bc))
|
||||
* scheduled transactions prototype ([#242](https://github.com/joinmarket-webui/joinmarket-webui/issues/242)) ([0e1d0a8](https://github.com/joinmarket-webui/joinmarket-webui/commit/0e1d0a8a692633459f53edf156c1e4446db62852))
|
||||
* simple progress report for scheduled transactions ([#262](https://github.com/joinmarket-webui/joinmarket-webui/issues/262)) ([0e3b7b8](https://github.com/joinmarket-webui/joinmarket-webui/commit/0e3b7b8b9416331bda7837fc5bb39b6d9ba3e869))
|
||||
* split up scheduler destination addresses over 3 mixdepths ([#283](https://github.com/joinmarket-webui/joinmarket-webui/issues/283)) ([471cbc7](https://github.com/joinmarket-webui/joinmarket-webui/commit/471cbc7e985c2521f9053e950067c781649666b9))
|
||||
- add cheatsheet ([#211](https://github.com/joinmarket-webui/joinmarket-webui/issues/211)) ([825f725](https://github.com/joinmarket-webui/joinmarket-webui/commit/825f725053f4c11a696929516cc48f40ffc1aee5))
|
||||
- add French translation ([#216](https://github.com/joinmarket-webui/joinmarket-webui/issues/216)) ([69fcbaf](https://github.com/joinmarket-webui/joinmarket-webui/commit/69fcbaff42af41f7723fd46d318009e969a5ed12))
|
||||
- confirm password on Create Wallet screen ([#210](https://github.com/joinmarket-webui/joinmarket-webui/issues/210)) ([0c019db](https://github.com/joinmarket-webui/joinmarket-webui/commit/0c019db742e19efda1dab1f81f857d286c3ca1b5))
|
||||
- **footer:** show Jam version ([#281](https://github.com/joinmarket-webui/joinmarket-webui/issues/281)) ([3c886f3](https://github.com/joinmarket-webui/joinmarket-webui/commit/3c886f3e05f81377f6a4b06e2ada6a020b6ce2aa))
|
||||
- Individual balance toggle ([#247](https://github.com/joinmarket-webui/joinmarket-webui/issues/247)) ([e6c4cc1](https://github.com/joinmarket-webui/joinmarket-webui/commit/e6c4cc1c59925a630d0595148025ce784f3641ce))
|
||||
- prevent address reuse on Jam screen ([#272](https://github.com/joinmarket-webui/joinmarket-webui/issues/272)) ([c05b431](https://github.com/joinmarket-webui/joinmarket-webui/commit/c05b431191f49718f63032d3b0fc10ee8991596d))
|
||||
- rearrange order of tabs ([#258](https://github.com/joinmarket-webui/joinmarket-webui/issues/258)) ([8f527d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/8f527d7be83c9326e6583fa14888be84a37ecfff))
|
||||
- reload wallet data after send ([#236](https://github.com/joinmarket-webui/joinmarket-webui/issues/236)) ([edd5818](https://github.com/joinmarket-webui/joinmarket-webui/commit/edd5818b824276dd54a03d45103c810502e496bc))
|
||||
- scheduled transactions prototype ([#242](https://github.com/joinmarket-webui/joinmarket-webui/issues/242)) ([0e1d0a8](https://github.com/joinmarket-webui/joinmarket-webui/commit/0e1d0a8a692633459f53edf156c1e4446db62852))
|
||||
- simple progress report for scheduled transactions ([#262](https://github.com/joinmarket-webui/joinmarket-webui/issues/262)) ([0e3b7b8](https://github.com/joinmarket-webui/joinmarket-webui/commit/0e3b7b8b9416331bda7837fc5bb39b6d9ba3e869))
|
||||
- split up scheduler destination addresses over 3 mixdepths ([#283](https://github.com/joinmarket-webui/joinmarket-webui/issues/283)) ([471cbc7](https://github.com/joinmarket-webui/joinmarket-webui/commit/471cbc7e985c2521f9053e950067c781649666b9))
|
||||
|
||||
#### Fixed
|
||||
|
||||
* do not hide CreateWallet component on connection errors ([#199](https://github.com/joinmarket-webui/joinmarket-webui/issues/199)) ([963dc49](https://github.com/joinmarket-webui/joinmarket-webui/commit/963dc49e723072d91f91649610e3f733bef358d1))
|
||||
* force-close pending websockets connections ([#200](https://github.com/joinmarket-webui/joinmarket-webui/issues/200)) ([33b35f7](https://github.com/joinmarket-webui/joinmarket-webui/commit/33b35f7ba9bd0f97c115172ea5918bb06d308422))
|
||||
* link to dev docs on contributing page ([#224](https://github.com/joinmarket-webui/joinmarket-webui/issues/224)) ([ef23b4b](https://github.com/joinmarket-webui/joinmarket-webui/commit/ef23b4b0850c2fa1d28a0ae9b56f2f53293b296f))
|
||||
* possible reference error in catch clause ([#265](https://github.com/joinmarket-webui/joinmarket-webui/issues/265)) ([2526eac](https://github.com/joinmarket-webui/joinmarket-webui/commit/2526eacf3b67307ece0a86a442094377df8c77c5))
|
||||
* prevent operations when maker/taker service is running ([#218](https://github.com/joinmarket-webui/joinmarket-webui/issues/218)) ([035dd80](https://github.com/joinmarket-webui/joinmarket-webui/commit/035dd8034ab5a45ef377a17877b343132cdf933f))
|
||||
* prevent starting scheduler when utxo preconditions are not met ([#263](https://github.com/joinmarket-webui/joinmarket-webui/issues/263)) ([a500b02](https://github.com/joinmarket-webui/joinmarket-webui/commit/a500b02f9bb1c11681a8ae8c532b77dfb18add6a))
|
||||
* prevent starting/stopping scheduler while data is loading ([#260](https://github.com/joinmarket-webui/joinmarket-webui/issues/260)) ([593981d](https://github.com/joinmarket-webui/joinmarket-webui/commit/593981dfd3d823ad02f09bbb683a652027b5383e))
|
||||
* reload session info on Earn and Wallets screen ([#231](https://github.com/joinmarket-webui/joinmarket-webui/issues/231)) ([df34272](https://github.com/joinmarket-webui/joinmarket-webui/commit/df342722c45d9c0184031a566e54fb31acae9685))
|
||||
* Remove skip button quiz screen ([#198](https://github.com/joinmarket-webui/joinmarket-webui/issues/198)) ([6c5e149](https://github.com/joinmarket-webui/joinmarket-webui/commit/6c5e149516454e1008aebf5f5cef74da9309f5f6))
|
||||
* reset wallet when token became invalid ([#223](https://github.com/joinmarket-webui/joinmarket-webui/issues/223)) ([70ffc99](https://github.com/joinmarket-webui/joinmarket-webui/commit/70ffc990402df242cf5c1fcc30308762ca918b8f))
|
||||
- do not hide CreateWallet component on connection errors ([#199](https://github.com/joinmarket-webui/joinmarket-webui/issues/199)) ([963dc49](https://github.com/joinmarket-webui/joinmarket-webui/commit/963dc49e723072d91f91649610e3f733bef358d1))
|
||||
- force-close pending websockets connections ([#200](https://github.com/joinmarket-webui/joinmarket-webui/issues/200)) ([33b35f7](https://github.com/joinmarket-webui/joinmarket-webui/commit/33b35f7ba9bd0f97c115172ea5918bb06d308422))
|
||||
- link to dev docs on contributing page ([#224](https://github.com/joinmarket-webui/joinmarket-webui/issues/224)) ([ef23b4b](https://github.com/joinmarket-webui/joinmarket-webui/commit/ef23b4b0850c2fa1d28a0ae9b56f2f53293b296f))
|
||||
- possible reference error in catch clause ([#265](https://github.com/joinmarket-webui/joinmarket-webui/issues/265)) ([2526eac](https://github.com/joinmarket-webui/joinmarket-webui/commit/2526eacf3b67307ece0a86a442094377df8c77c5))
|
||||
- prevent operations when maker/taker service is running ([#218](https://github.com/joinmarket-webui/joinmarket-webui/issues/218)) ([035dd80](https://github.com/joinmarket-webui/joinmarket-webui/commit/035dd8034ab5a45ef377a17877b343132cdf933f))
|
||||
- prevent starting scheduler when utxo preconditions are not met ([#263](https://github.com/joinmarket-webui/joinmarket-webui/issues/263)) ([a500b02](https://github.com/joinmarket-webui/joinmarket-webui/commit/a500b02f9bb1c11681a8ae8c532b77dfb18add6a))
|
||||
- prevent starting/stopping scheduler while data is loading ([#260](https://github.com/joinmarket-webui/joinmarket-webui/issues/260)) ([593981d](https://github.com/joinmarket-webui/joinmarket-webui/commit/593981dfd3d823ad02f09bbb683a652027b5383e))
|
||||
- reload session info on Earn and Wallets screen ([#231](https://github.com/joinmarket-webui/joinmarket-webui/issues/231)) ([df34272](https://github.com/joinmarket-webui/joinmarket-webui/commit/df342722c45d9c0184031a566e54fb31acae9685))
|
||||
- Remove skip button quiz screen ([#198](https://github.com/joinmarket-webui/joinmarket-webui/issues/198)) ([6c5e149](https://github.com/joinmarket-webui/joinmarket-webui/commit/6c5e149516454e1008aebf5f5cef74da9309f5f6))
|
||||
- reset wallet when token became invalid ([#223](https://github.com/joinmarket-webui/joinmarket-webui/issues/223)) ([70ffc99](https://github.com/joinmarket-webui/joinmarket-webui/commit/70ffc990402df242cf5c1fcc30308762ca918b8f))
|
||||
|
||||
### [0.0.5](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.4...v0.0.5) (2022-03-29)
|
||||
|
||||
#### Added
|
||||
|
||||
* add seed phrase backup confirmation during wallet creation ([#156](https://github.com/joinmarket-webui/joinmarket-webui/issues/156)) ([0719dc6](https://github.com/joinmarket-webui/joinmarket-webui/commit/0719dc62359387282363e0f3e658106d64c4051f))
|
||||
* satscomma formatting for bitcoin balances ([#171](https://github.com/joinmarket-webui/joinmarket-webui/issues/171)) ([fe94945](https://github.com/joinmarket-webui/joinmarket-webui/commit/fe94945f2ae95a96f011cddf4d7a8604c1e76d2e))
|
||||
* sweep mixdepths ([#184](https://github.com/joinmarket-webui/joinmarket-webui/issues/184)) ([81876b7](https://github.com/joinmarket-webui/joinmarket-webui/commit/81876b72146e2ab5c69ef52ccc183576a5929a34))
|
||||
* translate screens ([#174](https://github.com/joinmarket-webui/joinmarket-webui/issues/174)) ([63018ac](https://github.com/joinmarket-webui/joinmarket-webui/commit/63018ac96b8dd743ef47cb9a9a010f773f1542e4))
|
||||
- add seed phrase backup confirmation during wallet creation ([#156](https://github.com/joinmarket-webui/joinmarket-webui/issues/156)) ([0719dc6](https://github.com/joinmarket-webui/joinmarket-webui/commit/0719dc62359387282363e0f3e658106d64c4051f))
|
||||
- satscomma formatting for bitcoin balances ([#171](https://github.com/joinmarket-webui/joinmarket-webui/issues/171)) ([fe94945](https://github.com/joinmarket-webui/joinmarket-webui/commit/fe94945f2ae95a96f011cddf4d7a8604c1e76d2e))
|
||||
- sweep mixdepths ([#184](https://github.com/joinmarket-webui/joinmarket-webui/issues/184)) ([81876b7](https://github.com/joinmarket-webui/joinmarket-webui/commit/81876b72146e2ab5c69ef52ccc183576a5929a34))
|
||||
- translate screens ([#174](https://github.com/joinmarket-webui/joinmarket-webui/issues/174)) ([63018ac](https://github.com/joinmarket-webui/joinmarket-webui/commit/63018ac96b8dd743ef47cb9a9a010f773f1542e4))
|
||||
|
||||
#### Fixed
|
||||
|
||||
* make websocket health state work across browsers ([#186](https://github.com/joinmarket-webui/joinmarket-webui/issues/186)) ([39019cc](https://github.com/joinmarket-webui/joinmarket-webui/commit/39019ccbb668637b20c7220277b9b3cfcb6a7942))
|
||||
* pass correct request body in send-direct request ([#180](https://github.com/joinmarket-webui/joinmarket-webui/issues/180)) ([182b09c](https://github.com/joinmarket-webui/joinmarket-webui/commit/182b09c359e492531c3be7a1ad6d91310c7c5546))
|
||||
- make websocket health state work across browsers ([#186](https://github.com/joinmarket-webui/joinmarket-webui/issues/186)) ([39019cc](https://github.com/joinmarket-webui/joinmarket-webui/commit/39019ccbb668637b20c7220277b9b3cfcb6a7942))
|
||||
- pass correct request body in send-direct request ([#180](https://github.com/joinmarket-webui/joinmarket-webui/issues/180)) ([182b09c](https://github.com/joinmarket-webui/joinmarket-webui/commit/182b09c359e492531c3be7a1ad6d91310c7c5546))
|
||||
|
||||
### [0.0.4](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.3...v0.0.4) (2022-03-10)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* address copy button on http sites ([#165](https://github.com/joinmarket-webui/joinmarket-webui/issues/165)) ([34f8d2d](https://github.com/joinmarket-webui/joinmarket-webui/commit/34f8d2dc2b2fb50def6ed3e316e50db769f84154))
|
||||
* page reloads ([#162](https://github.com/joinmarket-webui/joinmarket-webui/issues/162)) ([78d15a1](https://github.com/joinmarket-webui/joinmarket-webui/commit/78d15a1a30c5bc755085820bbd85d02056c78eeb))
|
||||
* suggest number of collaborators based on configured minimum ([#116](https://github.com/joinmarket-webui/joinmarket-webui/issues/116)) ([d2c36bf](https://github.com/joinmarket-webui/joinmarket-webui/commit/d2c36bfc65311163f05c7415993a09c5d5131f82))
|
||||
* update suggested number of collaborators ([#150](https://github.com/joinmarket-webui/joinmarket-webui/issues/150)) ([26ffe8c](https://github.com/joinmarket-webui/joinmarket-webui/commit/26ffe8cdeb2146a757c90d05b67d3304485af918))
|
||||
* qrcode on receive page ([#146](https://github.com/joinmarket-webui/joinmarket-webui/issues/146)) ([87299b3](https://github.com/joinmarket-webui/joinmarket-webui/commit/87299b3268fbcb3710ce81cf3db8419325994138))
|
||||
* warn on missing config vars ([#152](https://github.com/joinmarket-webui/joinmarket-webui/issues/152)) ([3180103](https://github.com/joinmarket-webui/joinmarket-webui/commit/3180103da67f70dfd2030cc9db403d30a58a1b4a))
|
||||
- address copy button on http sites ([#165](https://github.com/joinmarket-webui/joinmarket-webui/issues/165)) ([34f8d2d](https://github.com/joinmarket-webui/joinmarket-webui/commit/34f8d2dc2b2fb50def6ed3e316e50db769f84154))
|
||||
- page reloads ([#162](https://github.com/joinmarket-webui/joinmarket-webui/issues/162)) ([78d15a1](https://github.com/joinmarket-webui/joinmarket-webui/commit/78d15a1a30c5bc755085820bbd85d02056c78eeb))
|
||||
- suggest number of collaborators based on configured minimum ([#116](https://github.com/joinmarket-webui/joinmarket-webui/issues/116)) ([d2c36bf](https://github.com/joinmarket-webui/joinmarket-webui/commit/d2c36bfc65311163f05c7415993a09c5d5131f82))
|
||||
- update suggested number of collaborators ([#150](https://github.com/joinmarket-webui/joinmarket-webui/issues/150)) ([26ffe8c](https://github.com/joinmarket-webui/joinmarket-webui/commit/26ffe8cdeb2146a757c90d05b67d3304485af918))
|
||||
- qrcode on receive page ([#146](https://github.com/joinmarket-webui/joinmarket-webui/issues/146)) ([87299b3](https://github.com/joinmarket-webui/joinmarket-webui/commit/87299b3268fbcb3710ce81cf3db8419325994138))
|
||||
- warn on missing config vars ([#152](https://github.com/joinmarket-webui/joinmarket-webui/issues/152)) ([3180103](https://github.com/joinmarket-webui/joinmarket-webui/commit/3180103da67f70dfd2030cc9db403d30a58a1b4a))
|
||||
|
||||
#### Added
|
||||
|
||||
* show seed phrase in settings ([#160](https://github.com/joinmarket-webui/joinmarket-webui/issues/160)) ([7fb76ff](https://github.com/joinmarket-webui/joinmarket-webui/commit/7fb76ff5d54a03e3ef0763e94f3f10bc3e73c503))
|
||||
* i18n ([#153](https://github.com/joinmarket-webui/joinmarket-webui/issues/153)) ([cc168eb](https://github.com/joinmarket-webui/joinmarket-webui/commit/cc168eb49faf7495bc653ff104f6cac1c090dbbe))
|
||||
- show seed phrase in settings ([#160](https://github.com/joinmarket-webui/joinmarket-webui/issues/160)) ([7fb76ff](https://github.com/joinmarket-webui/joinmarket-webui/commit/7fb76ff5d54a03e3ef0763e94f3f10bc3e73c503))
|
||||
- i18n ([#153](https://github.com/joinmarket-webui/joinmarket-webui/issues/153)) ([cc168eb](https://github.com/joinmarket-webui/joinmarket-webui/commit/cc168eb49faf7495bc653ff104f6cac1c090dbbe))
|
||||
|
||||
### [0.0.3](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.2...v0.0.3) (2022-02-18)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* display info text on send page if service is running ([#86](https://github.com/joinmarket-webui/joinmarket-webui/issues/86)) ([4070d70](https://github.com/joinmarket-webui/joinmarket-webui/commit/4070d7026c0ba7eac7fca02a3c14c65da510f67b))
|
||||
* fix and dry up font features ([#98](https://github.com/joinmarket-webui/joinmarket-webui/issues/98)) ([ac762c5](https://github.com/joinmarket-webui/joinmarket-webui/commit/ac762c520486e33fc493b3de59ad8b02f6aa5332))
|
||||
* improve onboarding mobile layout ([#109](https://github.com/joinmarket-webui/joinmarket-webui/issues/109)) ([4159fac](https://github.com/joinmarket-webui/joinmarket-webui/commit/4159fac2303233faa93c57c2e2cec9faa3b8fbd6))
|
||||
* navigate to root on unmapped path ([#89](https://github.com/joinmarket-webui/joinmarket-webui/issues/89)) ([6f0f515](https://github.com/joinmarket-webui/joinmarket-webui/commit/6f0f515e87c177c8e5bf71009782497f779eac83))
|
||||
* update sat symbol ([#36](https://github.com/joinmarket-webui/joinmarket-webui/issues/36)) ([765c9d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/765c9d7315f96843cefeddbe51b49682f652ea2a))
|
||||
- display info text on send page if service is running ([#86](https://github.com/joinmarket-webui/joinmarket-webui/issues/86)) ([4070d70](https://github.com/joinmarket-webui/joinmarket-webui/commit/4070d7026c0ba7eac7fca02a3c14c65da510f67b))
|
||||
- fix and dry up font features ([#98](https://github.com/joinmarket-webui/joinmarket-webui/issues/98)) ([ac762c5](https://github.com/joinmarket-webui/joinmarket-webui/commit/ac762c520486e33fc493b3de59ad8b02f6aa5332))
|
||||
- improve onboarding mobile layout ([#109](https://github.com/joinmarket-webui/joinmarket-webui/issues/109)) ([4159fac](https://github.com/joinmarket-webui/joinmarket-webui/commit/4159fac2303233faa93c57c2e2cec9faa3b8fbd6))
|
||||
- navigate to root on unmapped path ([#89](https://github.com/joinmarket-webui/joinmarket-webui/issues/89)) ([6f0f515](https://github.com/joinmarket-webui/joinmarket-webui/commit/6f0f515e87c177c8e5bf71009782497f779eac83))
|
||||
- update sat symbol ([#36](https://github.com/joinmarket-webui/joinmarket-webui/issues/36)) ([765c9d7](https://github.com/joinmarket-webui/joinmarket-webui/commit/765c9d7315f96843cefeddbe51b49682f652ea2a))
|
||||
|
||||
#### Added
|
||||
|
||||
* ability for reverse proxy to enforce own auth scheme ([#102](https://github.com/joinmarket-webui/joinmarket-webui/issues/102)) ([5b7fc98](https://github.com/joinmarket-webui/joinmarket-webui/commit/5b7fc982e5241219e5f43fd5cd77f1d9a65abbfe))
|
||||
* add connection indicator to footer ([#55](https://github.com/joinmarket-webui/joinmarket-webui/issues/55)) ([6470ab4](https://github.com/joinmarket-webui/joinmarket-webui/commit/6470ab431627c091a40016b9baa2516157ca5ba2))
|
||||
* add privacy levels ([#51](https://github.com/joinmarket-webui/joinmarket-webui/issues/51)) ([7546ca7](https://github.com/joinmarket-webui/joinmarket-webui/commit/7546ca74d44369c4d8dba6961b4d441bd98edb09))
|
||||
* add quick hide balance ([#106](https://github.com/joinmarket-webui/joinmarket-webui/issues/106)) ([56b4d1e](https://github.com/joinmarket-webui/joinmarket-webui/commit/56b4d1e64f51dbe243d5b9877800c94caa5f0fa9))
|
||||
* add sat symbol & update balance UI ([#11](https://github.com/joinmarket-webui/joinmarket-webui/issues/11)) ([4351d13](https://github.com/joinmarket-webui/joinmarket-webui/commit/4351d1380e3a3d7ba872dce1f2281d192ad095ec))
|
||||
* add settings screen ([#26](https://github.com/joinmarket-webui/joinmarket-webui/issues/26)) ([867fff3](https://github.com/joinmarket-webui/joinmarket-webui/commit/867fff330bac37c4c741ca528e7f9ffa0511d2fd))
|
||||
* add theming support ([#10](https://github.com/joinmarket-webui/joinmarket-webui/issues/10)) ([d7268aa](https://github.com/joinmarket-webui/joinmarket-webui/commit/d7268aa70c54fa08a094b0e4c1e8eb2c88e00c77))
|
||||
* developer docs ([#37](https://github.com/joinmarket-webui/joinmarket-webui/issues/37)) ([d843770](https://github.com/joinmarket-webui/joinmarket-webui/commit/d84377071a87c2011b5fe1bc957243f491f66cfd))
|
||||
* display basic yield generator report ([#73](https://github.com/joinmarket-webui/joinmarket-webui/issues/73)) ([b37de70](https://github.com/joinmarket-webui/joinmarket-webui/commit/b37de7083ecb9575d798a2df2aa69a6d21dba0a0))
|
||||
* freeze/unfreeze utxos ([#110](https://github.com/joinmarket-webui/joinmarket-webui/issues/110)) ([2c2d228](https://github.com/joinmarket-webui/joinmarket-webui/commit/2c2d22875422771ffc2962f584ba60db78c8398d))
|
||||
* hide sensitive info on wallet create ([#77](https://github.com/joinmarket-webui/joinmarket-webui/issues/77)) ([174726b](https://github.com/joinmarket-webui/joinmarket-webui/commit/174726b74fd889980c124b9d56e2c1d1766e8a2f))
|
||||
* update create wallet flow ([#62](https://github.com/joinmarket-webui/joinmarket-webui/issues/62)) ([8ed27ae](https://github.com/joinmarket-webui/joinmarket-webui/commit/8ed27ae745f46ffda246006b5f7ba549493aa39b))
|
||||
* update earn page ([#82](https://github.com/joinmarket-webui/joinmarket-webui/issues/82)) ([7d62a38](https://github.com/joinmarket-webui/joinmarket-webui/commit/7d62a3820a83103a691633c8030bc26312e8310d))
|
||||
* update receive page ([#85](https://github.com/joinmarket-webui/joinmarket-webui/issues/85)) ([756d8e8](https://github.com/joinmarket-webui/joinmarket-webui/commit/756d8e8a93185933d0a92713f92bf92f7ccdf664))
|
||||
* update send page ([#76](https://github.com/joinmarket-webui/joinmarket-webui/issues/76)) ([0d71915](https://github.com/joinmarket-webui/joinmarket-webui/commit/0d71915180dafb03ce69c8ba82c3c76fa5b2db46))
|
||||
* update wallets page ([#108](https://github.com/joinmarket-webui/joinmarket-webui/issues/108)) ([68e7a7f](https://github.com/joinmarket-webui/joinmarket-webui/commit/68e7a7fab85015efcdbcebad38c0e04ceb2024fc), [#64](https://github.com/joinmarket-webui/joinmarket-webui/issues/64)) ([f900344](https://github.com/joinmarket-webui/joinmarket-webui/commit/f90034454920fb77ddb7fedcbe3e92f1bb994322))
|
||||
- ability for reverse proxy to enforce own auth scheme ([#102](https://github.com/joinmarket-webui/joinmarket-webui/issues/102)) ([5b7fc98](https://github.com/joinmarket-webui/joinmarket-webui/commit/5b7fc982e5241219e5f43fd5cd77f1d9a65abbfe))
|
||||
- add connection indicator to footer ([#55](https://github.com/joinmarket-webui/joinmarket-webui/issues/55)) ([6470ab4](https://github.com/joinmarket-webui/joinmarket-webui/commit/6470ab431627c091a40016b9baa2516157ca5ba2))
|
||||
- add privacy levels ([#51](https://github.com/joinmarket-webui/joinmarket-webui/issues/51)) ([7546ca7](https://github.com/joinmarket-webui/joinmarket-webui/commit/7546ca74d44369c4d8dba6961b4d441bd98edb09))
|
||||
- add quick hide balance ([#106](https://github.com/joinmarket-webui/joinmarket-webui/issues/106)) ([56b4d1e](https://github.com/joinmarket-webui/joinmarket-webui/commit/56b4d1e64f51dbe243d5b9877800c94caa5f0fa9))
|
||||
- add sat symbol & update balance UI ([#11](https://github.com/joinmarket-webui/joinmarket-webui/issues/11)) ([4351d13](https://github.com/joinmarket-webui/joinmarket-webui/commit/4351d1380e3a3d7ba872dce1f2281d192ad095ec))
|
||||
- add settings screen ([#26](https://github.com/joinmarket-webui/joinmarket-webui/issues/26)) ([867fff3](https://github.com/joinmarket-webui/joinmarket-webui/commit/867fff330bac37c4c741ca528e7f9ffa0511d2fd))
|
||||
- add theming support ([#10](https://github.com/joinmarket-webui/joinmarket-webui/issues/10)) ([d7268aa](https://github.com/joinmarket-webui/joinmarket-webui/commit/d7268aa70c54fa08a094b0e4c1e8eb2c88e00c77))
|
||||
- developer docs ([#37](https://github.com/joinmarket-webui/joinmarket-webui/issues/37)) ([d843770](https://github.com/joinmarket-webui/joinmarket-webui/commit/d84377071a87c2011b5fe1bc957243f491f66cfd))
|
||||
- display basic yield generator report ([#73](https://github.com/joinmarket-webui/joinmarket-webui/issues/73)) ([b37de70](https://github.com/joinmarket-webui/joinmarket-webui/commit/b37de7083ecb9575d798a2df2aa69a6d21dba0a0))
|
||||
- freeze/unfreeze utxos ([#110](https://github.com/joinmarket-webui/joinmarket-webui/issues/110)) ([2c2d228](https://github.com/joinmarket-webui/joinmarket-webui/commit/2c2d22875422771ffc2962f584ba60db78c8398d))
|
||||
- hide sensitive info on wallet create ([#77](https://github.com/joinmarket-webui/joinmarket-webui/issues/77)) ([174726b](https://github.com/joinmarket-webui/joinmarket-webui/commit/174726b74fd889980c124b9d56e2c1d1766e8a2f))
|
||||
- update create wallet flow ([#62](https://github.com/joinmarket-webui/joinmarket-webui/issues/62)) ([8ed27ae](https://github.com/joinmarket-webui/joinmarket-webui/commit/8ed27ae745f46ffda246006b5f7ba549493aa39b))
|
||||
- update earn page ([#82](https://github.com/joinmarket-webui/joinmarket-webui/issues/82)) ([7d62a38](https://github.com/joinmarket-webui/joinmarket-webui/commit/7d62a3820a83103a691633c8030bc26312e8310d))
|
||||
- update receive page ([#85](https://github.com/joinmarket-webui/joinmarket-webui/issues/85)) ([756d8e8](https://github.com/joinmarket-webui/joinmarket-webui/commit/756d8e8a93185933d0a92713f92bf92f7ccdf664))
|
||||
- update send page ([#76](https://github.com/joinmarket-webui/joinmarket-webui/issues/76)) ([0d71915](https://github.com/joinmarket-webui/joinmarket-webui/commit/0d71915180dafb03ce69c8ba82c3c76fa5b2db46))
|
||||
- update wallets page ([#108](https://github.com/joinmarket-webui/joinmarket-webui/issues/108)) ([68e7a7f](https://github.com/joinmarket-webui/joinmarket-webui/commit/68e7a7fab85015efcdbcebad38c0e04ceb2024fc), [#64](https://github.com/joinmarket-webui/joinmarket-webui/issues/64)) ([f900344](https://github.com/joinmarket-webui/joinmarket-webui/commit/f90034454920fb77ddb7fedcbe3e92f1bb994322))
|
||||
|
||||
### [0.0.2](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.1...v0.0.2) (2022-02-16)
|
||||
|
||||
#### Fixed
|
||||
|
||||
* remove displayed artifact on missing fidelity bonds ([#2](https://github.com/joinmarket-webui/joinmarket-webui/pull/2)) ([17d5afa](https://github.com/joinmarket-webui/joinmarket-webui/commit/17d5afaa578b6390a27a1d195dd31523f5228546))
|
||||
- remove displayed artifact on missing fidelity bonds ([#2](https://github.com/joinmarket-webui/joinmarket-webui/pull/2)) ([17d5afa](https://github.com/joinmarket-webui/joinmarket-webui/commit/17d5afaa578b6390a27a1d195dd31523f5228546))
|
||||
|
||||
### [0.0.1](https://github.com/joinmarket-webui/joinmarket-webui/compare/2b9704d...v0.0.1) (2022-02-16)
|
||||
|
||||
#### Added
|
||||
|
||||
* fork [JoinMarket-Org/jm-web-client](https://github.com/JoinMarket-Org/jm-web-client) ([79e90ea](https://github.com/joinmarket-webui/joinmarket-webui/commit/79e90eadaa772689d30bbc7e9107887dad331183))
|
||||
* refactor app ([a72e0a9](https://github.com/joinmarket-webui/joinmarket-webui/commit/a72e0a97c1d4c425f8ad1d6f928f985d464aa59d))
|
||||
* UI updates ([c838a7b](https://github.com/joinmarket-webui/joinmarket-webui/commit/c838a7b9dab915c8a20655f63430837ecea4f290))
|
||||
- fork [JoinMarket-Org/jm-web-client](https://github.com/JoinMarket-Org/jm-web-client) ([79e90ea](https://github.com/joinmarket-webui/joinmarket-webui/commit/79e90eadaa772689d30bbc7e9107887dad331183))
|
||||
- refactor app ([a72e0a9](https://github.com/joinmarket-webui/joinmarket-webui/commit/a72e0a97c1d4c425f8ad1d6f928f985d464aa59d))
|
||||
- UI updates ([c838a7b](https://github.com/joinmarket-webui/joinmarket-webui/commit/c838a7b9dab915c8a20655f63430837ecea4f290))
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ Initialize the regtest setup:
|
|||
```sh
|
||||
npm run regtest:init
|
||||
```
|
||||
|
||||
This creates and funds a wallet `Satoshi` with password `test`.
|
||||
|
||||
Start the UI on port 3000:
|
||||
|
|
@ -95,6 +96,7 @@ Initialize the regtest setup:
|
|||
```sh
|
||||
npm run regtest:init
|
||||
```
|
||||
|
||||
This creates and funds a wallet `Satoshi` with password `test`.
|
||||
|
||||
Start the UI on port 3000:
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ It aims to provide sensible defaults and be easy to use for beginners while stil
|
|||
- 💬 Join our [Matrix room](https://matrix.to/#/%23jam:bitcoin.kyoto) to get help and [contribute](https://jamdocs.org/contribute/)!
|
||||
- 📚 Check out the [documentation](https://jamdocs.org) and the [wiki](https://github.com/joinmarket-webui/jam/wiki) for resources such as meeting notes, call recordings, ideas, and discussions.
|
||||
|
||||
|
||||
## 📸
|
||||
|
||||
<div align="center">
|
||||
|
|
|
|||
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
|
@ -3,10 +3,11 @@ Starts a staging joinmarket directory node that you can point to in your regtest
|
|||
- start-dn.py taken from https://github.com/JoinMarket-Org/custom-scripts/blob/e3c5fb548c704fc56cdaa869705797955a9821dd/start-dn.py
|
||||
(might already be in master - last check on 2022-05-11)
|
||||
|
||||
You must mount the directory specified in `hidden_service_dir`, which contains hostname, public and private key,
|
||||
You must mount the directory specified in `hidden_service_dir`, which contains hostname, public and private key,
|
||||
and provide the correct onion hostname via `directory_nodes` yourself!
|
||||
|
||||
e.g. in a docker-compose setup:
|
||||
|
||||
```yml
|
||||
joinmarket_directory_node:
|
||||
[...]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ All containers will have a wallet named `Satoshi.jmdat` with password `test`.
|
|||
The second container has basic auth enabled (username `joinmarket` and password `joinmarket`).
|
||||
|
||||
## Common flow
|
||||
|
||||
```sh
|
||||
# (optional) once in a while rebuild the images
|
||||
npm run regtest:rebuild
|
||||
|
|
@ -65,9 +66,11 @@ npm run regtest:clear
|
|||
```
|
||||
|
||||
### Mine
|
||||
|
||||
Mine regtest blocks in a fixed interval (current default is every 11 seconds).
|
||||
This is useful for features that await confirmations or need incoming blocks regularly.
|
||||
e.g. This is necessary for scheduled transactions to execute successfully.
|
||||
|
||||
```sh
|
||||
npm run regtest:mine
|
||||
```
|
||||
|
|
@ -86,6 +89,7 @@ This is useful if you want to perform regression tests.
|
|||
One additional JoinMarket container acts as [Directory Node](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/onion-message-channels.md#directory) and exists solely to enable communication between peers.
|
||||
|
||||
### Build
|
||||
|
||||
```sh
|
||||
# building the images
|
||||
npm run regtest:build
|
||||
|
|
|
|||
31
eslint.config.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from 'eslint-plugin-storybook'
|
||||
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
// Allow unused variables when they start with underscore
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
storybook.configs['flat/recommended'],
|
||||
)
|
||||
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jam</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24595
package-lock.json
generated
153
package.json
|
|
@ -1,65 +1,19 @@
|
|||
{
|
||||
"name": "jam",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Your sats. Your privacy. Your profit.",
|
||||
"repository": "git@github.com:joinmarket-webui/jam.git",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.11.0",
|
||||
"npm": ">=10.9.0"
|
||||
},
|
||||
"homepage": ".",
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.8.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"conventional-changelog": "^6.0.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"husky": "^9.1.7",
|
||||
"jest-watch-typeahead": "^2.2.2",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"prettier": "^3.5.3",
|
||||
"react-scripts": "^5.0.1",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@table-library/react-table-library": "^4.1.15",
|
||||
"bootstrap": "^5.3.6",
|
||||
"classnames": "^2.5.1",
|
||||
"formik": "^2.4.6",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-bootstrap": "^0.26.3",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:start": "echo 'Deprecated command will be removed soon. Please use `npm run dev` instead.'",
|
||||
"dev:start:secondary": "echo 'Deprecated command will be removed soon. Please use `npm run dev:secondary` instead.'",
|
||||
"dev": "REACT_APP_JAM_DEV_MODE=true npm start",
|
||||
"dev:secondary": "PORT=3001 JAM_BACKEND=jam-standalone JAM_API_PORT=29080 npm run dev:start",
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"prepare": "husky",
|
||||
"lint": "prettier --check --no-error-on-unmatched-pattern 'src/**/*.{js,jsx,ts,tsx,json,css,md}'",
|
||||
"format": "prettier --write --no-error-on-unmatched-pattern 'src/**/*.{js,jsx,ts,tsx,json,css,md}'",
|
||||
"version": "node scripts/changelog.mjs && git checkout -b \"prepare-v${npm_package_version}-$(date +%s)\" && git add --all && git commit --message \"chore(release): v${npm_package_version}\" && git push --set-upstream origin $(git branch --show-current)",
|
||||
"postversion": "which gh && gh pr create --title \"chore(release): v${npm_package_version}\" --body \"Prepares the v${npm_package_version} release.\" --assignee @me --label release --repo joinmarket-webui/jam --draft",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"prettier": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
|
||||
"format": "npm run lint -- --fix && npm run prettier",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"prepare": "husky install",
|
||||
"test": "vitest run",
|
||||
"regtest:build": "npm run regtest:clear && docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml build --pull",
|
||||
"regtest:rebuild": "rm -rf docker/regtest/.tmp && npm run regtest:build -- --no-cache",
|
||||
"regtest:clear": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml down --volumes --remove-orphans",
|
||||
|
|
@ -72,40 +26,69 @@
|
|||
"regtest:init": "./docker/regtest/init-setup.sh",
|
||||
"regtest:mine": "watch -n 11 ./docker/regtest/mine-block.sh"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,css,md}": "prettier --write"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
"dependencies": {
|
||||
"@hey-api/client-fetch": "^0.13.1",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tanstack/react-query": "^5.80.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.518.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tw-animate-css": "^1.3.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{js,jsx,ts,tsx,json,css,md}": "prettier --no-error-on-unmatched-pattern --write"
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^4.0.0",
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@storybook/addon-a11y": "^9.0.12",
|
||||
"@storybook/addon-docs": "^9.0.12",
|
||||
"@storybook/addon-vitest": "^9.0.12",
|
||||
"@storybook/react-vite": "^9.0.12",
|
||||
"@types/node": "^22.15.29",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"conventional-changelog": "^7.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"eslint-plugin-storybook": "^9.0.12",
|
||||
"globals": "^16.2.0",
|
||||
"husky": "^8.0.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"prettier": "^3.5.3",
|
||||
"storybook": "^9.0.12",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.34.1",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"jest": {
|
||||
"extraGlobals": [
|
||||
"Math",
|
||||
"localStorage"
|
||||
],
|
||||
"clearMocks": true,
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!@table-library)"
|
||||
]
|
||||
"overrides": {
|
||||
"storybook": "$storybook"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#ffffff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
|
@ -1,41 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="base-url" href="." />
|
||||
<link rel="public-url" href="%PUBLIC_URL%" data-value="%PUBLIC_URL%" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" crossorigin="use-credentials" />
|
||||
<link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#000000" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<title>Jam for JoinMarket</title>
|
||||
<script>
|
||||
window.JM = { SETTINGS_STORE_KEY: 'jm-settings', THEMES: ['light', 'dark'], THEME_ROOT_ATTR: 'data-theme' }
|
||||
// determine base path depending on whether PUBLIC_URL is set
|
||||
const baseHref = document.querySelector('link[rel="base-url"]').href
|
||||
const publicHref = document.querySelector('link[rel="public-url"]').href
|
||||
const publicUrl = document.querySelector('link[rel="public-url"]').dataset.value
|
||||
const base = publicUrl.length === 0 || baseHref.length > publicHref.length ? baseHref : publicHref
|
||||
window.JM.PUBLIC_PATH = base
|
||||
.replace(`${window.location.protocol}//${window.location.host}`, '') // remove domain part
|
||||
.replace(/\/$/, '') // remove trailing slash
|
||||
// theme
|
||||
const settings = JSON.parse(window.localStorage.getItem(JM.SETTINGS_STORE_KEY) || '{}')
|
||||
const userColorMode = settings.theme
|
||||
const systemColorMode = window.matchMedia('(prefers-color-scheme: dark)').matches ? JM.THEMES[1] : JM.THEMES[0]
|
||||
const initialColorMode = JM.THEMES.includes(userColorMode) ? userColorMode : systemColorMode
|
||||
if (!userColorMode) {
|
||||
const updatedSettings = Object.assign(settings, { theme: initialColorMode })
|
||||
window.localStorage.setItem(JM.SETTINGS_STORE_KEY, JSON.stringify(updatedSettings))
|
||||
}
|
||||
document.documentElement.setAttribute(JM.THEME_ROOT_ATTR, initialColorMode)
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
|
@ -1,3 +0,0 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "JoinMarket",
|
||||
"short_name": "JoinMarket",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 103 KiB |
1
public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
112
src/App.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import JamLanding from './components/JamLanding'
|
||||
import LoginPage from './components/Login'
|
||||
import CreateWallet from './components/CreateWallet'
|
||||
import { Layout } from './components/layout/Layout'
|
||||
import { clearSession, getSession, setSession } from './lib/session'
|
||||
import { Toaster } from './components/ui/sonner'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { queryClient } from './lib/queryClient'
|
||||
import { useApiClient } from './hooks/useApiClient'
|
||||
import { useEffect } from 'react'
|
||||
import { JM_API_AUTH_TOKEN_EXPIRY } from './constants/jm'
|
||||
import { setIntervalDebounced } from './lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { token } from './lib/jm-api/generated/client'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
|
||||
const isAuthenticated = () => {
|
||||
const session = getSession()
|
||||
return session?.auth?.token !== undefined
|
||||
}
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
return isAuthenticated() ? <>{children}</> : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark" enableSystem>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RefreshApiToken />
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={isAuthenticated() ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/create-wallet" element={isAuthenticated() ? <Navigate to="/" replace /> : <CreateWallet />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<JamLanding />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
<Toaster closeButton />
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const API_AUTH_TOKEN_RENEW_INTERVAL = Math.round(JM_API_AUTH_TOKEN_EXPIRY * 0.75)
|
||||
|
||||
function RefreshApiToken() {
|
||||
const client = useApiClient()
|
||||
|
||||
// TODO: stop this interval if no wallet if active
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
toast.info(`[DEV] setup refresh interval`)
|
||||
}
|
||||
|
||||
let intervalId: NodeJS.Timeout
|
||||
setIntervalDebounced(
|
||||
async () => {
|
||||
const session = getSession()
|
||||
if (session?.auth?.refresh_token === undefined) return
|
||||
|
||||
const response = await token({
|
||||
client,
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: session.auth.refresh_token,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.data) {
|
||||
clearSession()
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const message = response.error?.message || response.error?.error_description || 'Unknown error.'
|
||||
toast.error(`[DEV] Error while refreshing auth token: ${message}`)
|
||||
}
|
||||
} else {
|
||||
setSession({
|
||||
auth: {
|
||||
token: response.data.token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
},
|
||||
})
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
toast.info(`[DEV] Successfully refreshed auth token.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
API_AUTH_TOKEN_RENEW_INTERVAL,
|
||||
(timerId) => (intervalId = timerId),
|
||||
)
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
}, [client])
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { ReactNode, PropsWithChildren, useState } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { useSettings } from '../context/SettingsContext'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import Sprite from './Sprite'
|
||||
|
||||
interface AccordionProps {
|
||||
title: ReactNode | string
|
||||
defaultOpen?: boolean
|
||||
disabled?: boolean
|
||||
variant?: 'warning' | 'danger'
|
||||
}
|
||||
|
||||
const Accordion = ({
|
||||
title,
|
||||
defaultOpen = false,
|
||||
disabled = false,
|
||||
variant,
|
||||
children,
|
||||
}: PropsWithChildren<AccordionProps>) => {
|
||||
const settings = useSettings()
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<rb.Button
|
||||
variant={settings.theme}
|
||||
className="d-flex align-items-center bg-transparent border-0 rounded-0 w-100 px-0 py-2"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div
|
||||
className={classNames('d-flex align-items-center', {
|
||||
'text-danger': variant === 'danger',
|
||||
})}
|
||||
>
|
||||
{variant && (
|
||||
<div
|
||||
className={classNames('badge rounded-pill p-0 me-2', {
|
||||
'text-dark': variant === 'warning',
|
||||
'bg-warning': variant === 'warning',
|
||||
'text-light': variant === 'danger',
|
||||
'bg-danger': variant === 'danger',
|
||||
})}
|
||||
>
|
||||
<Sprite symbol="warn" width="20" height="20" />
|
||||
</div>
|
||||
)}
|
||||
{title}
|
||||
</div>
|
||||
<Sprite symbol={`caret-${isOpen ? 'up' : 'down'}`} className="ms-1" width="20" height="20" />
|
||||
</rb.Button>
|
||||
<div className="m-0 mb-4 border-0 border-bottom" />
|
||||
<rb.Collapse in={isOpen}>
|
||||
<div>{children}</div>
|
||||
</rb.Collapse>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Accordion
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import Sprite from './Sprite'
|
||||
|
||||
interface ActivityIndicatorProps {
|
||||
isOn: boolean
|
||||
}
|
||||
|
||||
function ActivityIndicator({ isOn, children }: PropsWithChildren<ActivityIndicatorProps>) {
|
||||
return (
|
||||
<span className={`activity-indicator ${isOn ? 'activity-indicator-on' : 'activity-indicator-off'}`}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface JoiningIndicatorProps {
|
||||
isOn: boolean
|
||||
size?: number
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function JoiningIndicator({ isOn, size = 32, className = '', ...props }: JoiningIndicatorProps) {
|
||||
return (
|
||||
<span className="joining-indicator">
|
||||
<ActivityIndicator isOn={isOn}>
|
||||
{isOn && <Sprite symbol="mixed" width={size} height={size} className={`${className}`} {...props} />}
|
||||
</ActivityIndicator>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface TabActivityIndicatorProps {
|
||||
isOn: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TabActivityIndicator({ isOn, className }: TabActivityIndicatorProps) {
|
||||
return (
|
||||
<span className={classNames('earn-indicator', className)}>
|
||||
<ActivityIndicator isOn={isOn} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import { Alert as BsAlert } from 'react-bootstrap'
|
||||
|
||||
export default function Alert({ message, onClose, ...props }: SimpleAlert) {
|
||||
const [show, setShow] = useState(true)
|
||||
|
||||
return (
|
||||
<BsAlert
|
||||
className="my-3"
|
||||
onClose={(a: any, b: any) => {
|
||||
setShow(false)
|
||||
onClose && onClose(a, b)
|
||||
}}
|
||||
show={show}
|
||||
{...props}
|
||||
>
|
||||
{message}
|
||||
</BsAlert>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { render, screen, act } from '../testUtils'
|
||||
import user from '@testing-library/user-event'
|
||||
import * as apiMock from '../libs/JmWalletApi'
|
||||
|
||||
import App from './App'
|
||||
|
||||
jest.mock('../libs/JmWalletApi', () => ({
|
||||
...jest.requireActual('../libs/JmWalletApi'),
|
||||
getGetinfo: jest.fn(),
|
||||
getSession: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('<App />', () => {
|
||||
beforeEach(() => {
|
||||
const neverResolvingPromise = new Promise(() => {})
|
||||
;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(neverResolvingPromise)
|
||||
;(apiMock.getSession as jest.Mock).mockResolvedValue(neverResolvingPromise)
|
||||
})
|
||||
|
||||
it('should display Onboarding screen initially', async () => {
|
||||
await act(async () => render(<App />))
|
||||
|
||||
// Onboarding screen
|
||||
expect(screen.getByText('onboarding.splashscreen_button_get_started')).toBeInTheDocument()
|
||||
expect(screen.getByText('onboarding.splashscreen_button_skip_intro')).toBeInTheDocument()
|
||||
|
||||
// Wallets screen shown after Intro is skipped
|
||||
expect(screen.queryByText('wallets.title')).not.toBeInTheDocument()
|
||||
|
||||
const skipIntro = screen.getByText('onboarding.splashscreen_button_skip_intro')
|
||||
await user.click(skipIntro)
|
||||
|
||||
expect(screen.getByText('wallets.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display Wallets screen directly when Onboarding screen has been shown', async () => {
|
||||
global.__DEV__.addToAppSettings({ showOnboarding: false })
|
||||
|
||||
await act(async () => render(<App />))
|
||||
|
||||
// Wallets screen
|
||||
expect(screen.getByText('wallets.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display a modal with beta warning information', async () => {
|
||||
global.__DEV__.addToAppSettings({ showOnboarding: false })
|
||||
|
||||
await act(async () => render(<App />))
|
||||
|
||||
expect(screen.getByText('Read this before using.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/While JoinMarket is tried and tested, Jam is not./)).not.toBeInTheDocument()
|
||||
|
||||
const readThis = screen.getByText('Read this before using.')
|
||||
await user.click(readThis)
|
||||
|
||||
expect(screen.getByText('footer.warning_alert_text')).toBeInTheDocument()
|
||||
expect(screen.getByText('footer.warning_alert_button_ok')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display websocket connection indicator as CONNECTED', async () => {
|
||||
global.__DEV__.addToAppSettings({ showOnboarding: false })
|
||||
|
||||
await act(async () => {
|
||||
render(<App />)
|
||||
})
|
||||
|
||||
await global.__DEV__.JM_WEBSOCKET_SERVER_MOCK.connected
|
||||
|
||||
expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-success')).toBe(true)
|
||||
expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-secondary')).toBe(false)
|
||||
})
|
||||
|
||||
it('should display websocket connection indicator AS DISCONNECTED', async () => {
|
||||
global.__DEV__.addToAppSettings({ showOnboarding: false })
|
||||
|
||||
await act(async () => {
|
||||
render(<App />)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
global.__DEV__.JM_WEBSOCKET_SERVER_MOCK.close()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-success')).toBe(false)
|
||||
expect(screen.getByTestId('connection-indicator-icon').classList.contains('text-secondary')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,359 +0,0 @@
|
|||
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Navigate,
|
||||
Route,
|
||||
RouterProvider,
|
||||
Outlet,
|
||||
} from 'react-router-dom'
|
||||
import classNames from 'classnames'
|
||||
import * as Api from '../libs/JmWalletApi'
|
||||
import { routes } from '../constants/routes'
|
||||
import { useServiceInfo, useSessionConnectionError } from '../context/ServiceInfoContext'
|
||||
import { useSettings } from '../context/SettingsContext'
|
||||
import {
|
||||
WalletInfo,
|
||||
CurrentWallet,
|
||||
useCurrentWallet,
|
||||
useSetCurrentWallet,
|
||||
useClearCurrentWallet,
|
||||
useReloadCurrentWalletInfo,
|
||||
} from '../context/WalletContext'
|
||||
import { clearSession, setSession } from '../session'
|
||||
import { isDebugFeatureEnabled } from '../constants/debugFeatures'
|
||||
import CreateWallet from './CreateWallet'
|
||||
import ImportWallet from './ImportWallet'
|
||||
import Earn from './Earn'
|
||||
import ErrorPage from './ErrorPage'
|
||||
import Footer from './Footer'
|
||||
import Jam from './Jam'
|
||||
import Layout from './Layout'
|
||||
import MainWalletView from './MainWalletView'
|
||||
import Navbar from './Navbar'
|
||||
import Onboarding from './Onboarding'
|
||||
import Receive from './Receive'
|
||||
import Send from './Send'
|
||||
import RescanChain from './RescanChain'
|
||||
import Settings from './Settings'
|
||||
import Wallets from './Wallets'
|
||||
const DevSetupPage = lazy(() => import('./DevSetupPage'))
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
const currentWallet = useCurrentWallet()
|
||||
const setCurrentWallet = useSetCurrentWallet()
|
||||
const clearCurrentWallet = useClearCurrentWallet()
|
||||
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
|
||||
const serviceInfo = useServiceInfo()
|
||||
const sessionConnectionError = useSessionConnectionError()
|
||||
const [reloadingWalletInfoCounter, setReloadingWalletInfoCounter] = useState(0)
|
||||
const isReloadingWalletInfo = useMemo(() => reloadingWalletInfoCounter > 0, [reloadingWalletInfoCounter])
|
||||
|
||||
const startWallet = useCallback(
|
||||
(walletFileName: Api.WalletFileName, auth: Api.ApiAuthContext) => {
|
||||
setSession({ walletFileName, auth })
|
||||
setCurrentWallet({ walletFileName, token: auth.token })
|
||||
},
|
||||
[setCurrentWallet],
|
||||
)
|
||||
|
||||
const stopWallet = useCallback(() => {
|
||||
clearCurrentWallet()
|
||||
clearSession()
|
||||
}, [clearCurrentWallet])
|
||||
|
||||
const reloadWalletInfo = useCallback(
|
||||
({ delay, force }: { delay: Milliseconds; force: boolean }) => {
|
||||
setReloadingWalletInfoCounter((current) => current + 1)
|
||||
console.info('Reloading wallet info...')
|
||||
return new Promise<WalletInfo>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
const reload = force ? reloadCurrentWalletInfo.reloadAllForce : reloadCurrentWalletInfo.reloadAll
|
||||
const abortCtrl = new AbortController()
|
||||
reload({ signal: abortCtrl.signal })
|
||||
.then((result) => resolve(result))
|
||||
.catch((error) => reject(error))
|
||||
.finally(() => {
|
||||
console.info('Finished reloading wallet info.')
|
||||
setReloadingWalletInfoCounter((current) => current - 1)
|
||||
})
|
||||
}, delay),
|
||||
)
|
||||
},
|
||||
[reloadCurrentWalletInfo],
|
||||
)
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route
|
||||
id="base"
|
||||
element={
|
||||
<>
|
||||
<Navbar />
|
||||
<rb.Container as="main" className="py-4 py-lg-5" fluid="xl">
|
||||
<Outlet />
|
||||
</rb.Container>
|
||||
<Footer />
|
||||
</>
|
||||
}
|
||||
errorElement={<ErrorPage />}
|
||||
>
|
||||
<Route
|
||||
id="error-boundary"
|
||||
element={
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
}
|
||||
errorElement={
|
||||
<Layout variant="wide">
|
||||
<ErrorPage />
|
||||
</Layout>
|
||||
}
|
||||
>
|
||||
{/**
|
||||
* This sections defines all routes that can be displayed, even if the connection
|
||||
* to the backend is down, e.g. "create-wallet" shows the seed quiz and it is important
|
||||
* that it stays visible in case the backend becomes unavailable.
|
||||
*/}
|
||||
<Route
|
||||
id="create-wallet"
|
||||
path={routes.createWallet}
|
||||
element={<CreateWallet parentRoute={'home'} startWallet={startWallet} />}
|
||||
/>
|
||||
|
||||
{sessionConnectionError ? (
|
||||
<Route
|
||||
id="404"
|
||||
path="*"
|
||||
element={
|
||||
<rb.Alert variant="danger">
|
||||
<h5 className="alert-heading">
|
||||
{t('app.alert_no_connection', { connectionError: sessionConnectionError.message })}
|
||||
</h5>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="app.alert_no_connection_details"
|
||||
components={{
|
||||
1: (
|
||||
<a
|
||||
className="alert-link"
|
||||
href="https://jamdocs.org/FAQ/#how-to-resolve-no-connection-to-gateway"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
the docs
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
{sessionConnectionError.response && !sessionConnectionError.response.ok && (
|
||||
<pre>
|
||||
{sessionConnectionError.response.status} {sessionConnectionError.response.statusText}
|
||||
{sessionConnectionError.response.url}
|
||||
</pre>
|
||||
)}
|
||||
</rb.Alert>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/**
|
||||
* This section defines all routes that are displayed only if the backend is reachable.
|
||||
*/}
|
||||
<Route
|
||||
id="wallets"
|
||||
path={routes.home}
|
||||
element={<Wallets currentWallet={currentWallet} startWallet={startWallet} stopWallet={stopWallet} />}
|
||||
/>
|
||||
<Route
|
||||
id="import-wallet"
|
||||
path={routes.importWallet}
|
||||
element={<ImportWallet parentRoute={'home'} startWallet={startWallet} />}
|
||||
/>
|
||||
{currentWallet && (
|
||||
<>
|
||||
<Route id="wallet" path={routes.wallet} element={<MainWalletView wallet={currentWallet} />} />
|
||||
<Route id="jam" path={routes.jam} element={<Jam wallet={currentWallet} />} />
|
||||
<Route id="send" path={routes.send} element={<Send wallet={currentWallet} />} />
|
||||
<Route id="earn" path={routes.earn} element={<Earn wallet={currentWallet} />} />
|
||||
<Route id="receive" path={routes.receive} element={<Receive wallet={currentWallet} />} />
|
||||
<Route id="rescan" path={routes.rescanChain} element={<RescanChain wallet={currentWallet} />} />
|
||||
<Route
|
||||
id="settings"
|
||||
path={routes.settings}
|
||||
element={<Settings wallet={currentWallet} stopWallet={stopWallet} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isDebugFeatureEnabled('errorExamplePage') && (
|
||||
<Route id="error-example" path={routes.__errorExample} element={<ErrorThrowingComponent />} />
|
||||
)}
|
||||
{isDebugFeatureEnabled('devSetupPage') && (
|
||||
<Route
|
||||
id="dev-env"
|
||||
path={routes.__devSetup}
|
||||
element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<DevSetupPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Route id="404" path="*" element={<Navigate to={routes.home} replace={true} />} />
|
||||
</>
|
||||
)}
|
||||
</Route>
|
||||
</Route>,
|
||||
),
|
||||
{
|
||||
basename: window.JM.PUBLIC_PATH,
|
||||
future: {
|
||||
v7_fetcherPersist: true,
|
||||
v7_normalizeFormMethod: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (settings.showOnboarding === true) {
|
||||
return (
|
||||
<rb.Container className="onboarding pt-3 pt-md-5">
|
||||
<rb.Row className="justify-content-center">
|
||||
<rb.Col xs={10} sm={10} md={8} lg={6} xl={4}>
|
||||
<Onboarding />
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
</rb.Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames('app', {
|
||||
'jam-reload-wallet-info-in-progress': isReloadingWalletInfo,
|
||||
'jm-coinjoin-in-progress': serviceInfo?.coinjoinInProgress === true,
|
||||
'jm-rescan-in-progress': serviceInfo?.rescanning === true,
|
||||
'jm-maker-running': serviceInfo?.makerRunning === true,
|
||||
})}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</div>
|
||||
<WalletInfoAutoReload currentWallet={currentWallet} reloadWalletInfo={reloadWalletInfo} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Loading = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="text-center">
|
||||
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
|
||||
{t('global.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ErrorThrowingComponent = () => {
|
||||
useEffect(() => {
|
||||
throw new Error('This error is thrown on purpose. Only to be used for testing.')
|
||||
}, [])
|
||||
return <></>
|
||||
}
|
||||
|
||||
const RELOAD_WALLET_INFO_DELAY: {
|
||||
AFTER_RESCAN: Milliseconds
|
||||
AFTER_UNLOCK: 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
|
||||
// and the delay might need to be increased if users encounter problems, e.g. the
|
||||
// balance changes again when switching views.
|
||||
// As reference: 4 seconds was not enough, even on regtest. But keep in mind, this only
|
||||
// takes effect after rescanning the chain, which should happen quite infrequently.
|
||||
AFTER_RESCAN: 8_000,
|
||||
|
||||
// No delay is needed after normal unlock of wallet
|
||||
AFTER_UNLOCK: 0,
|
||||
}
|
||||
|
||||
const MAX_RECURSIVE_WALLET_INFO_RELOADS = 10
|
||||
|
||||
interface WalletInfoAutoReloadProps {
|
||||
currentWallet: CurrentWallet | null
|
||||
reloadWalletInfo: ({ delay, force }: { delay: Milliseconds; force: boolean }) => Promise<WalletInfo>
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that automatically reloads wallet information on certain state changes,
|
||||
* e.g. when the wallet is unlocked or rescanning the chain finished successfully.
|
||||
*
|
||||
* If the auto-reloading on wallet change fails, the error can currently
|
||||
* only be logged and cannot be displayed to the user satisfactorily.
|
||||
* This might change in the future but is okay for now - components can
|
||||
* always trigger a reload on demand and inform the user as they see fit.
|
||||
*/
|
||||
const WalletInfoAutoReload = ({ currentWallet, reloadWalletInfo }: WalletInfoAutoReloadProps) => {
|
||||
const serviceInfo = useServiceInfo()
|
||||
const [previousRescanning, setPreviousRescanning] = useState(serviceInfo?.rescanning || false)
|
||||
const [currentRescanning, setCurrentRescanning] = useState(serviceInfo?.rescanning || false)
|
||||
const rescanningFinished = useMemo(
|
||||
() => previousRescanning === true && currentRescanning === false,
|
||||
[previousRescanning, currentRescanning],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setPreviousRescanning(currentRescanning)
|
||||
setCurrentRescanning(serviceInfo?.rescanning || false)
|
||||
}, [serviceInfo, currentRescanning])
|
||||
|
||||
useEffect(
|
||||
function reloadAfterUnlock() {
|
||||
if (!currentWallet) return
|
||||
|
||||
reloadWalletInfo({ delay: RELOAD_WALLET_INFO_DELAY.AFTER_UNLOCK, force: true }).catch((err) => console.error(err))
|
||||
},
|
||||
[currentWallet, reloadWalletInfo],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
function reloadAfterRescan() {
|
||||
if (!currentWallet || !rescanningFinished) return
|
||||
|
||||
// Hacky: If the balance changes after a reload, the backend might still not have been fully synchronized - try again!
|
||||
// Hint 1: Wallet might be empty after the first attempt
|
||||
// Hint 2: Just because wallet balance did not change, it does not mean everything has been found.
|
||||
const reloadWhileBalanceChangesRecursively = async (
|
||||
currentBalance: Api.AmountSats,
|
||||
delay: Milliseconds,
|
||||
maxCalls: number,
|
||||
callCounter: number = 0,
|
||||
) => {
|
||||
if (callCounter >= maxCalls) return
|
||||
const info = await reloadWalletInfo({ delay, force: false })
|
||||
const newBalance = info.balanceSummary.calculatedTotalBalanceInSats
|
||||
if (newBalance > currentBalance) {
|
||||
await reloadWhileBalanceChangesRecursively(newBalance, delay, maxCalls, callCounter++)
|
||||
}
|
||||
}
|
||||
|
||||
reloadWalletInfo({ delay: RELOAD_WALLET_INFO_DELAY.AFTER_RESCAN, force: false })
|
||||
.then((info) =>
|
||||
reloadWhileBalanceChangesRecursively(
|
||||
info.balanceSummary.calculatedTotalBalanceInSats,
|
||||
RELOAD_WALLET_INFO_DELAY.AFTER_RESCAN,
|
||||
MAX_RECURSIVE_WALLET_INFO_RELOADS,
|
||||
),
|
||||
)
|
||||
.catch((err) => console.error(err))
|
||||
},
|
||||
[currentWallet, rescanningFinished, reloadWalletInfo],
|
||||
)
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
:root {
|
||||
--jam-balance-color: #212529;
|
||||
--jam-balance-deemphasize-color: #9eacba;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--jam-balance-color: #ffffff;
|
||||
--jam-balance-deemphasize-color: #555c62;
|
||||
}
|
||||
|
||||
.frozen {
|
||||
--jam-balance-color: #0d6efd;
|
||||
--jam-balance-deemphasize-color: #7eb2ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .frozen {
|
||||
--jam-balance-color: #1372ff;
|
||||
--jam-balance-deemphasize-color: #1153b5;
|
||||
}
|
||||
|
||||
.balanceColor {
|
||||
color: var(--jam-balance-color);
|
||||
}
|
||||
|
||||
.hideSymbol {
|
||||
padding-left: 0.1em;
|
||||
color: var(--jam-balance-deemphasize-color);
|
||||
}
|
||||
|
||||
.bitcoinSymbol {
|
||||
order: -1;
|
||||
width: 1em;
|
||||
padding-right: 0.1em;
|
||||
}
|
||||
|
||||
.satsSymbol {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.frozenSymbol {
|
||||
order: 5;
|
||||
}
|
||||
.bitcoinAmount + .frozenSymbol {
|
||||
order: -2;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.frozenSymbol,
|
||||
.bitcoinSymbol,
|
||||
.satsSymbol {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bitcoinAmountSpacing .fractionalPart :nth-child(3)::before,
|
||||
.bitcoinAmountSpacing .fractionalPart :nth-child(6)::before {
|
||||
content: '\202F';
|
||||
}
|
||||
|
||||
/** Integer Part **/
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .integerPart,
|
||||
/** Decimal Point **/
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .decimalPoint,
|
||||
.bitcoinAmountColor[data-fractional-part-starts-with-zero="true"] .decimalPoint,
|
||||
/** Fractional Part **/
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="false"] .fractionalPart,
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]),
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"],
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"],
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
|
||||
.bitcoinAmountColor[data-integer-part-is-zero="true"] .fractionalPart :nth-child(1):is(span[data-digit="0"]) + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"] + span[data-digit="0"],
|
||||
/** Symbol */
|
||||
.bitcoinAmountColor[data-raw-value="0"] + .bitcoinSymbol {
|
||||
color: var(--jam-balance-deemphasize-color);
|
||||
}
|
||||
|
||||
.satsAmountColor[data-raw-value='0'],
|
||||
.satsAmountColor[data-raw-value='0'] + .satsSymbol {
|
||||
color: var(--jam-balance-deemphasize-color);
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import { render } from '@testing-library/react'
|
||||
import user from '@testing-library/user-event'
|
||||
import { screen } from '../testUtils'
|
||||
import { BTC, SATS } from '../utils'
|
||||
import Balance from './Balance'
|
||||
|
||||
describe('<Balance />', () => {
|
||||
it('should render invalid param as given', () => {
|
||||
render(<Balance valueString={'NaN'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByText(`NaN`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render balance in BTC', () => {
|
||||
render(<Balance valueString={'123.456'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`123.45600000`)
|
||||
expect(screen.getByTestId('bitcoin-symbol')).toBeVisible()
|
||||
expect(screen.queryByTestId('sats-symbol')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render balance in SATS', () => {
|
||||
render(<Balance valueString={'123.456'} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
|
||||
expect(screen.getByTestId('sats-symbol')).toBeVisible()
|
||||
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide balance for BTC by default', () => {
|
||||
render(<Balance valueString={'123.456'} convertToUnit={BTC} />)
|
||||
expect(screen.getByText(`*****`)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bitcoin-amount')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide balance for SATS by default', () => {
|
||||
render(<Balance valueString={'123'} convertToUnit={SATS} />)
|
||||
expect(screen.getByText(`*****`)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('sats-symbol')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a string BTC value correctly as BTC', () => {
|
||||
render(<Balance valueString={'123.03224961'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`123.03224961`)
|
||||
expect(screen.getByTestId('bitcoin-symbol')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should render a string BTC value correctly as SATS', () => {
|
||||
render(<Balance valueString={'123.03224961'} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`)
|
||||
expect(screen.getByTestId('sats-symbol')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should render a zero string BTC value correctly as BTC', () => {
|
||||
render(<Balance valueString={'0.00000000'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`0.00000000`)
|
||||
})
|
||||
|
||||
it('should render a zero string BTC value correctly as SATS', () => {
|
||||
render(<Balance valueString={'0.00000000'} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`0`)
|
||||
})
|
||||
|
||||
it('should render a large string BTC value correctly as BTC', () => {
|
||||
render(<Balance valueString={'20999999.97690000'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
|
||||
})
|
||||
|
||||
it('should render a large string BTC value correctly as SATS', () => {
|
||||
render(<Balance valueString={'20999999.97690000'} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
|
||||
})
|
||||
|
||||
it('should render a max string BTC value correctly as BTC', () => {
|
||||
render(<Balance valueString={'21000000.00000000'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
|
||||
})
|
||||
|
||||
it('should render a max string BTC value correctly as SATS', () => {
|
||||
render(<Balance valueString={'21000000.00000000'} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
|
||||
})
|
||||
|
||||
it('should render a string SATS value correctly as SATS', () => {
|
||||
render(<Balance valueString={'43000'} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`43,000`)
|
||||
})
|
||||
|
||||
it('should render a string SATS value correctly as BTC', () => {
|
||||
render(<Balance valueString={'43000'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`0.00043000`)
|
||||
})
|
||||
|
||||
it('should render a zero string SATS value correctly as BTC', () => {
|
||||
render(<Balance valueString={'0'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`0.00000000`)
|
||||
})
|
||||
|
||||
it('should render a zero string SATS value correctly as SATS', () => {
|
||||
render(<Balance valueString={'0'} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`0`)
|
||||
})
|
||||
|
||||
it('should render a large string SATS value correctly as BTC', () => {
|
||||
render(<Balance valueString={'2099999997690000'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
|
||||
})
|
||||
|
||||
it('should render a large string SATS value correctly as SATS', () => {
|
||||
render(<Balance valueString={'2099999997690000'} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
|
||||
})
|
||||
|
||||
it('should render a max string SATS value correctly as BTC', () => {
|
||||
render(<Balance valueString={'2100000000000000'} convertToUnit={BTC} showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
|
||||
})
|
||||
|
||||
it('should render a max string SATS value correctly as SATS', () => {
|
||||
render(<Balance valueString={'2100000000000000'} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
|
||||
})
|
||||
|
||||
it('should render frozen balance in BTC', () => {
|
||||
render(<Balance valueString={'123.456'} convertToUnit={BTC} showBalance={true} frozen={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`123.45600000`)
|
||||
expect(screen.getByTestId('bitcoin-symbol')).toBeVisible()
|
||||
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should render frozen balance in SATS', () => {
|
||||
render(<Balance valueString={'123.456'} convertToUnit={SATS} showBalance={true} frozen={true} />)
|
||||
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
|
||||
expect(screen.getByTestId('sats-symbol')).toBeVisible()
|
||||
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should render balance without symbol', () => {
|
||||
render(<Balance valueString={'123.456'} convertToUnit={SATS} showBalance={true} frozen={true} showSymbol={false} />)
|
||||
expect(screen.getByTestId('sats-amount')).toBeVisible()
|
||||
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
|
||||
expect(screen.queryByTestId('sats-symbol')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle visibility of initially hidden balance on click by default', async () => {
|
||||
render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={false} />)
|
||||
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(`*****`)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(`*****`))
|
||||
|
||||
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
|
||||
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId(`sats-amount`))
|
||||
|
||||
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(`*****`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT toggle visibility of initially hidden balance on click when disabled via flag', async () => {
|
||||
render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={false} enableVisibilityToggle={false} />)
|
||||
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(`*****`)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(`*****`))
|
||||
|
||||
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(`*****`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT toggle visibility of initially visible balance on click by default', async () => {
|
||||
render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
|
||||
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId(`sats-amount`))
|
||||
|
||||
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
|
||||
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle visibility of initially visible balance on click when enabled via flag', async () => {
|
||||
render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={true} enableVisibilityToggle={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
|
||||
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId(`sats-amount`))
|
||||
|
||||
expect(screen.queryByTestId(`sats-amount`)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(`*****`)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(`*****`))
|
||||
|
||||
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
|
||||
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
import { PropsWithChildren, MouseEventHandler, useEffect, useMemo, useState } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import Sprite from './Sprite'
|
||||
import { SATS, BTC, btcToSats, satsToBtc, isValidNumber, formatBtc, formatSats } from '../utils'
|
||||
import styles from './Balance.module.css'
|
||||
|
||||
const DISPLAY_MODE_BTC = 0
|
||||
const DISPLAY_MODE_SATS = 1
|
||||
const DISPLAY_MODE_HIDDEN = 2
|
||||
|
||||
const getDisplayMode = (unit: Unit, showBalance: boolean) => {
|
||||
if (showBalance && unit === SATS) return DISPLAY_MODE_SATS
|
||||
if (showBalance && unit === BTC) return DISPLAY_MODE_BTC
|
||||
|
||||
return DISPLAY_MODE_HIDDEN
|
||||
}
|
||||
|
||||
const BTC_SYMBOL = (
|
||||
<span data-testid="bitcoin-symbol" className={styles.bitcoinSymbol}>
|
||||
{'\u20BF'}
|
||||
</span>
|
||||
)
|
||||
|
||||
const SAT_SYMBOL = (
|
||||
<Sprite data-testid="sats-symbol" className={styles.satsSymbol} symbol="sats" width="1.2em" height="1.2em" />
|
||||
)
|
||||
|
||||
const FROZEN_SYMBOL = (
|
||||
<Sprite
|
||||
data-testid="frozen-symbol"
|
||||
className={`${styles.frozenSymbol} frozen-symbol-hook`}
|
||||
symbol="snowflake"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
/>
|
||||
)
|
||||
|
||||
interface BalanceComponentProps {
|
||||
symbol?: React.ReactNode
|
||||
showSymbol?: boolean
|
||||
frozen?: boolean
|
||||
colored?: boolean
|
||||
frozenSymbol?: boolean
|
||||
}
|
||||
|
||||
const BalanceComponent = ({
|
||||
symbol,
|
||||
showSymbol = true,
|
||||
frozen = false,
|
||||
colored = true,
|
||||
frozenSymbol = true,
|
||||
children,
|
||||
}: PropsWithChildren<BalanceComponentProps>) => {
|
||||
return (
|
||||
<span
|
||||
className={classNames('balance-hook', 'd-inline-flex align-items-center', {
|
||||
[styles.frozen]: frozen,
|
||||
[styles.balanceColor]: colored,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
{showSymbol && symbol}
|
||||
{frozen && frozenSymbol && FROZEN_SYMBOL}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const DECIMAL_POINT_CHAR = '.'
|
||||
|
||||
type BitcoinBalanceProps = Omit<BalanceComponentProps, 'symbol'> & { value: number }
|
||||
|
||||
const BitcoinBalance = ({ value, colored = true, ...props }: BitcoinBalanceProps) => {
|
||||
const numberString = formatBtc(value)
|
||||
const [integerPart, fractionalPart] = numberString.split(DECIMAL_POINT_CHAR)
|
||||
|
||||
const fractionPartArray = fractionalPart.split('')
|
||||
const integerPartIsZero = integerPart === '0'
|
||||
const fractionalPartStartsWithZero = fractionPartArray[0] === '0'
|
||||
|
||||
return (
|
||||
<BalanceComponent symbol={BTC_SYMBOL} colored={colored} {...props}>
|
||||
<span
|
||||
className={classNames(`slashed-zeroes`, styles.bitcoinAmount, styles.bitcoinAmountSpacing, {
|
||||
[styles.bitcoinAmountColor]: colored,
|
||||
})}
|
||||
data-testid="bitcoin-amount"
|
||||
data-integer-part-is-zero={integerPartIsZero}
|
||||
data-fractional-part-starts-with-zero={fractionalPartStartsWithZero}
|
||||
data-raw-value={value}
|
||||
data-formatted-value={numberString}
|
||||
>
|
||||
<span className={styles.integerPart}>{integerPart}</span>
|
||||
<span className={styles.decimalPoint}>{DECIMAL_POINT_CHAR}</span>
|
||||
<span className={styles.fractionalPart}>
|
||||
{fractionPartArray.map((digit, index) => (
|
||||
<span key={index} data-digit={digit}>
|
||||
{digit}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</BalanceComponent>
|
||||
)
|
||||
}
|
||||
|
||||
type SatsBalanceProps = Omit<BalanceComponentProps, 'symbol'> & { value: number }
|
||||
|
||||
const SatsBalance = ({ value, colored = true, ...props }: SatsBalanceProps) => {
|
||||
return (
|
||||
<BalanceComponent symbol={SAT_SYMBOL} colored={colored} {...props}>
|
||||
<span
|
||||
className={classNames(`slashed-zeroes`, { [styles.satsAmountColor]: colored })}
|
||||
data-testid="sats-amount"
|
||||
data-raw-value={value}
|
||||
>
|
||||
{formatSats(value)}
|
||||
</span>
|
||||
</BalanceComponent>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Options argument for Balance component.
|
||||
*
|
||||
* @param {valueString}: The balance value to render.
|
||||
* Integer values are treated as SATS while decimal numbers with a decimal point (.) are treated as BTC.
|
||||
* For example:
|
||||
* - 0, 10, 2100000000000000 are treated as a value in SATS; while
|
||||
* - 0.00000000, 150.00000001, 21000000.00000000 are treated as a value in BTC.
|
||||
* @param {convertToUnit}: The unit to convert the `valueString` to. Type {@link Unit}.
|
||||
* @param {showBalance}: A flag indicating whether to render or hide the balance.
|
||||
* Hidden balances are masked with `*****`.
|
||||
* @param {loading}: A loading flag that renders a placeholder while true.
|
||||
* @param {enableVisibilityToggle}: A flag that controls whether the balance can mask/unmask when clicked
|
||||
*/
|
||||
type BalanceProps = Omit<BalanceComponentProps, 'symbol'> & {
|
||||
valueString: string
|
||||
convertToUnit: Unit
|
||||
showBalance?: boolean
|
||||
enableVisibilityToggle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render balances nicely formatted.
|
||||
*/
|
||||
export default function Balance({
|
||||
valueString,
|
||||
convertToUnit,
|
||||
showBalance = false,
|
||||
enableVisibilityToggle = !showBalance,
|
||||
...props
|
||||
}: BalanceProps) {
|
||||
const [isBalanceVisible, setIsBalanceVisible] = useState(showBalance)
|
||||
const displayMode = useMemo(() => getDisplayMode(convertToUnit, isBalanceVisible), [convertToUnit, isBalanceVisible])
|
||||
|
||||
useEffect(() => {
|
||||
setIsBalanceVisible(showBalance)
|
||||
}, [showBalance])
|
||||
|
||||
const toggleVisibility: MouseEventHandler = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setIsBalanceVisible((current) => !current)
|
||||
}
|
||||
|
||||
const balanceComponent = useMemo(() => {
|
||||
if (displayMode === DISPLAY_MODE_HIDDEN) {
|
||||
return (
|
||||
<BalanceComponent
|
||||
symbol={<Sprite symbol="hide" width="1.2em" height="1.2em" className={styles.hideSymbol} />}
|
||||
{...props}
|
||||
>
|
||||
<span className="slashed-zeroes">{'*****'}</span>
|
||||
</BalanceComponent>
|
||||
)
|
||||
}
|
||||
|
||||
const valueNumber = parseFloat(valueString)
|
||||
if (!isValidNumber(valueNumber)) {
|
||||
console.warn('<Balance /> component expects number input as string')
|
||||
return <BalanceComponent {...props}>{valueString}</BalanceComponent>
|
||||
}
|
||||
|
||||
// Treat integers as sats.
|
||||
const valueIsSats = valueString === parseInt(valueString, 10).toString()
|
||||
// Treat decimal numbers as btc.
|
||||
const valueIsBtc = !valueIsSats && valueString.indexOf('.') > -1
|
||||
|
||||
if (valueIsBtc && displayMode === DISPLAY_MODE_BTC) return <BitcoinBalance value={valueNumber} {...props} />
|
||||
if (valueIsSats && displayMode === DISPLAY_MODE_SATS) return <SatsBalance value={valueNumber} {...props} />
|
||||
|
||||
if (valueIsBtc && displayMode === DISPLAY_MODE_SATS)
|
||||
return <SatsBalance value={btcToSats(valueString)} {...props} />
|
||||
if (valueIsSats && displayMode === DISPLAY_MODE_BTC)
|
||||
return <BitcoinBalance value={satsToBtc(valueString)} {...props} />
|
||||
|
||||
console.warn('<Balance /> component cannot determine balance format')
|
||||
return <BalanceComponent {...props}>{valueString}</BalanceComponent>
|
||||
}, [valueString, displayMode, props])
|
||||
|
||||
if (!enableVisibilityToggle) {
|
||||
return <>{balanceComponent}</>
|
||||
} else {
|
||||
return (
|
||||
<span onClick={toggleVisibility} className="cursor-pointer">
|
||||
{balanceComponent}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import user from '@testing-library/user-event'
|
||||
import { render, screen } from '../testUtils'
|
||||
|
||||
import { noop } from '../utils'
|
||||
|
||||
import BitcoinAmountInput, { AmountValue, BitcoinAmountInputProps } from './BitcoinAmountInput'
|
||||
import { Formik } from 'formik'
|
||||
|
||||
describe('<BitcoinAmountInput />', () => {
|
||||
const setup = (props: Omit<BitcoinAmountInputProps, 'field' | 'form'>) => {
|
||||
render(
|
||||
<Formik initialValues={{ value: undefined }} onSubmit={noop}>
|
||||
{(form) => {
|
||||
const valueField = form.getFieldProps<AmountValue>('value')
|
||||
return <BitcoinAmountInput form={form} field={valueField} {...props} />
|
||||
}}
|
||||
</Formik>,
|
||||
)
|
||||
}
|
||||
|
||||
it('should render without errors', () => {
|
||||
setup({
|
||||
label: 'test-label',
|
||||
})
|
||||
const inputElement = screen.getByLabelText('test-label')
|
||||
|
||||
expect(inputElement).toBeVisible()
|
||||
expect(inputElement.dataset.value).toBe(undefined)
|
||||
expect(inputElement.dataset.displayUnit).toBe(undefined)
|
||||
expect(inputElement.dataset.displayValue).toBe(undefined)
|
||||
})
|
||||
|
||||
it('amount can be entered in sats', async () => {
|
||||
setup({
|
||||
label: 'test-label',
|
||||
})
|
||||
|
||||
const inputElement = screen.getByLabelText('test-label')
|
||||
|
||||
await user.type(inputElement, '123456')
|
||||
|
||||
expect(inputElement).toHaveFocus()
|
||||
expect(inputElement.dataset.value).toBe(`123456`)
|
||||
expect(inputElement.dataset.displayUnit).toBe(`sats`)
|
||||
expect(inputElement.dataset.displayValue).toBe(`123456`)
|
||||
|
||||
// changes to display unit to BTC after element loses focus
|
||||
await user.tab()
|
||||
|
||||
expect(inputElement).not.toHaveFocus()
|
||||
expect(inputElement.dataset.value).toBe(`123456`)
|
||||
expect(inputElement.dataset.displayUnit).toBe(`BTC`)
|
||||
expect(inputElement.dataset.displayValue).toBe(`0.00 123 456`)
|
||||
})
|
||||
|
||||
it('amount can be entered in BTC', async () => {
|
||||
setup({
|
||||
label: 'test-label',
|
||||
})
|
||||
const inputElement = screen.getByLabelText('test-label')
|
||||
|
||||
await user.type(inputElement, '1.234')
|
||||
|
||||
expect(inputElement).toHaveFocus()
|
||||
expect(inputElement.dataset.value).toBe(`123400000`)
|
||||
expect(inputElement.dataset.displayUnit).toBe(`BTC`)
|
||||
expect(inputElement.dataset.displayValue).toBe(`1.234`)
|
||||
|
||||
// keeps display unit in BTC after element loses focus
|
||||
await user.tab()
|
||||
|
||||
expect(inputElement).not.toHaveFocus()
|
||||
expect(inputElement.dataset.value).toBe(`123400000`)
|
||||
expect(inputElement.dataset.displayUnit).toBe(`BTC`)
|
||||
expect(inputElement.dataset.displayValue).toBe(`1.23 400 000`)
|
||||
})
|
||||
|
||||
it('given input must be a number', async () => {
|
||||
setup({
|
||||
label: 'test-label',
|
||||
})
|
||||
const inputElement = screen.getByLabelText('test-label')
|
||||
|
||||
await user.type(inputElement, 'test')
|
||||
|
||||
expect(inputElement).toHaveFocus()
|
||||
expect(inputElement.dataset.value).toBe(undefined)
|
||||
expect(inputElement.dataset.displayUnit).toBe(undefined)
|
||||
expect(inputElement.dataset.displayValue).toBe(undefined)
|
||||
|
||||
await user.tab()
|
||||
|
||||
expect(inputElement).not.toHaveFocus()
|
||||
expect(inputElement.dataset.value).toBe(undefined)
|
||||
expect(inputElement.dataset.displayUnit).toBe(undefined)
|
||||
expect(inputElement.dataset.displayValue).toBe('')
|
||||
})
|
||||
|
||||
it('amount 1.0 should be interpreted as 1 BTC', async () => {
|
||||
setup({
|
||||
label: 'test-label',
|
||||
})
|
||||
const inputElement = screen.getByLabelText('test-label')
|
||||
|
||||
await user.type(inputElement, '1.0')
|
||||
|
||||
expect(inputElement).toHaveFocus()
|
||||
expect(inputElement.dataset.value).toBe('100000000')
|
||||
expect(inputElement.dataset.displayUnit).toBe('BTC')
|
||||
expect(inputElement.dataset.displayValue).toBe(`1.0`)
|
||||
|
||||
await user.tab()
|
||||
|
||||
expect(inputElement).not.toHaveFocus()
|
||||
expect(inputElement.dataset.value).toBe('100000000')
|
||||
expect(inputElement.dataset.displayUnit).toBe('BTC')
|
||||
expect(inputElement.dataset.displayValue).toBe(`1.00 000 000`)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import { PropsWithChildren, forwardRef, useMemo, useState } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import classNames from 'classnames'
|
||||
import { FieldInputProps, FormikContextType } from 'formik'
|
||||
import Sprite from './Sprite'
|
||||
import * as Api from '../libs/JmWalletApi'
|
||||
import { BITCOIN_SYMBOL, formatBtcDisplayValue, isValidNumber } from '../utils'
|
||||
|
||||
export type AmountValue = {
|
||||
value: Api.AmountSats | null
|
||||
isSweep: boolean
|
||||
userRawInputValue?: string
|
||||
displayValue?: string
|
||||
}
|
||||
|
||||
export const toAmountValue = (value: Api.AmountSats): AmountValue => ({
|
||||
value,
|
||||
isSweep: false,
|
||||
userRawInputValue: String(value),
|
||||
displayValue: formatBtcDisplayValue(value),
|
||||
})
|
||||
|
||||
const unitFromValue = (value: string | undefined): Unit | undefined => {
|
||||
return value !== undefined && value !== '' ? (value?.includes('.') ? 'BTC' : 'sats') : undefined
|
||||
}
|
||||
|
||||
export type BitcoinAmountInputProps = {
|
||||
label: string
|
||||
className?: string
|
||||
inputGroupTextClassName?: string
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
field: FieldInputProps<AmountValue | undefined>
|
||||
form: FormikContextType<any>
|
||||
}
|
||||
|
||||
const BitcoinAmountInput = forwardRef(
|
||||
(
|
||||
{
|
||||
label,
|
||||
className,
|
||||
inputGroupTextClassName,
|
||||
disabled,
|
||||
placeholder,
|
||||
field,
|
||||
form,
|
||||
children,
|
||||
}: PropsWithChildren<BitcoinAmountInputProps>,
|
||||
ref: React.Ref<HTMLInputElement>,
|
||||
) => {
|
||||
const [inputType, setInputType] = useState<{ type: 'text' | 'number'; inputMode?: 'decimal' }>({
|
||||
type: 'text',
|
||||
inputMode: 'decimal',
|
||||
})
|
||||
|
||||
const displayInputUnit = useMemo(() => {
|
||||
return inputType.type === 'number'
|
||||
? unitFromValue(field.value?.userRawInputValue)
|
||||
: field.value?.displayValue
|
||||
? 'BTC'
|
||||
: undefined
|
||||
}, [field, inputType])
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setInputType({
|
||||
type: 'text',
|
||||
inputMode: 'decimal',
|
||||
})
|
||||
|
||||
let displayValue = String(field.value?.value || '')
|
||||
if (isValidNumber(field.value?.value)) {
|
||||
displayValue = formatBtcDisplayValue(field.value!.value!)
|
||||
}
|
||||
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
{
|
||||
...field.value,
|
||||
displayValue,
|
||||
},
|
||||
false,
|
||||
)
|
||||
field.onBlur(e)
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawUserInputOrEmpty = e.target.value ?? ''
|
||||
const validNumberRegex = /^-?\d*\.?\d*$/
|
||||
if (!validNumberRegex.test(rawUserInputOrEmpty)) {
|
||||
return
|
||||
}
|
||||
const floatValueOrNan = parseFloat(rawUserInputOrEmpty)
|
||||
if (!isValidNumber(floatValueOrNan)) {
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
{
|
||||
...field.value,
|
||||
value: null,
|
||||
userRawInputValue: e.target.value,
|
||||
displayValue: e.target.value,
|
||||
},
|
||||
true,
|
||||
)
|
||||
return
|
||||
} else {
|
||||
const value: number = floatValueOrNan
|
||||
let numberValues: string | undefined
|
||||
const unit =
|
||||
rawUserInputOrEmpty.includes('.') && parseFloat(rawUserInputOrEmpty)
|
||||
? unitFromValue(String(rawUserInputOrEmpty))
|
||||
: unitFromValue(String(value))
|
||||
if (unit === 'BTC') {
|
||||
const splitted = String(value).split('.')
|
||||
const [integerPart, fractionalPart = ''] = splitted
|
||||
const paddedFractionalPart = fractionalPart.padEnd(8, '0').substring(0, 8)
|
||||
numberValues = `${integerPart}${paddedFractionalPart}`
|
||||
} else {
|
||||
numberValues = value.toLocaleString('en-US', {
|
||||
maximumFractionDigits: 0,
|
||||
useGrouping: false,
|
||||
})
|
||||
}
|
||||
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
{
|
||||
value: parseInt(numberValues, 10),
|
||||
userRawInputValue: e.target.value,
|
||||
displayValue: e.target.value,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<rb.InputGroup hasValidation={true}>
|
||||
<rb.InputGroup.Text className={inputGroupTextClassName}>
|
||||
{displayInputUnit === undefined && <>…</>}
|
||||
{displayInputUnit === 'sats' && <Sprite symbol="sats" width="24" height="24" />}
|
||||
{displayInputUnit === 'BTC' && <span style={{ fontSize: '1.175rem' }}>{BITCOIN_SYMBOL}</span>}
|
||||
</rb.InputGroup.Text>
|
||||
<rb.Form.Control
|
||||
ref={ref}
|
||||
aria-label={label}
|
||||
data-value={field.value?.value}
|
||||
data-display-unit={displayInputUnit}
|
||||
data-display-value={field.value?.displayValue}
|
||||
name={field.name}
|
||||
autoComplete="off"
|
||||
inputMode={inputType.inputMode}
|
||||
className={classNames('slashed-zeroes', className)}
|
||||
value={
|
||||
inputType.type === 'text'
|
||||
? (field.value?.displayValue ?? '')
|
||||
: String(field.value?.userRawInputValue ?? '')
|
||||
}
|
||||
placeholder={placeholder}
|
||||
min={displayInputUnit === 'BTC' ? '0.00000001' : '1'}
|
||||
step={displayInputUnit === 'BTC' ? '0.00000001' : '1'}
|
||||
isInvalid={form.touched[field.name] && !!form.errors[field.name]}
|
||||
disabled={disabled}
|
||||
required
|
||||
onFocus={() => {
|
||||
setInputType({ type: 'number' })
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{children}
|
||||
<rb.Form.Control.Feedback type="invalid">
|
||||
<>{form.errors[field.name]}</>
|
||||
</rb.Form.Control.Feedback>
|
||||
</rb.InputGroup>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default BitcoinAmountInput
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
import { satsToBtc } from '../utils'
|
||||
import { AmountSats, BitcoinAddress } from '../libs/JmWalletApi'
|
||||
|
||||
interface BitcoinQRProps {
|
||||
address: BitcoinAddress
|
||||
amount?: AmountSats
|
||||
errorCorrectionLevel?: QRCode.QRCodeErrorCorrectionLevel
|
||||
width?: number
|
||||
}
|
||||
|
||||
export const BitcoinQR = ({ address, amount, errorCorrectionLevel = 'H', width = 260 }: BitcoinQRProps) => {
|
||||
const [data, setData] = useState<string>()
|
||||
const [image, setImage] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
const btc = amount ? satsToBtc(String(amount)) || 0 : 0
|
||||
const uri = `bitcoin:${address}${btc > 0 ? `?amount=${btc.toFixed(8)}` : ''}`
|
||||
|
||||
QRCode.toDataURL(uri, {
|
||||
errorCorrectionLevel,
|
||||
width,
|
||||
})
|
||||
.then((val) => {
|
||||
setImage(val)
|
||||
setData(uri)
|
||||
})
|
||||
.catch(() => {
|
||||
setImage(undefined)
|
||||
setData(uri)
|
||||
})
|
||||
}, [address, amount, errorCorrectionLevel, width])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<img src={image} alt={data} title={data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
.cheatsheet {
|
||||
height: auto !important;
|
||||
/* page height - navbar height - some spacing*/
|
||||
max-height: calc(100% - 4.75rem - 1rem) !important;
|
||||
margin: 0px auto !important;
|
||||
border: none !important;
|
||||
border-top-left-radius: 1rem;
|
||||
border-top-right-radius: 1rem;
|
||||
width: 33rem;
|
||||
|
||||
box-shadow: 6px -3px 12px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .cheatsheet {
|
||||
box-shadow: 6px -3px 12px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cheatsheet a {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.cheatsheet .cheatsheet-list-item {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cheatsheet-list-item.upcoming-feature {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.cheatsheet-list-item h6 {
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.numbered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(0, 0, 0);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .numbered {
|
||||
color: rgb(0, 0, 0);
|
||||
background-color: white;
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { routes } from '../constants/routes'
|
||||
import Sprite from './Sprite'
|
||||
import styles from './Cheatsheet.module.css'
|
||||
|
||||
interface CheatsheetProps {
|
||||
show: boolean
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
type NumberedProps = {
|
||||
number: number | 'last'
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Numbered({ number }: NumberedProps) {
|
||||
return (
|
||||
<div className={styles.numbered}>
|
||||
{number === 'last' ? (
|
||||
<>
|
||||
<Sprite symbol="checkmark" width="24" height="24" />
|
||||
</>
|
||||
) : (
|
||||
<>{number}</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListItem({ number, children, ...props }: PropsWithChildren<NumberedProps>) {
|
||||
return (
|
||||
<rb.Stack className={`${styles['cheatsheet-list-item']} ${props.className || ''}`} direction="horizontal" gap={3}>
|
||||
<Numbered number={number} />
|
||||
<rb.Stack gap={0}>{children}</rb.Stack>
|
||||
</rb.Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<rb.Offcanvas className={styles.cheatsheet} show={show} onHide={onHide} placement="bottom" onClick={onHide}>
|
||||
<rb.Offcanvas.Header>
|
||||
<rb.Stack>
|
||||
<rb.Offcanvas.Title>{t('cheatsheet.title')}</rb.Offcanvas.Title>
|
||||
<div className="small text-secondary">
|
||||
<Trans i18nKey="cheatsheet.description">
|
||||
Follow the steps below to increase your financial privacy. It is advisable to switch from{' '}
|
||||
<a href="https://jamdocs.org/glossary/#maker" target="_blank" rel="noopener noreferrer">
|
||||
earning as a maker
|
||||
</a>{' '}
|
||||
to{' '}
|
||||
<a href="https://jamdocs.org/glossary/#taker" target="_blank" rel="noopener noreferrer">
|
||||
sending as a taker
|
||||
</a>{' '}
|
||||
back and forth.{' '}
|
||||
<a href="https://jamdocs.org/interface/00-cheatsheet/" target="_blank" rel="noopener noreferrer">
|
||||
Learn more.
|
||||
</a>
|
||||
</Trans>
|
||||
</div>
|
||||
</rb.Stack>
|
||||
<rb.Button variant="link" className="unstyled p-0 mb-auto" onClick={onHide}>
|
||||
<Sprite symbol="cancel" width="32" height="32" />
|
||||
</rb.Button>
|
||||
</rb.Offcanvas.Header>
|
||||
<rb.Offcanvas.Body>
|
||||
<rb.Stack className="mb-4" gap={4}>
|
||||
<ListItem number={1}>
|
||||
<h6>
|
||||
<Trans i18nKey="cheatsheet.receive.title">
|
||||
<Link to={routes.receive}>Fund</Link> your wallet.
|
||||
</Trans>
|
||||
</h6>
|
||||
<div className="small text-secondary">{t('cheatsheet.receive.description')}</div>
|
||||
</ListItem>
|
||||
<ListItem number={2}>
|
||||
<h6>
|
||||
<Trans i18nKey="cheatsheet.send.title">
|
||||
<Link to={routes.send}>Send</Link> a collaborative transaction to another jar.
|
||||
</Trans>
|
||||
</h6>
|
||||
<div className="small text-secondary">{t('cheatsheet.send.description')}</div>
|
||||
</ListItem>
|
||||
<ListItem number={3}>
|
||||
<h6>
|
||||
<Trans i18nKey="cheatsheet.bond.title">
|
||||
Optional: <Link to={routes.earn}>Lock</Link> funds in a fidelity bond.
|
||||
</Trans>
|
||||
</h6>
|
||||
<div className="small text-secondary">{t('cheatsheet.bond.description')}</div>
|
||||
</ListItem>
|
||||
<ListItem number={4}>
|
||||
<h6>
|
||||
<Trans i18nKey="cheatsheet.earn.title">
|
||||
<Link to={routes.earn}>Earn</Link> sats by providing liquidity.
|
||||
</Trans>
|
||||
</h6>
|
||||
<div className="small text-secondary">{t('cheatsheet.earn.description')}</div>
|
||||
</ListItem>
|
||||
<ListItem number={5}>
|
||||
<h6>
|
||||
<Trans i18nKey="cheatsheet.schedule.title">
|
||||
Schedule <Link to={routes.jam}>sweep</Link> transactions to empty your wallet.
|
||||
</Trans>
|
||||
</h6>
|
||||
<div className="small text-secondary">{t('cheatsheet.schedule.description')}</div>
|
||||
</ListItem>
|
||||
<ListItem number={'last'}>
|
||||
<h6>{t('cheatsheet.repeat.title')}</h6>
|
||||
<div className="small text-secondary">
|
||||
<Trans i18nKey="cheatsheet.repeat.description">
|
||||
Still confused?{' '}
|
||||
<a href="https://jamdocs.org/interface/00-cheatsheet/" target="_blank" rel="noopener noreferrer">
|
||||
Dig into the documentation
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</div>
|
||||
</ListItem>
|
||||
</rb.Stack>
|
||||
</rb.Offcanvas.Body>
|
||||
</rb.Offcanvas>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { Ref, forwardRef } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useSettings } from '../context/SettingsContext'
|
||||
import { CoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
|
||||
import { jarInitial } from './jars/Jar'
|
||||
import { shortenStringMiddle } from '../utils'
|
||||
import Sprite from './Sprite'
|
||||
import Balance from './Balance'
|
||||
|
||||
interface CoinjoinPreconditionViolationAlertProps {
|
||||
summary: CoinjoinRequirementSummary
|
||||
i18nPrefix?: string
|
||||
}
|
||||
|
||||
export const CoinjoinPreconditionViolationAlert = forwardRef(
|
||||
({ summary, i18nPrefix = '' }: CoinjoinPreconditionViolationAlertProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
|
||||
if (summary.isFulfilled) return <></>
|
||||
|
||||
if (summary.numberOfMissingUtxos > 0) {
|
||||
return (
|
||||
<rb.Alert variant="warning" ref={ref}>
|
||||
{t(`${i18nPrefix}hint_missing_utxos`, {
|
||||
minConfirmations: summary.options.minConfirmations,
|
||||
})}
|
||||
</rb.Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (summary.numberOfMissingConfirmations > 0) {
|
||||
return (
|
||||
<rb.Alert variant="warning" ref={ref}>
|
||||
{t(`${i18nPrefix}hint_missing_confirmations`, {
|
||||
minConfirmations: summary.options.minConfirmations,
|
||||
amountOfMissingConfirmations: summary.numberOfMissingConfirmations,
|
||||
})}
|
||||
</rb.Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const utxosViolatingRetriesLeft = summary.violations
|
||||
.map((it) => it.utxosViolatingRetriesLeft)
|
||||
.reduce((acc, utxos) => acc.concat(utxos), [])
|
||||
|
||||
if (utxosViolatingRetriesLeft.length > 0) {
|
||||
return (
|
||||
<rb.Alert variant="warning" ref={ref}>
|
||||
<>
|
||||
<Trans i18nKey={`${i18nPrefix}hint_missing_retries`}>
|
||||
You tried too many times. See
|
||||
<a
|
||||
href="https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.7/docs/SOURCING-COMMITMENTS.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
the docs
|
||||
</a>{' '}
|
||||
for more info.
|
||||
</Trans>
|
||||
<br />
|
||||
<br />
|
||||
<Trans i18nKey={`${i18nPrefix}hint_missing_retries_detail`} count={utxosViolatingRetriesLeft.length}>
|
||||
Following utxos have been used unsuccessfully too many times:
|
||||
<ul className="mt-2 mb-0 ps-2">
|
||||
{utxosViolatingRetriesLeft.map((utxo, index) => (
|
||||
<li key={index} className="mb-2 slashed-zeroes small" style={{ display: 'inline-flex' }}>
|
||||
<span className="pe-1" style={{ display: 'inline-flex' }}>
|
||||
<Sprite symbol="jar-closed-fill-50" width="20" height="20" />
|
||||
<span className="slashed-zeroes">
|
||||
<strong>{jarInitial(utxo.mixdepth)}</strong>
|
||||
</span>
|
||||
:
|
||||
</span>
|
||||
<div>
|
||||
<span>{utxo.address}</span>
|
||||
(
|
||||
<Balance
|
||||
valueString={`${utxo.value}`}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={settings.showBalance}
|
||||
/>
|
||||
)
|
||||
<br />
|
||||
<small>{shortenStringMiddle(utxo.utxo, 32)}</small>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Trans>
|
||||
</>
|
||||
</rb.Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return <></>
|
||||
},
|
||||
)
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { ReactNode, PropsWithChildren, useState, useEffect, useRef } from 'react'
|
||||
|
||||
const copyToClipboard = (
|
||||
text: string,
|
||||
fallbackInputField: HTMLInputElement,
|
||||
errorMessage?: string,
|
||||
): Promise<boolean> => {
|
||||
const copyToClipboardFallback = (
|
||||
inputField: HTMLInputElement,
|
||||
errorMessage = 'Cannot copy value to clipboard',
|
||||
): Promise<boolean> =>
|
||||
new Promise((resolve, reject) => {
|
||||
inputField.select()
|
||||
const success = document.execCommand && document.execCommand('copy')
|
||||
inputField.blur()
|
||||
success ? resolve(success) : reject(new Error(errorMessage))
|
||||
})
|
||||
|
||||
// The `navigator.clipboard` API might not be available, e.g. on sites served over HTTP.
|
||||
if (!navigator.clipboard) {
|
||||
return copyToClipboardFallback(fallbackInputField)
|
||||
}
|
||||
|
||||
return navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => true)
|
||||
.catch((e: Error) => {
|
||||
if (fallbackInputField) {
|
||||
return copyToClipboardFallback(fallbackInputField, errorMessage)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface CopyableProps {
|
||||
value: string
|
||||
onSuccess?: () => void
|
||||
onError?: (e: Error) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function Copyable({
|
||||
value,
|
||||
onSuccess,
|
||||
onError,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: PropsWithChildren<CopyableProps>) {
|
||||
const valueFallbackInputRef = useRef(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
onClick={() => copyToClipboard(value, valueFallbackInputRef.current!).then(onSuccess, onError)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<input
|
||||
readOnly
|
||||
aria-hidden
|
||||
ref={valueFallbackInputRef}
|
||||
value={value}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
top: '-9999px',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface CopyButtonProps extends CopyableProps {
|
||||
text: ReactNode
|
||||
successText?: ReactNode
|
||||
successTextTimeout?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
onSuccess,
|
||||
onError,
|
||||
text,
|
||||
successText = text,
|
||||
successTextTimeout = 1_500,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: CopyButtonProps) {
|
||||
const [showValueCopiedConfirmation, setShowValueCopiedConfirmation] = useState(false)
|
||||
const [valueCopiedFlag, setValueCopiedFlag] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (valueCopiedFlag < 1) return
|
||||
|
||||
setShowValueCopiedConfirmation(true)
|
||||
const timer = setTimeout(() => {
|
||||
setShowValueCopiedConfirmation(false)
|
||||
}, successTextTimeout)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [valueCopiedFlag, successTextTimeout])
|
||||
|
||||
return (
|
||||
<Copyable
|
||||
{...props}
|
||||
disabled={disabled}
|
||||
className={`btn ${className || ''}`}
|
||||
value={value}
|
||||
onError={onError}
|
||||
onSuccess={() => {
|
||||
setValueCopiedFlag((current) => current + 1)
|
||||
onSuccess && onSuccess()
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center justify-content-center">
|
||||
{showValueCopiedConfirmation ? successText : text}
|
||||
</div>
|
||||
</Copyable>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
import { BrowserRouter } from 'react-router-dom'
|
||||
import user from '@testing-library/user-event'
|
||||
import { render, screen, act } from '../testUtils'
|
||||
import { __testSetDebugFeatureEnabled } from '../constants/debugFeatures'
|
||||
|
||||
import * as apiMock from '../libs/JmWalletApi'
|
||||
import { DUMMY_MNEMONIC_PHRASE } from '../utils'
|
||||
|
||||
import CreateWallet from './CreateWallet'
|
||||
|
||||
jest.mock('../libs/JmWalletApi', () => ({
|
||||
...jest.requireActual('../libs/JmWalletApi'),
|
||||
getGetinfo: jest.fn(),
|
||||
getSession: jest.fn(),
|
||||
postWalletCreate: jest.fn(),
|
||||
getWalletAll: jest.fn(),
|
||||
}))
|
||||
|
||||
const NOOP = () => {}
|
||||
|
||||
describe('<CreateWallet />', () => {
|
||||
const testWalletName = 'test_wallet21'
|
||||
const invalidTestWalletName = 'invalid_wallet_name!'
|
||||
const testWalletPassword = 'correct horse battery staple'
|
||||
|
||||
const setup = ({
|
||||
startWallet = NOOP,
|
||||
}: {
|
||||
startWallet?: (name: apiMock.WalletFileName, auth: apiMock.ApiAuthContext) => void
|
||||
}) => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<CreateWallet startWallet={startWallet} parentRoute="home" />
|
||||
</BrowserRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const neverResolvingPromise = new Promise(() => {})
|
||||
;(apiMock.getGetinfo as jest.Mock).mockReturnValue(neverResolvingPromise)
|
||||
;(apiMock.getSession as jest.Mock).mockReturnValue(neverResolvingPromise)
|
||||
;(apiMock.getWalletAll as jest.Mock).mockReturnValue(neverResolvingPromise)
|
||||
})
|
||||
|
||||
it('should display alert when rescanning is active', async () => {
|
||||
;(apiMock.getSession as jest.Mock).mockReturnValue(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
rescanning: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => setup({}))
|
||||
|
||||
expect(screen.getByText('create_wallet.title')).toBeVisible()
|
||||
expect(screen.getByTestId('alert-rescanning')).toBeVisible()
|
||||
expect(screen.queryByText('create_wallet.button_create')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without errors', () => {
|
||||
setup({})
|
||||
|
||||
expect(screen.getByText('create_wallet.title')).toBeVisible()
|
||||
expect(screen.getByLabelText('create_wallet.label_wallet_name')).toBeVisible()
|
||||
expect(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name')).toBeVisible()
|
||||
expect(screen.getByLabelText('create_wallet.label_password')).toBeVisible()
|
||||
expect(screen.getByPlaceholderText('create_wallet.placeholder_password')).toBeVisible()
|
||||
expect(screen.getByLabelText('create_wallet.label_password_confirm')).toBeVisible()
|
||||
expect(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm')).toBeVisible()
|
||||
expect(screen.getByText('create_wallet.button_create')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should show validation messages to user if form is invalid', async () => {
|
||||
setup({})
|
||||
|
||||
expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
|
||||
expect(await screen.queryByText('create_wallet.feedback_invalid_password')).not.toBeInTheDocument()
|
||||
expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
|
||||
|
||||
// click on the "create" button without filling the form
|
||||
await user.click(screen.getByText('create_wallet.button_create'))
|
||||
|
||||
expect(await screen.findByText('create_wallet.feedback_invalid_wallet_name')).toBeVisible()
|
||||
expect(await screen.findByText('create_wallet.feedback_invalid_password')).toBeVisible()
|
||||
expect(await screen.findByText('create_wallet.feedback_invalid_password_confirm')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should show validation message to user if duplicate wallet name', async () => {
|
||||
;(apiMock.getWalletAll as jest.Mock).mockReturnValue(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ wallets: [`${testWalletName}.jmdat`] }),
|
||||
}),
|
||||
)
|
||||
setup({})
|
||||
|
||||
expect(await screen.queryByText('create_wallet.feedback_wallet_name_already_exists')).not.toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
|
||||
|
||||
await user.click(screen.getByText('create_wallet.button_create'))
|
||||
|
||||
expect(await screen.findByText('create_wallet.feedback_wallet_name_already_exists')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should not submit form if wallet name contains invalid characters', async () => {
|
||||
setup({})
|
||||
|
||||
expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
|
||||
expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), invalidTestWalletName)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
|
||||
|
||||
await user.click(screen.getByText('create_wallet.button_create'))
|
||||
|
||||
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
|
||||
expect(await screen.findByText('create_wallet.feedback_invalid_wallet_name')).toBeVisible()
|
||||
expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not submit form if passwords do not match', async () => {
|
||||
setup({})
|
||||
|
||||
expect(await screen.findByPlaceholderText('create_wallet.placeholder_password')).toBeVisible()
|
||||
expect(await screen.findByPlaceholderText('create_wallet.placeholder_password_confirm')).toBeVisible()
|
||||
expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
|
||||
expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), '.*')
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), 'a_mismatching_input')
|
||||
|
||||
await user.click(screen.getByText('create_wallet.button_create'))
|
||||
|
||||
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
|
||||
expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('create_wallet.feedback_invalid_password_confirm')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should advance to WalletCreationConfirmation after wallet is created', async () => {
|
||||
;(apiMock.postWalletCreate as jest.Mock).mockReturnValue(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
walletname: `${testWalletName}.jmdat`,
|
||||
token: 'ANY_TOKEN',
|
||||
seedphrase: DUMMY_MNEMONIC_PHRASE.join(' '),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
setup({})
|
||||
|
||||
expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
|
||||
expect(await screen.queryByText('create_wallet.title_wallet_created')).not.toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
|
||||
|
||||
await user.click(screen.getByText('create_wallet.button_create'))
|
||||
|
||||
expect(screen.getByText('create_wallet.title_wallet_created')).toBeVisible()
|
||||
expect(screen.queryByText('create_wallet.button_create')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should verify that "skip" button is NOT visible by default (feature is disabled)', async () => {
|
||||
;(apiMock.postWalletCreate as jest.Mock).mockReturnValue(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
walletname: `${testWalletName}.jmdat`,
|
||||
token: 'ANY_TOKEN',
|
||||
seedphrase: DUMMY_MNEMONIC_PHRASE.join(' '),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
setup({})
|
||||
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
|
||||
|
||||
const createWalletButton = screen.getByText('create_wallet.button_create')
|
||||
await user.click(createWalletButton)
|
||||
|
||||
const revealToggle = await screen.findByText('create_wallet.confirmation_toggle_reveal_info')
|
||||
await user.click(revealToggle)
|
||||
|
||||
const confirmToggle = screen.getByText('create_wallet.confirmation_toggle_info_written_down')
|
||||
await user.click(confirmToggle)
|
||||
|
||||
const nextButton = screen.getByText('create_wallet.next_button')
|
||||
await user.click(nextButton)
|
||||
|
||||
expect(screen.queryByText('create_wallet.skip_button')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('create_wallet.back_button')).toBeVisible()
|
||||
expect(screen.getByText('create_wallet.confirmation_button_fund_wallet')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should verify that "skip" button IS visible when feature is enabled', async () => {
|
||||
__testSetDebugFeatureEnabled('skipWalletBackupConfirmation', true)
|
||||
;(apiMock.postWalletCreate as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
walletname: `${testWalletName}.jmdat`,
|
||||
token: 'ANY_TOKEN',
|
||||
seedphrase: DUMMY_MNEMONIC_PHRASE.join(' '),
|
||||
}),
|
||||
})
|
||||
|
||||
setup({})
|
||||
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), testWalletName)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
|
||||
await user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
|
||||
|
||||
const createWalletButton = screen.getByText('create_wallet.button_create')
|
||||
await user.click(createWalletButton)
|
||||
|
||||
const revealToggle = await screen.findByText('create_wallet.confirmation_toggle_reveal_info')
|
||||
await user.click(revealToggle)
|
||||
|
||||
const confirmToggle = screen.getByText('create_wallet.confirmation_toggle_info_written_down')
|
||||
await user.click(confirmToggle)
|
||||
|
||||
const nextButton = screen.getByText('create_wallet.next_button')
|
||||
await user.click(nextButton)
|
||||
|
||||
expect(screen.getByText('create_wallet.skip_button')).toBeVisible()
|
||||
expect(screen.getByText('create_wallet.back_button')).toBeVisible()
|
||||
expect(screen.getByText('create_wallet.confirmation_button_fund_wallet')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,222 +1,280 @@
|
|||
import { useState, useCallback, useMemo } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import PageTitle from './PageTitle'
|
||||
import Sprite from './Sprite'
|
||||
import WalletCreationConfirmation, { CreatedWalletInfo } from './WalletCreationConfirmation'
|
||||
import PreventLeavingPageByMistake from './PreventLeavingPageByMistake'
|
||||
import WalletCreationForm, { CreateWalletFormValues } from './WalletCreationForm'
|
||||
import MnemonicPhraseInput from './MnemonicPhraseInput'
|
||||
import { walletDisplayName, walletDisplayNameToFileName } from '../utils'
|
||||
import { useServiceInfo } from '../context/ServiceInfoContext'
|
||||
import * as Api from '../libs/JmWalletApi'
|
||||
import { Route, routes } from '../constants/routes'
|
||||
import { isDebugFeatureEnabled } from '../constants/debugFeatures'
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { setSession, clearSession } from '@/lib/session'
|
||||
import { formatWalletName } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { AlertCircle, Wallet, Lock, Loader2, Eye, EyeOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { createwallet, session, type CreateWalletResponse } from '@/lib/jm-api/generated/client'
|
||||
|
||||
type CreatedWalletWithAuth = CreatedWalletInfo & {
|
||||
auth: Api.ApiAuthContext
|
||||
}
|
||||
const CreateWallet = () => {
|
||||
const navigate = useNavigate()
|
||||
const [walletName, setWalletName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [createWalletResponse, setCreateWalletResponse] = useState<CreateWalletResponse>()
|
||||
const [step, setStep] = useState<'create' | 'seed' | 'confirm'>('create')
|
||||
|
||||
interface BackupConfirmationProps {
|
||||
wallet: CreatedWalletInfo
|
||||
onSuccess: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
const handleCreateWallet = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const BackupConfirmation = ({ wallet, onSuccess, onCancel }: BackupConfirmationProps) => {
|
||||
const { t } = useTranslation()
|
||||
// Validation
|
||||
if (!walletName.trim()) {
|
||||
toast.error('Wallet name is required')
|
||||
return
|
||||
}
|
||||
|
||||
const seedphrase = useMemo(() => wallet.seedphrase.split(' '), [wallet])
|
||||
const [givenWords, setGivenWords] = useState(new Array(seedphrase.length).fill(''))
|
||||
const [showSkipButton] = useState(isDebugFeatureEnabled('skipWalletBackupConfirmation'))
|
||||
if (password.length < 8) {
|
||||
toast.error('Password must be at least 8 characters long')
|
||||
return
|
||||
}
|
||||
|
||||
const isSeedBackupConfirmed = useMemo(
|
||||
() => givenWords.every((word, index) => word === seedphrase[index]),
|
||||
[givenWords, seedphrase],
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// Clear any existing local session
|
||||
clearSession()
|
||||
|
||||
// Check if there's an active session on the server
|
||||
try {
|
||||
const { data: sessionInfo } = await session()
|
||||
if (sessionInfo?.session || sessionInfo?.wallet_name !== 'None') {
|
||||
console.warn('Active session detected:', sessionInfo)
|
||||
toast.error(
|
||||
`Cannot create wallet as "${formatWalletName(
|
||||
sessionInfo?.wallet_name || 'Unknown',
|
||||
)}" wallet is currently active.`,
|
||||
{
|
||||
description: (
|
||||
<div className="text-black dark:text-white">
|
||||
Alternatively, you can{' '}
|
||||
<Link to="/login" className="underline hover:no-underline font-medium">
|
||||
log in with the existing wallet
|
||||
</Link>{' '}
|
||||
instead.
|
||||
</div>
|
||||
),
|
||||
duration: 8000,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch (sessionError) {
|
||||
console.warn('Could not check session status:', sessionError)
|
||||
// Continue anyway, wallet creation might still work
|
||||
}
|
||||
|
||||
const walletFileName = walletName.endsWith('.jmdat') ? walletName : `${walletName}.jmdat`
|
||||
const { data: response, error: createError } = await createwallet({
|
||||
body: {
|
||||
walletname: walletFileName,
|
||||
password,
|
||||
wallettype: 'sw-fb',
|
||||
},
|
||||
})
|
||||
|
||||
if (createError) {
|
||||
throw createError
|
||||
}
|
||||
|
||||
if (response?.seedphrase) {
|
||||
setCreateWalletResponse(response)
|
||||
setStep('seed')
|
||||
} else {
|
||||
throw new Error('No seedphrase returned')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create wallet'
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmSeed = () => {
|
||||
if (createWalletResponse?.seedphrase) {
|
||||
// Save session and navigate to dashboard
|
||||
const walletFileName = walletName.endsWith('.jmdat') ? walletName : `${walletName}.jmdat`
|
||||
setSession({
|
||||
walletFileName,
|
||||
auth: { token: createWalletResponse.token, refresh_token: createWalletResponse.refresh_token }, // We'll need to unlock it properly later
|
||||
})
|
||||
|
||||
navigate('/login', {
|
||||
state: {
|
||||
message: 'Wallet created successfully! Please log in with your credentials.',
|
||||
walletName: walletFileName,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const renderCreateForm = () => (
|
||||
<form onSubmit={handleCreateWallet} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wallet-name">Wallet Name</Label>
|
||||
<Input
|
||||
id="wallet-name"
|
||||
type="text"
|
||||
value={walletName}
|
||||
onChange={(e) => setWalletName(e.target.value)}
|
||||
disabled={isLoading}
|
||||
placeholder="Enter wallet name"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Will be saved as {walletName || 'wallet-name'}.jmdat</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
placeholder="Enter password"
|
||||
className="pl-10 pr-10"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2"
|
||||
onClick={() => {
|
||||
setShowConfirmPassword(false)
|
||||
setShowPassword(!showPassword)
|
||||
}}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
placeholder="Confirm password"
|
||||
className="pl-10 pr-10"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2"
|
||||
onClick={() => {
|
||||
setShowPassword(false)
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading} size="lg">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating Wallet...
|
||||
</>
|
||||
) : (
|
||||
'Create Wallet'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
|
||||
const renderSeedPhrase = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<div className="grid grid-cols-3 gap-2 text-sm font-mono">
|
||||
{createWalletResponse?.seedphrase.split(' ').map((word, index) => (
|
||||
<div key={index} className="bg-background p-2 rounded border">
|
||||
<span className="text-muted-foreground mr-2">{index + 1}.</span>
|
||||
{word}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Important:</strong> Write down this seed phrase and store it safely. It's the only way to recover your
|
||||
wallet if you lose access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={handleConfirmSeed} className="w-full" size="lg">
|
||||
I have saved my seed phrase
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PreventLeavingPageByMistake />
|
||||
<div className="fs-4">{t('create_wallet.confirm_backup_title')}</div>
|
||||
<p className="text-secondary">{t('create_wallet.confirm_backup_subtitle')}</p>
|
||||
|
||||
<rb.Form noValidate>
|
||||
<MnemonicPhraseInput
|
||||
mnemonicPhrase={givenWords}
|
||||
onChange={(val) => setGivenWords(val)}
|
||||
isValid={(index) => givenWords[index] === seedphrase[index]}
|
||||
isDisabled={(index) => givenWords[index] === seedphrase[index]}
|
||||
/>
|
||||
</rb.Form>
|
||||
{isSeedBackupConfirmed && (
|
||||
<div className="mb-4 text-center text-success">{t('create_wallet.feedback_seed_confirmed')}</div>
|
||||
)}
|
||||
|
||||
<rb.Button
|
||||
className="w-100 mb-4"
|
||||
variant="dark"
|
||||
size="lg"
|
||||
disabled={!isSeedBackupConfirmed}
|
||||
onClick={() => onSuccess()}
|
||||
>
|
||||
{t('create_wallet.confirmation_button_fund_wallet')}
|
||||
</rb.Button>
|
||||
|
||||
<div className="d-flex justify-content-between mb-4 gap-4">
|
||||
<rb.Button variant="none" disabled={isSeedBackupConfirmed} onClick={() => onCancel()}>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<Sprite symbol="arrow-left" width="20" height="20" className="me-2" />
|
||||
{t('create_wallet.back_button')}
|
||||
</div>
|
||||
</rb.Button>
|
||||
|
||||
{showSkipButton && (
|
||||
<rb.Button
|
||||
className="position-relative"
|
||||
variant="outline-dark"
|
||||
disabled={isSeedBackupConfirmed}
|
||||
onClick={() => onSuccess()}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
{t('create_wallet.skip_button')}
|
||||
<Sprite symbol="arrow-right" width="20" height="20" className="ms-2" />
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||
<Wallet className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<span className="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-warning">
|
||||
dev
|
||||
</span>
|
||||
</rb.Button>
|
||||
)}
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
{step === 'create' && 'Create New Wallet'}
|
||||
{step === 'seed' && 'Save Your Seed Phrase'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{step === 'create' && 'Set up a new Joinmarket wallet for CoinJoin privacy'}
|
||||
{step === 'seed' && "This is your wallet's recovery phrase"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{step === 'create' && renderCreateForm()}
|
||||
{step === 'seed' && renderSeedPhrase()}
|
||||
|
||||
{step === 'create' && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already have a wallet?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary hover:text-primary/80 font-medium underline underline-offset-4"
|
||||
>
|
||||
Sign in here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CreateWalletProps {
|
||||
parentRoute: Route
|
||||
startWallet: (name: Api.WalletFileName, auth: Api.ApiAuthContext) => void
|
||||
}
|
||||
|
||||
export default function CreateWallet({ parentRoute, startWallet }: CreateWalletProps) {
|
||||
const { t } = useTranslation()
|
||||
const serviceInfo = useServiceInfo()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [alert, setAlert] = useState<SimpleAlert>()
|
||||
const [createdWallet, setCreatedWallet] = useState<CreatedWalletWithAuth>()
|
||||
|
||||
const isCreated = useMemo(() => !!createdWallet?.walletFileName && !!createdWallet?.auth, [createdWallet])
|
||||
const canCreate = useMemo(
|
||||
() => !isCreated && !serviceInfo?.walletFileName && !serviceInfo?.rescanning,
|
||||
[isCreated, serviceInfo],
|
||||
)
|
||||
|
||||
const createWallet = useCallback(
|
||||
async ({ walletName, password }: CreateWalletFormValues) => {
|
||||
setAlert(undefined)
|
||||
|
||||
try {
|
||||
const res = await Api.postWalletCreate({}, { walletname: walletDisplayNameToFileName(walletName), password })
|
||||
const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
|
||||
|
||||
const { seedphrase, walletname: createdWalletFileName } = body
|
||||
const auth = Api.Helper.parseAuthProps(body)
|
||||
|
||||
setCreatedWallet({ walletFileName: createdWalletFileName, seedphrase, password, auth })
|
||||
} catch (e: any) {
|
||||
const message = t('create_wallet.error_creating_failed', {
|
||||
reason: e.message || t('global.errors.reason_unknown'),
|
||||
})
|
||||
setAlert({ variant: 'danger', message })
|
||||
}
|
||||
},
|
||||
[setAlert, setCreatedWallet, t],
|
||||
)
|
||||
|
||||
const walletConfirmed = useCallback(() => {
|
||||
if (createdWallet) {
|
||||
setAlert(undefined)
|
||||
startWallet(createdWallet.walletFileName, createdWallet.auth)
|
||||
navigate(routes.wallet)
|
||||
} else {
|
||||
setAlert({ variant: 'danger', message: t('create_wallet.alert_confirmation_failed') })
|
||||
}
|
||||
}, [createdWallet, startWallet, navigate, setAlert, t])
|
||||
|
||||
const [showBackupConfirmation, setShowBackupConfirmation] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="create-wallet">
|
||||
{createdWallet ? (
|
||||
<PageTitle
|
||||
title={t('create_wallet.title_wallet_created')}
|
||||
subtitle={t('create_wallet.subtitle_wallet_created')}
|
||||
success
|
||||
/>
|
||||
) : (
|
||||
<PageTitle title={t('create_wallet.title')} />
|
||||
)}
|
||||
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
|
||||
{!canCreate && !isCreated ? (
|
||||
<>
|
||||
{serviceInfo?.walletFileName && (
|
||||
<rb.Alert variant="warning">
|
||||
<Trans
|
||||
i18nKey="create_wallet.alert_other_wallet_unlocked"
|
||||
values={{
|
||||
walletName: walletDisplayName(serviceInfo.walletFileName),
|
||||
}}
|
||||
>
|
||||
Currently <strong>walletName</strong> is active. You need to lock it first.
|
||||
<Link to={routes.walletList} className="alert-link">
|
||||
Go back
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</rb.Alert>
|
||||
)}
|
||||
{serviceInfo?.rescanning === true && (
|
||||
<rb.Alert variant="warning" data-testid="alert-rescanning">
|
||||
<Trans i18nKey="create_wallet.alert_rescan_in_progress">
|
||||
Rescanning the timechain is currently in progress. Please wait until the process finishes and then try
|
||||
again.
|
||||
<Link to={routes.walletList} className="alert-link">
|
||||
Go back
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</rb.Alert>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PreventLeavingPageByMistake />
|
||||
{!serviceInfo?.walletFileName && !createdWallet && (
|
||||
<WalletCreationForm
|
||||
onCancel={() => navigate(routes[parentRoute])}
|
||||
onSubmit={createWallet}
|
||||
submitButtonText={(isSubmitting) =>
|
||||
t(isSubmitting ? 'create_wallet.button_creating' : 'create_wallet.button_create')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{createdWallet &&
|
||||
(!showBackupConfirmation ? (
|
||||
<WalletCreationConfirmation
|
||||
wallet={createdWallet}
|
||||
submitButtonText={(_) => t('create_wallet.next_button')}
|
||||
onSubmit={async () => setShowBackupConfirmation(true)}
|
||||
/>
|
||||
) : (
|
||||
<BackupConfirmation
|
||||
wallet={createdWallet}
|
||||
onSuccess={walletConfirmed}
|
||||
onCancel={() => setShowBackupConfirmation(false)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default CreateWallet
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import Sprite from './Sprite'
|
||||
import PageTitle from './PageTitle'
|
||||
import { routes } from '../constants/routes'
|
||||
|
||||
const DEFAULT_BASIC_AUTH = {
|
||||
user: 'joinmarket',
|
||||
password: 'joinmarket',
|
||||
}
|
||||
|
||||
const LINK_JM_REGTEST_JOINMARKET2 = 'http://localhost:29080'
|
||||
const LINK_JM_REGTEST_JOINMARKET2_AUTH = DEFAULT_BASIC_AUTH
|
||||
const LINK_JM_REGTEST_JOINMARKET3 = 'http://localhost:30080'
|
||||
const LINK_JM_REGTEST_EXPLORER = 'http://localhost:3002'
|
||||
const LINK_JM_REGTEST_EXPLORER_AUTH = DEFAULT_BASIC_AUTH
|
||||
const LINK_JM_REGTEST_RPC_TERMINAL = `${LINK_JM_REGTEST_EXPLORER}/rpc-terminal`
|
||||
|
||||
export default function DevSetupPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageTitle title="Development setup" subtitle="" />
|
||||
<div className="d-flex flex-column gap-3">
|
||||
<div className="mb-4">
|
||||
<h5>Test Wallet</h5>
|
||||
<div className="ms-3 my-2">
|
||||
Name: <span className="font-monospace">Satoshi</span>
|
||||
<br />
|
||||
Password: <span className="font-monospace">test</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-column gap-3">
|
||||
<div className="mb-4">
|
||||
<h5>Links</h5>
|
||||
<div className="my-2">
|
||||
<Link className="link-dark" to={routes.__errorExample}>
|
||||
Error Example Page
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Jam Instances</h5>
|
||||
<div>
|
||||
<div className="d-flex align-items-center">
|
||||
<Sprite symbol="logo" width="24" height="24" className="me-2" />
|
||||
<a href={LINK_JM_REGTEST_JOINMARKET2} target="_blank" rel="noopener noreferrer" className="link-dark">
|
||||
jm_regtest_joinmarket2 ({LINK_JM_REGTEST_JOINMARKET2})
|
||||
</a>
|
||||
<span className="badge rounded-pill bg-primary ms-2">secondary</span>
|
||||
</div>
|
||||
|
||||
<div className="ms-5 my-2">
|
||||
Basic Authentication
|
||||
<br />
|
||||
<small>
|
||||
User: <span className="font-monospace">{LINK_JM_REGTEST_JOINMARKET2_AUTH.user}</span>
|
||||
<br />
|
||||
Password: <span className="font-monospace">{LINK_JM_REGTEST_JOINMARKET2_AUTH.password}</span>
|
||||
</small>
|
||||
</div>
|
||||
<div className="d-flex align-items-center">
|
||||
<Sprite symbol="logo" width="24" height="24" className="me-2" />
|
||||
<a href={LINK_JM_REGTEST_JOINMARKET3} target="_blank" rel="noopener noreferrer" className="link-dark">
|
||||
jm_regtest_joinmarket3 ({LINK_JM_REGTEST_JOINMARKET3})
|
||||
</a>
|
||||
<span className="badge rounded-pill bg-success ms-2">tertiary</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Block Explorer</h5>
|
||||
<div>
|
||||
{' '}
|
||||
<div className="d-flex align-items-center">
|
||||
<Sprite symbol="block" width="24" height="24" className="me-2" />
|
||||
|
||||
<a href={LINK_JM_REGTEST_EXPLORER} target="_blank" rel="noopener noreferrer" className="link-dark">
|
||||
jm_regtest_explorer ({LINK_JM_REGTEST_EXPLORER})
|
||||
</a>
|
||||
</div>
|
||||
<div className="ms-5 my-2">
|
||||
Basic Authentication
|
||||
<br />
|
||||
<small>
|
||||
User: <span className="font-monospace">{LINK_JM_REGTEST_EXPLORER_AUTH.user}</span>
|
||||
<br />
|
||||
Password: <span className="font-monospace">{LINK_JM_REGTEST_EXPLORER_AUTH.password}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="d-flex align-items-center">
|
||||
<Sprite symbol="console" width="24" height="24" className="me-2" />
|
||||
|
||||
<a href={LINK_JM_REGTEST_RPC_TERMINAL} target="_blank" rel="noopener noreferrer" className="link-dark">
|
||||
Regtest RPC Terminal ({LINK_JM_REGTEST_RPC_TERMINAL})
|
||||
</a>
|
||||
</div>
|
||||
<div className="ms-5 my-2">
|
||||
Mine a block, e.g.:
|
||||
<pre>generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/components/DisplayLogo.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
type DisplayLogoProps = {
|
||||
displayMode: 'sats' | 'btc'
|
||||
size?: 'sm' | 'lg'
|
||||
}
|
||||
|
||||
export function DisplayLogo({ displayMode, size = 'lg' }: DisplayLogoProps) {
|
||||
if (displayMode === 'btc') {
|
||||
return <span className={size === 'sm' ? 'text-lg ml-1' : 'text-4xl ml-1'}>₿</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size === 'sm' ? '18px' : '40px'}
|
||||
height={size === 'sm' ? '18px' : '40px'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{
|
||||
display: 'inline',
|
||||
verticalAlign: 'middle',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
<path d="M7 7.90906H17" stroke="currentColor" />
|
||||
<path d="M12 5.45454V3" stroke="currentColor" />
|
||||
<path d="M12 20.9999V18.5454" stroke="currentColor" />
|
||||
<path d="M7 12H17" stroke="currentColor" />
|
||||
<path d="M7 16.0909H17" stroke="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
.dividerContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dividerContainer .dividerLine {
|
||||
margin: 0;
|
||||
width: 50%;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.dividerContainer .dividerButton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 1rem;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
color: var(--bs-body-color);
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--bs-body-color);
|
||||
border-radius: 50%;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.dividerContainer .dividerButton:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import * as rb from 'react-bootstrap'
|
||||
import classNames from 'classnames'
|
||||
import Sprite from './Sprite'
|
||||
import styles from './Divider.module.css'
|
||||
|
||||
type DividerProps = rb.ColProps & {
|
||||
toggled: boolean
|
||||
onToggle: (current: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Divider({ toggled, onToggle, disabled, className, ...colProps }: DividerProps) {
|
||||
return (
|
||||
<rb.Row className={classNames('d-flex', 'justify-content-center', className)}>
|
||||
<rb.Col xs={12} {...colProps}>
|
||||
<div className={styles.dividerContainer}>
|
||||
<hr className={styles.dividerLine} />
|
||||
<button className={styles.dividerButton} disabled={disabled} onClick={() => onToggle(toggled)}>
|
||||
<Sprite symbol={toggled ? 'caret-up' : 'caret-down'} width="20" height="20" />
|
||||
</button>
|
||||
<hr className={styles.dividerLine} />
|
||||
</div>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
.earn .input-loader {
|
||||
height: 3.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.earn .fidelityBondsLoader {
|
||||
height: 11rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.earn form input:not([type='checkbox']) {
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
.inputGroupText {
|
||||
width: 5ch;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.offerLoader {
|
||||
height: 10rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.offerContainer {
|
||||
border: 1px solid var(--bs-gray-200);
|
||||
border-radius: 0.3rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .offerContainer {
|
||||
border-color: var(--bs-gray-700);
|
||||
}
|
||||
|
||||
.offerContainer .offerTitle {
|
||||
width: 100%;
|
||||
font-size: 1.2rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.offerContainer .offerLabel {
|
||||
color: var(--bs-gray-600);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.offerContainer .offerContent {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
|
@ -1,841 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Formik, FormikErrors } from 'formik'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TFunction } from 'i18next'
|
||||
import { useSettings } from '../context/SettingsContext'
|
||||
import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo, WalletInfo } from '../context/WalletContext'
|
||||
import { useServiceInfo, useReloadServiceInfo, Offer } from '../context/ServiceInfoContext'
|
||||
import {
|
||||
calcOfferMinsizeMax,
|
||||
factorToPercentage,
|
||||
isAbsoluteOffer,
|
||||
isRelativeOffer,
|
||||
isValidNumber,
|
||||
percentageToFactor,
|
||||
} from '../utils'
|
||||
import {
|
||||
OFFER_FEE_ABS_MIN,
|
||||
OFFER_FEE_REL_MAX,
|
||||
OFFER_FEE_REL_MIN,
|
||||
OFFER_FEE_REL_STEP,
|
||||
OFFER_MINSIZE_MIN,
|
||||
} from '../constants/jam'
|
||||
import * as Api from '../libs/JmWalletApi'
|
||||
import * as fb from './fb/utils'
|
||||
import Sprite from './Sprite'
|
||||
import PageTitle from './PageTitle'
|
||||
import SegmentedTabs from './SegmentedTabs'
|
||||
import { CreateFidelityBond } from './fb/CreateFidelityBond'
|
||||
import { ExistingFidelityBond } from './fb/ExistingFidelityBond'
|
||||
import { RenewFidelityBondModal, SpendFidelityBondModal } from './fb/SpendFidelityBondModal'
|
||||
import { EarnReportOverlay } from './EarnReport'
|
||||
import { OrderbookOverlay } from './Orderbook'
|
||||
import Balance from './Balance'
|
||||
import Accordion from './Accordion'
|
||||
import BitcoinAmountInput, { AmountValue, toAmountValue } from './BitcoinAmountInput'
|
||||
import { isValidAmount } from './Send/helpers'
|
||||
import styles from './Earn.module.css'
|
||||
|
||||
// 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_MS = 2_000
|
||||
|
||||
// When reloading UTXO after creating a fidelity bond, use a delay to make sure
|
||||
// that the UTXO corresponding to the fidelity bond is correctly marked as such.
|
||||
const RELOAD_FIDELITY_BONDS_DELAY_MS = 2_000
|
||||
|
||||
const OFFERTYPE_REL: Api.OfferType = 'sw0reloffer'
|
||||
const OFFERTYPE_ABS: Api.OfferType = 'sw0absoffer'
|
||||
|
||||
const FORM_INPUT_LOCAL_STORAGE_KEYS = {
|
||||
offertype: 'jm-offertype',
|
||||
feeRel: 'jm-feeRel',
|
||||
feeAbs: 'jm-feeAbs',
|
||||
minsize: 'jm-minsize',
|
||||
}
|
||||
|
||||
export interface EarnFormValues {
|
||||
offertype: Api.OfferType
|
||||
feeRel: number
|
||||
feeAbs?: AmountValue
|
||||
minsize?: AmountValue
|
||||
}
|
||||
|
||||
const FORM_INPUT_DEFAULT_VALUES: Required<EarnFormValues> = {
|
||||
offertype: OFFERTYPE_REL,
|
||||
feeRel: 0.000_3,
|
||||
feeAbs: toAmountValue(250),
|
||||
minsize: toAmountValue(100_000),
|
||||
}
|
||||
|
||||
const persistFormValues = (values: EarnFormValues) => {
|
||||
window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.offertype, values.offertype)
|
||||
|
||||
if (values.minsize) {
|
||||
window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.minsize, String(values.minsize.value))
|
||||
}
|
||||
|
||||
if (isRelativeOffer(values.offertype)) {
|
||||
window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeRel, String(values.feeRel))
|
||||
}
|
||||
if (isAbsoluteOffer(values.offertype) && values.feeAbs) {
|
||||
window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeAbs, String(values.feeAbs.value))
|
||||
}
|
||||
}
|
||||
|
||||
const initialFormValues = (): EarnFormValues => {
|
||||
const feeRel = parseFloat(
|
||||
window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeRel) ?? String(FORM_INPUT_DEFAULT_VALUES.feeRel),
|
||||
)
|
||||
const feeAbs = parseInt(
|
||||
window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeAbs) ?? String(FORM_INPUT_DEFAULT_VALUES.feeAbs),
|
||||
10,
|
||||
)
|
||||
const minsize = parseInt(
|
||||
window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.minsize) ??
|
||||
String(FORM_INPUT_DEFAULT_VALUES.minsize.value),
|
||||
10,
|
||||
)
|
||||
const offertype =
|
||||
window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.offertype) ?? FORM_INPUT_DEFAULT_VALUES.offertype
|
||||
|
||||
return {
|
||||
offertype,
|
||||
feeRel: isValidNumber(feeRel) ? feeRel : FORM_INPUT_DEFAULT_VALUES.feeRel,
|
||||
feeAbs: toAmountValue(isValidNumber(feeAbs) ? feeAbs : FORM_INPUT_DEFAULT_VALUES.feeAbs.value!),
|
||||
minsize: toAmountValue(isValidNumber(minsize) ? minsize : FORM_INPUT_DEFAULT_VALUES.minsize.value!),
|
||||
}
|
||||
}
|
||||
|
||||
const renderOfferType = (offer: Offer, t: TFunction) => {
|
||||
if (isAbsoluteOffer(offer.ordertype)) {
|
||||
return <rb.Badge bg="info">{t('earn.current.text_offer_type_absolute')}</rb.Badge>
|
||||
}
|
||||
if (isRelativeOffer(offer.ordertype)) {
|
||||
return <rb.Badge bg="primary">{t('earn.current.text_offer_type_relative')}</rb.Badge>
|
||||
}
|
||||
return <rb.Badge bg="secondary">{offer.ordertype}</rb.Badge>
|
||||
}
|
||||
|
||||
interface CurrentOfferProps {
|
||||
offer: Offer
|
||||
nickname: string
|
||||
}
|
||||
|
||||
function CurrentOffer({ offer, nickname }: CurrentOfferProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
|
||||
return (
|
||||
<div className={styles.offerContainer}>
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div className="d-flex flex-column">
|
||||
<div className={styles.offerLabel}>{t('earn.current.text_offer')}</div>
|
||||
<div className={`${styles.offerTitle} slashed-zeroes`}>
|
||||
{nickname}:{offer.oid}
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">{renderOfferType(offer, t)}</div>
|
||||
</div>
|
||||
<rb.Container className="mt-2">
|
||||
<rb.Row className="mb-1">
|
||||
<rb.Col xs={6}>
|
||||
<div className="d-flex flex-column">
|
||||
<div className={styles.offerLabel}>{t('earn.current.text_cjfee')}</div>
|
||||
<div>
|
||||
{isRelativeOffer(offer.ordertype) ? (
|
||||
<>{factorToPercentage(parseFloat(offer.cjfee) || 0)}%</>
|
||||
) : (
|
||||
<>
|
||||
<Balance
|
||||
valueString={String(offer.cjfee)}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={settings.showBalance}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</rb.Col>
|
||||
|
||||
<rb.Col xs={6}>
|
||||
<div className="d-flex flex-column">
|
||||
<div className={styles.offerLabel}>{t('earn.current.text_minsize')}</div>
|
||||
<div>
|
||||
<Balance
|
||||
valueString={String(offer.minsize)}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={settings.showBalance}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
|
||||
<rb.Row>
|
||||
<rb.Col xs={6}>
|
||||
<div className="d-flex flex-column">
|
||||
<div className={styles.offerLabel}>{t('earn.current.text_txfee')}</div>
|
||||
<div>
|
||||
<Balance
|
||||
valueString={String(offer.txfee)}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={settings.showBalance}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</rb.Col>
|
||||
|
||||
<rb.Col xs={6}>
|
||||
<div className="d-flex flex-column">
|
||||
<div className={styles.offerLabel}>{t('earn.current.text_maxsize')}</div>
|
||||
<div>
|
||||
<Balance
|
||||
valueString={String(offer.maxsize)}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={settings.showBalance}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
</rb.Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EarnFormProps {
|
||||
initialValues?: EarnFormValues
|
||||
submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
|
||||
onSubmit: (values: EarnFormValues) => Promise<void>
|
||||
isLoading: boolean
|
||||
disabled?: boolean
|
||||
walletInfo?: WalletInfo
|
||||
}
|
||||
|
||||
const EarnForm = ({
|
||||
initialValues = FORM_INPUT_DEFAULT_VALUES,
|
||||
submitButtonText,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
walletInfo,
|
||||
}: EarnFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const offerMinsizeMax = useMemo(() => {
|
||||
return walletInfo === undefined ? 0 : calcOfferMinsizeMax(walletInfo.balanceSummary.accountBalances)
|
||||
}, [walletInfo])
|
||||
|
||||
const validate = (values: EarnFormValues) => {
|
||||
const errors = {} as FormikErrors<EarnFormValues>
|
||||
const isRelOffer = isRelativeOffer(values.offertype)
|
||||
const isAbsOffer = isAbsoluteOffer(values.offertype)
|
||||
|
||||
if (!isRelOffer && !isAbsOffer) {
|
||||
// currently no need for translation, this should never occur -> input is controlled by toggle
|
||||
errors.offertype = 'Offertype is not supported'
|
||||
}
|
||||
|
||||
if (isRelOffer) {
|
||||
if (!isValidNumber(values.feeRel) || values.feeRel < OFFER_FEE_REL_MIN || values.feeRel > OFFER_FEE_REL_MAX) {
|
||||
errors.feeRel = t('earn.feedback_invalid_rel_fee', {
|
||||
feeRelPercentageMin: `${factorToPercentage(OFFER_FEE_REL_MIN)}%`,
|
||||
feeRelPercentageMax: `${factorToPercentage(OFFER_FEE_REL_MAX)}%`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isAbsOffer) {
|
||||
if (!isValidNumber(values.feeAbs?.value) || values.feeAbs!.value! < OFFER_FEE_ABS_MIN) {
|
||||
errors.feeAbs = t('earn.feedback_invalid_abs_fee')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidAmount(values.minsize?.value ?? null, false)) {
|
||||
errors.minsize = t('earn.feedback_invalid_min_amount')
|
||||
} else {
|
||||
const minsize = values.minsize?.value || 0
|
||||
if (OFFER_MINSIZE_MIN > offerMinsizeMax) {
|
||||
errors.minsize = t('earn.feedback_invalid_min_amount_insufficient_funds')
|
||||
} else if (minsize < OFFER_MINSIZE_MIN || minsize > offerMinsizeMax) {
|
||||
errors.minsize = t('earn.feedback_invalid_min_amount_range', {
|
||||
minAmountMin: OFFER_MINSIZE_MIN.toLocaleString(),
|
||||
minAmountMax: offerMinsizeMax.toLocaleString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit}>
|
||||
{(props) => {
|
||||
const { handleSubmit, setFieldValue, handleBlur, values, touched, errors, isSubmitting } = props
|
||||
const minsizeField = props.getFieldProps<AmountValue>('minsize')
|
||||
const feeAbsField = props.getFieldProps<AmountValue>('feeAbs')
|
||||
return (
|
||||
<>
|
||||
<rb.Form onSubmit={handleSubmit} noValidate>
|
||||
<Accordion title={t('earn.button_settings')} variant={!props.isValid ? 'danger' : undefined}>
|
||||
<>
|
||||
<rb.Form.Group className="mb-4 d-flex justify-content-center" controlId="offertype">
|
||||
<SegmentedTabs
|
||||
name="offertype"
|
||||
tabs={[
|
||||
{
|
||||
label: t('earn.radio_abs_offer_label'),
|
||||
value: OFFERTYPE_ABS,
|
||||
},
|
||||
{
|
||||
label: t('earn.radio_rel_offer_label'),
|
||||
value: OFFERTYPE_REL,
|
||||
},
|
||||
]}
|
||||
value={values.offertype}
|
||||
onChange={(tab) => {
|
||||
setFieldValue('offertype', tab.value, true)
|
||||
}}
|
||||
disabled={isLoading || isSubmitting}
|
||||
/>
|
||||
</rb.Form.Group>
|
||||
{values.offertype === OFFERTYPE_REL ? (
|
||||
<rb.Form.Group className="mb-3" controlId="feeRel">
|
||||
<rb.Form.Label className="mb-0">
|
||||
{t('earn.label_rel_fee', {
|
||||
fee: typeof values.feeRel === 'number' ? `(${factorToPercentage(values.feeRel)}%)` : '',
|
||||
})}
|
||||
</rb.Form.Label>
|
||||
<rb.Form.Text className="d-block text-secondary mb-2">
|
||||
{t('earn.description_rel_fee')}
|
||||
</rb.Form.Text>
|
||||
{isLoading ? (
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder xs={12} className={styles['input-loader']} />
|
||||
</rb.Placeholder>
|
||||
) : (
|
||||
<rb.InputGroup hasValidation>
|
||||
<rb.InputGroup.Text id="feeRel-addon1" className={styles.inputGroupText}>
|
||||
%
|
||||
</rb.InputGroup.Text>
|
||||
<rb.Form.Control
|
||||
aria-label={t('earn.label_rel_fee', { fee: '' })}
|
||||
className="slashed-zeroes"
|
||||
type="number"
|
||||
name="feeRel"
|
||||
disabled={isSubmitting}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value || ''
|
||||
setFieldValue('feeRel', value !== '' ? percentageToFactor(parseFloat(value)) : '', true)
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
value={typeof values.feeRel === 'number' ? factorToPercentage(values.feeRel) : ''}
|
||||
isValid={touched.feeRel && !errors.feeRel}
|
||||
isInvalid={touched.feeRel && !!errors.feeRel}
|
||||
min={factorToPercentage(OFFER_FEE_REL_MIN)}
|
||||
step={factorToPercentage(OFFER_FEE_REL_STEP)}
|
||||
/>
|
||||
<rb.Form.Control.Feedback type="invalid">{errors.feeRel}</rb.Form.Control.Feedback>
|
||||
</rb.InputGroup>
|
||||
)}
|
||||
</rb.Form.Group>
|
||||
) : (
|
||||
<rb.Form.Group className="mb-3" controlId="feeAbs">
|
||||
<rb.Form.Label className="mb-0">
|
||||
{t('earn.label_abs_fee', {
|
||||
fee: '', // empty on purpose
|
||||
})}
|
||||
</rb.Form.Label>
|
||||
<rb.Form.Text className="d-block text-secondary mb-2">
|
||||
{t('earn.description_abs_fee')}
|
||||
</rb.Form.Text>
|
||||
{isLoading ? (
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder xs={12} className={styles['input-loader']} />
|
||||
</rb.Placeholder>
|
||||
) : (
|
||||
<div className={touched.feeAbs && !!errors.feeAbs ? 'is-invalid' : ''}>
|
||||
<BitcoinAmountInput
|
||||
inputGroupTextClassName={styles.inputGroupText}
|
||||
label={t('earn.label_abs_fee')}
|
||||
placeholder={''}
|
||||
field={feeAbsField}
|
||||
form={props}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</rb.Form.Group>
|
||||
)}
|
||||
|
||||
<rb.Form.Group className="mb-4" controlId="minsize">
|
||||
<rb.Form.Label>{t('earn.label_min_amount_input')}</rb.Form.Label>
|
||||
{isLoading ? (
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder xs={12} className={styles['input-loader']} />
|
||||
</rb.Placeholder>
|
||||
) : (
|
||||
<div className={touched.minsize && !!errors.minsize ? 'is-invalid' : ''}>
|
||||
<BitcoinAmountInput
|
||||
inputGroupTextClassName={styles.inputGroupText}
|
||||
label={t('earn.label_min_amount_input')}
|
||||
placeholder={t('earn.placeholder_min_amount_input')}
|
||||
field={minsizeField}
|
||||
form={props}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</rb.Form.Group>
|
||||
</>
|
||||
</Accordion>
|
||||
<rb.Button
|
||||
className="w-100 mb-4"
|
||||
variant="dark"
|
||||
size="lg"
|
||||
type="submit"
|
||||
disabled={isLoading || isSubmitting || disabled}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">{submitButtonText(isSubmitting)}</div>
|
||||
</rb.Button>
|
||||
</rb.Form>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
|
||||
const toStartMakerRequest = (values: EarnFormValues): Api.StartMakerRequest => {
|
||||
// both fee properties need to be provided.
|
||||
// prevent providing an invalid value by setting the ignored prop to zero
|
||||
const cjfee_a = isAbsoluteOffer(values.offertype) ? values.feeAbs!.value! : 0
|
||||
const cjfee_r = isRelativeOffer(values.offertype) ? values.feeRel : 0
|
||||
return {
|
||||
ordertype: values.offertype,
|
||||
minsize: values.minsize!.value!,
|
||||
cjfee_a,
|
||||
cjfee_r,
|
||||
}
|
||||
}
|
||||
|
||||
interface EarnProps {
|
||||
wallet: CurrentWallet
|
||||
}
|
||||
|
||||
export default function Earn({ wallet }: EarnProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
const currentWalletInfo = useCurrentWalletInfo()
|
||||
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
|
||||
const serviceInfo = useServiceInfo()
|
||||
const reloadServiceInfo = useReloadServiceInfo()
|
||||
|
||||
const [alert, setAlert] = useState<SimpleAlert>()
|
||||
const [serviceInfoAlert, setServiceInfoAlert] = useState<SimpleAlert>()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isWaitingMakerStart, setIsWaitingMakerStart] = useState(false)
|
||||
const [isWaitingMakerStop, setIsWaitingMakerStop] = useState(false)
|
||||
const [isShowReport, setIsShowReport] = useState(false)
|
||||
const [isShowOrderbook, setIsShowOrderbook] = useState(false)
|
||||
|
||||
const [initialValues, setInitialValues] = useState(initialFormValues())
|
||||
|
||||
const fidelityBonds = useMemo(() => {
|
||||
return currentWalletInfo?.fidelityBondSummary.fbOutputs || []
|
||||
}, [currentWalletInfo])
|
||||
|
||||
const [moveToJarFidelityBondId, setMoveToJarFidelityBondId] = useState<Api.UtxoId>()
|
||||
const [renewFidelityBondId, setRenewFidelityBondId] = useState<Api.UtxoId>()
|
||||
|
||||
const isSufficientFundsAvailable = useMemo(
|
||||
() => (currentWalletInfo?.balanceSummary.calculatedAvailableBalanceInSats ?? 0) > 0,
|
||||
[currentWalletInfo],
|
||||
)
|
||||
|
||||
const isOperationDisabled = useMemo(() => {
|
||||
return !isSufficientFundsAvailable || serviceInfo?.rescanning === true || isWaitingMakerStart || isWaitingMakerStop
|
||||
}, [isSufficientFundsAvailable, serviceInfo, isWaitingMakerStart, isWaitingMakerStop])
|
||||
|
||||
const startMakerService = useCallback(
|
||||
(values: EarnFormValues) => {
|
||||
setIsSending(true)
|
||||
setIsWaitingMakerStart(true)
|
||||
|
||||
// There is no response data to check if maker got started:
|
||||
// Wait for the websocket or session response!
|
||||
return (
|
||||
Api.postMakerStart({ ...wallet }, toStartMakerRequest(values))
|
||||
.then((res) => (res.ok ? true : Api.Helper.throwError(res)))
|
||||
// show the loader a little longer to avoid flickering
|
||||
.then((result) => new Promise((r) => setTimeout(() => r(result), 200)))
|
||||
.catch((e) => {
|
||||
setIsWaitingMakerStart(false)
|
||||
throw e
|
||||
})
|
||||
.finally(() => setIsSending(false))
|
||||
)
|
||||
},
|
||||
[wallet],
|
||||
)
|
||||
|
||||
const stopMakerService = useCallback(() => {
|
||||
setIsSending(true)
|
||||
setIsWaitingMakerStop(true)
|
||||
|
||||
// There is no response data to check if maker got stopped:
|
||||
// Wait for the websocket or session response!
|
||||
return Api.getMakerStop({ ...wallet })
|
||||
.then((res) => (res.ok ? true : Api.Helper.throwError(res)))
|
||||
.then((result) => new Promise((r) => setTimeout(() => r(result), MAKER_STOP_RESPONSE_DELAY_MS)))
|
||||
.catch((e) => {
|
||||
setIsWaitingMakerStop(false)
|
||||
throw e
|
||||
})
|
||||
.finally(() => setIsSending(false))
|
||||
}, [wallet])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSending) return
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
const reloadingServiceInfo = reloadServiceInfo({ signal: abortCtrl.signal })
|
||||
const reloadingCurrentWalletInfo = reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal })
|
||||
|
||||
Promise.all([reloadingServiceInfo, reloadingCurrentWalletInfo])
|
||||
.catch((err) => {
|
||||
!abortCtrl.signal.aborted && setAlert({ variant: 'danger', message: err.message })
|
||||
})
|
||||
.finally(() => !abortCtrl.signal.aborted && setIsLoading(false))
|
||||
|
||||
return () => abortCtrl.abort()
|
||||
}, [isSending, reloadServiceInfo, reloadCurrentWalletInfo])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSending) return
|
||||
|
||||
const makerRunning = serviceInfo?.makerRunning === true
|
||||
|
||||
const waitingForMakerToStart = isWaitingMakerStart && !makerRunning
|
||||
setIsWaitingMakerStart(waitingForMakerToStart)
|
||||
|
||||
const waitingForMakerToStop = isWaitingMakerStop && makerRunning
|
||||
setIsWaitingMakerStop(waitingForMakerToStop)
|
||||
|
||||
const waiting = waitingForMakerToStart || waitingForMakerToStop
|
||||
|
||||
setServiceInfoAlert((current) => {
|
||||
if (!waiting && makerRunning) {
|
||||
return { variant: 'success', message: t('earn.alert_running') }
|
||||
} else if (!waiting) {
|
||||
return undefined
|
||||
}
|
||||
return current
|
||||
})
|
||||
}, [isSending, serviceInfo, isWaitingMakerStart, isWaitingMakerStop, t])
|
||||
|
||||
const reloadFidelityBonds = useCallback(
|
||||
({ delay }: { delay: number }) => {
|
||||
const abortCtrl = new AbortController()
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
new Promise((resolve) => {
|
||||
setTimeout(async () => {
|
||||
resolve(await reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }))
|
||||
}, delay)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (abortCtrl.signal.aborted) return
|
||||
setAlert({ variant: 'danger', message: err.message || t('global.errors.reason_unknown') })
|
||||
})
|
||||
.finally(() => {
|
||||
if (abortCtrl.signal.aborted) return
|
||||
setIsLoading(false)
|
||||
})
|
||||
},
|
||||
[reloadCurrentWalletInfo, t],
|
||||
)
|
||||
|
||||
const onSubmitStart = useCallback(
|
||||
async (values: EarnFormValues) => {
|
||||
if (isLoading || isSending || isWaitingMakerStart || isWaitingMakerStop) {
|
||||
return
|
||||
}
|
||||
|
||||
setAlert(undefined)
|
||||
|
||||
try {
|
||||
persistFormValues(values)
|
||||
|
||||
setServiceInfoAlert({ variant: 'success', message: t('earn.alert_starting') })
|
||||
|
||||
await startMakerService(values)
|
||||
|
||||
setInitialValues(initialFormValues())
|
||||
} catch (e: any) {
|
||||
setServiceInfoAlert(undefined)
|
||||
setAlert({ variant: 'danger', message: e.message || t('global.errors.reason_unknown') })
|
||||
}
|
||||
},
|
||||
[startMakerService, isLoading, isSending, isWaitingMakerStart, isWaitingMakerStop, t],
|
||||
)
|
||||
|
||||
const onSubmitStop = useCallback(async () => {
|
||||
if (isLoading || isSending || isWaitingMakerStart || isWaitingMakerStop) {
|
||||
return
|
||||
}
|
||||
|
||||
setAlert(undefined)
|
||||
|
||||
try {
|
||||
setServiceInfoAlert({ variant: 'success', message: t('earn.alert_stopping') })
|
||||
await stopMakerService()
|
||||
} catch (e: any) {
|
||||
setServiceInfoAlert(undefined)
|
||||
setAlert({ variant: 'danger', message: e.message || t('global.errors.reason_unknown') })
|
||||
}
|
||||
}, [stopMakerService, isLoading, isSending, isWaitingMakerStart, isWaitingMakerStop, t])
|
||||
|
||||
return (
|
||||
<div className={styles.earn}>
|
||||
<PageTitle title={t('earn.title')} subtitle={t('earn.subtitle')} />
|
||||
|
||||
<rb.Row className="mb-2">
|
||||
<rb.Col>
|
||||
<rb.Fade in={serviceInfo?.coinjoinInProgress} mountOnEnter={true} unmountOnExit={true}>
|
||||
<rb.Alert variant="info" className="mb-4">
|
||||
{t('earn.alert_coinjoin_in_progress')}
|
||||
</rb.Alert>
|
||||
</rb.Fade>
|
||||
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
|
||||
{serviceInfoAlert && <rb.Alert variant={serviceInfoAlert.variant}>{serviceInfoAlert.message}</rb.Alert>}
|
||||
{!serviceInfo?.coinjoinInProgress &&
|
||||
!serviceInfo?.makerRunning &&
|
||||
!isWaitingMakerStart &&
|
||||
!isWaitingMakerStop && <p className="text-secondary mb-4">{t('earn.market_explainer')}</p>}
|
||||
{serviceInfo?.makerRunning &&
|
||||
(serviceInfo?.offers && serviceInfo?.nickname ? (
|
||||
<>
|
||||
{serviceInfo.offers.map((offer, index) => (
|
||||
<CurrentOffer key={index} offer={offer} nickname={serviceInfo.nickname || '-'} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder xs={12} className={styles.offerLoader} />
|
||||
</rb.Placeholder>
|
||||
))}
|
||||
{!serviceInfo?.coinjoinInProgress && (
|
||||
<>
|
||||
<PageTitle
|
||||
title={t('earn.title_fidelity_bonds', { count: fidelityBonds.length })}
|
||||
subtitle={t('earn.subtitle_fidelity_bonds')}
|
||||
/>
|
||||
<div className="d-flex flex-column gap-3 mb-4">
|
||||
{currentWalletInfo && moveToJarFidelityBondId && (
|
||||
<SpendFidelityBondModal
|
||||
show={true}
|
||||
fidelityBondId={moveToJarFidelityBondId}
|
||||
wallet={wallet}
|
||||
walletInfo={currentWalletInfo}
|
||||
destinationJarIndex={0}
|
||||
onClose={({ mustReload }) => {
|
||||
setMoveToJarFidelityBondId(undefined)
|
||||
if (mustReload) {
|
||||
reloadFidelityBonds({ delay: 0 })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentWalletInfo && renewFidelityBondId && (
|
||||
<RenewFidelityBondModal
|
||||
show={true}
|
||||
fidelityBondId={renewFidelityBondId}
|
||||
wallet={wallet}
|
||||
walletInfo={currentWalletInfo}
|
||||
onClose={({ mustReload }) => {
|
||||
setRenewFidelityBondId(undefined)
|
||||
if (mustReload) {
|
||||
reloadFidelityBonds({ delay: 0 })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{fidelityBonds.map((fidelityBond, index) => {
|
||||
const isExpired = !fb.utxo.isLocked(fidelityBond)
|
||||
const actionsEnabled =
|
||||
isExpired &&
|
||||
serviceInfo &&
|
||||
!serviceInfo.coinjoinInProgress &&
|
||||
!serviceInfo.makerRunning &&
|
||||
!serviceInfo.rescanning &&
|
||||
!isWaitingMakerStart &&
|
||||
!isWaitingMakerStop &&
|
||||
!isLoading
|
||||
return (
|
||||
<ExistingFidelityBond key={index} fidelityBond={fidelityBond}>
|
||||
{actionsEnabled && (
|
||||
<div className="mt-4 d-flex gap-2">
|
||||
<rb.Button
|
||||
variant={settings.theme === 'dark' ? 'light' : 'dark'}
|
||||
className="w-100 d-flex justify-content-center align-items-center"
|
||||
disabled={moveToJarFidelityBondId !== undefined}
|
||||
onClick={() => setMoveToJarFidelityBondId(fidelityBond.utxo)}
|
||||
>
|
||||
<Sprite className="me-1 mb-1" symbol="unlock" width="24" height="24" />
|
||||
{t('earn.fidelity_bond.existing.button_spend')}
|
||||
</rb.Button>
|
||||
<rb.Button
|
||||
variant={settings.theme === 'dark' ? 'light' : 'dark'}
|
||||
className="w-100 d-flex justify-content-center align-items-center"
|
||||
disabled={renewFidelityBondId !== undefined}
|
||||
onClick={() => setRenewFidelityBondId(fidelityBond.utxo)}
|
||||
>
|
||||
<Sprite className="me-1" symbol="refresh" width="24" height="24" />
|
||||
{t('earn.fidelity_bond.existing.button_renew')}
|
||||
</rb.Button>
|
||||
</div>
|
||||
)}
|
||||
</ExistingFidelityBond>
|
||||
)
|
||||
})}
|
||||
<>
|
||||
{!serviceInfo?.makerRunning &&
|
||||
!serviceInfo?.coinjoinInProgress &&
|
||||
!serviceInfo?.rescanning &&
|
||||
!isWaitingMakerStart &&
|
||||
!isWaitingMakerStop &&
|
||||
(!isLoading && currentWalletInfo ? (
|
||||
<CreateFidelityBond
|
||||
otherFidelityBondExists={fidelityBonds.length > 0}
|
||||
wallet={wallet}
|
||||
walletInfo={currentWalletInfo}
|
||||
onDone={() => reloadFidelityBonds({ delay: RELOAD_FIDELITY_BONDS_DELAY_MS })}
|
||||
/>
|
||||
) : (
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder xs={12} className={styles.fidelityBondsLoader} />
|
||||
</rb.Placeholder>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!serviceInfo?.coinjoinInProgress && (
|
||||
<>
|
||||
{!serviceInfo?.makerRunning && !isWaitingMakerStart && !isWaitingMakerStop ? (
|
||||
<EarnForm
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmitStart}
|
||||
walletInfo={currentWalletInfo}
|
||||
isLoading={isLoading}
|
||||
disabled={isOperationDisabled}
|
||||
submitButtonText={(_) => {
|
||||
return (
|
||||
<>
|
||||
{isWaitingMakerStart || isWaitingMakerStop ? (
|
||||
<>
|
||||
<rb.Spinner
|
||||
as="span"
|
||||
animation="border"
|
||||
size="sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
className="me-2"
|
||||
/>
|
||||
{isWaitingMakerStart && t('earn.text_starting')}
|
||||
{isWaitingMakerStop && t('earn.text_stopping')}
|
||||
</>
|
||||
) : (
|
||||
<>{serviceInfo?.makerRunning === true ? t('earn.button_stop') : t('earn.button_start')}</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Formik initialValues={{}} onSubmit={onSubmitStop}>
|
||||
{({ handleSubmit, isSubmitting }) => (
|
||||
<rb.Form onSubmit={handleSubmit} noValidate>
|
||||
<rb.Button
|
||||
className="w-100 mb-4"
|
||||
variant="dark"
|
||||
size="lg"
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading ||
|
||||
serviceInfo?.makerRunning !== true ||
|
||||
serviceInfo?.rescanning === true ||
|
||||
isSubmitting ||
|
||||
isWaitingMakerStart ||
|
||||
isWaitingMakerStop
|
||||
}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
{isWaitingMakerStart || isWaitingMakerStop ? (
|
||||
<>
|
||||
<rb.Spinner
|
||||
as="span"
|
||||
animation="border"
|
||||
size="sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
className="me-2"
|
||||
/>
|
||||
{isWaitingMakerStart && t('earn.text_starting')}
|
||||
{isWaitingMakerStop && t('earn.text_stopping')}
|
||||
</>
|
||||
) : (
|
||||
<>{t('earn.button_stop')}</>
|
||||
)}
|
||||
</div>
|
||||
</rb.Button>
|
||||
</rb.Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
<rb.Row className="mb-4">
|
||||
<rb.Col className="d-flex justify-content-center">
|
||||
<OrderbookOverlay
|
||||
show={isShowOrderbook}
|
||||
onHide={() => setIsShowOrderbook(false)}
|
||||
nickname={serviceInfo?.nickname ?? undefined}
|
||||
/>
|
||||
<rb.Button
|
||||
variant="outline-dark"
|
||||
className="border-0 d-inline-flex align-items-center"
|
||||
onClick={() => setIsShowOrderbook(true)}
|
||||
>
|
||||
<Sprite symbol="globe" width="24" height="24" className="me-2" />
|
||||
{t('earn.button_show_orderbook')}
|
||||
</rb.Button>
|
||||
</rb.Col>
|
||||
<rb.Col className="d-flex justify-content-center">
|
||||
<EarnReportOverlay show={isShowReport} onHide={() => setIsShowReport(false)} />
|
||||
|
||||
<rb.Button
|
||||
variant="outline-dark"
|
||||
className="border-0 d-inline-flex align-items-center"
|
||||
onClick={() => setIsShowReport(true)}
|
||||
>
|
||||
<Sprite symbol="show" width="24" height="24" className="me-2" />
|
||||
{t('earn.button_show_report')}
|
||||
</rb.Button>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
.report-line-placeholder {
|
||||
height: 2.625rem;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.overlayContainer .earnReportContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) {
|
||||
.overlayContainer .earnReportContainer {
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.overlayContainer .earnReportContainer > .titleBar {
|
||||
min-height: 3.6rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem 0.8rem 0.5rem;
|
||||
background-color: var(--bs-gray-100);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) {
|
||||
.overlayContainer .earnReportContainer .titleBar {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
.overlayContainer .earnReportContainer .titleBar {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .overlayContainer .earnReportContainer .titleBar {
|
||||
background-color: var(--bs-gray-800);
|
||||
}
|
||||
|
||||
.overlayContainer .earnReportContainer > .titleBar .refreshButton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.1rem;
|
||||
border: none;
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { yieldgenReportToEarnReportEntries } from './EarnReport'
|
||||
|
||||
const EXPECTED_HEADER_LINE =
|
||||
'timestamp,cj amount/satoshi,my input count,my input value/satoshi,cjfee/satoshi,earned/satoshi,confirm time/min,notes\n'
|
||||
|
||||
describe('Earn Report', () => {
|
||||
it('should parse empty data correctly', () => {
|
||||
const entries = yieldgenReportToEarnReportEntries([])
|
||||
expect(entries.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should parse data only containing headers correctly', () => {
|
||||
const entries = yieldgenReportToEarnReportEntries([EXPECTED_HEADER_LINE])
|
||||
|
||||
expect(entries.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should parse expected data structure correctly', () => {
|
||||
const exampleData = [
|
||||
EXPECTED_HEADER_LINE,
|
||||
'2008/10/31 02:42:54,,,,,,,Connected\n',
|
||||
'2009/01/03 02:54:42,14999989490,4,20000005630,250,250,0.42,\n',
|
||||
'2009/01/03 03:03:32,10000000000,3,15000016390,250,250,0.8,\n',
|
||||
'2009/01/03 03:04:47,4999981140,1,5000016640,250,250,0,\n',
|
||||
'2009/01/03 03:06:07,1132600000,1,2500000000,250,250,13.37,\n',
|
||||
'2009/01/03 03:07:27,8867393010,2,10000000000,250,250,42,\n',
|
||||
'2009/01/03 03:08:52,1132595980,1,1367400250,250,250,0.17,\n',
|
||||
]
|
||||
|
||||
const entries = yieldgenReportToEarnReportEntries(exampleData)
|
||||
|
||||
expect(entries.length).toBe(7)
|
||||
|
||||
const firstEntry = entries[0]
|
||||
expect(firstEntry.timestamp.toUTCString()).toBe('Fri, 31 Oct 2008 02:42:54 GMT')
|
||||
expect(firstEntry.cjTotalAmount).toBe(null)
|
||||
expect(firstEntry.inputCount).toBe(null)
|
||||
expect(firstEntry.inputAmount).toBe(null)
|
||||
expect(firstEntry.fee).toBe(null)
|
||||
expect(firstEntry.earnedAmount).toBe(null)
|
||||
expect(firstEntry.confirmationDuration).toBe(null)
|
||||
expect(firstEntry.notes).toBe('Connected\n')
|
||||
|
||||
const lastEntry = entries[entries.length - 1]
|
||||
expect(lastEntry.timestamp.toUTCString()).toBe('Sat, 03 Jan 2009 03:08:52 GMT')
|
||||
expect(lastEntry.cjTotalAmount).toBe(1132595980)
|
||||
expect(lastEntry.inputCount).toBe(1)
|
||||
expect(lastEntry.inputAmount).toBe(1367400250)
|
||||
expect(lastEntry.fee).toBe(250)
|
||||
expect(lastEntry.earnedAmount).toBe(250)
|
||||
expect(lastEntry.confirmationDuration).toBe(0.17)
|
||||
expect(lastEntry.notes).toBe('\n')
|
||||
})
|
||||
|
||||
it('should handle unexpected/malformed data in a sane way', () => {
|
||||
const unexpectedHeader = EXPECTED_HEADER_LINE + ',foo,bar'
|
||||
const emptyLine = '' // should be skipped
|
||||
const onlyNewLine = '\n' // should be skipped
|
||||
const shortLine = '2009/01/03 04:04:04,,,' // should be skipped
|
||||
const longLine = '2009/01/03 05:05:05,,,,,,,,,,,,,,,,,,,,,,,' // should be parsed
|
||||
const malformedLine = 'this,is,a,malformed,line,with,some,unexpected,data' // should be parsed
|
||||
|
||||
const exampleData = [unexpectedHeader, emptyLine, onlyNewLine, shortLine, longLine, malformedLine]
|
||||
|
||||
const entries = yieldgenReportToEarnReportEntries(exampleData)
|
||||
|
||||
expect(entries.length).toBe(2)
|
||||
|
||||
const firstEntry = entries[0]
|
||||
expect(firstEntry.timestamp.toUTCString()).toBe('Sat, 03 Jan 2009 05:05:05 GMT')
|
||||
expect(firstEntry.cjTotalAmount).toBe(null)
|
||||
expect(firstEntry.inputCount).toBe(null)
|
||||
expect(firstEntry.inputAmount).toBe(null)
|
||||
expect(firstEntry.fee).toBe(null)
|
||||
expect(firstEntry.earnedAmount).toBe(null)
|
||||
expect(firstEntry.confirmationDuration).toBe(null)
|
||||
expect(firstEntry.notes).toBe(null)
|
||||
|
||||
const secondEntry = entries[1]
|
||||
expect(secondEntry.timestamp.toUTCString()).toBe('Invalid Date')
|
||||
expect(secondEntry.cjTotalAmount).toBe(NaN)
|
||||
expect(secondEntry.inputCount).toBe(NaN)
|
||||
expect(secondEntry.inputAmount).toBe(NaN)
|
||||
expect(secondEntry.fee).toBe(NaN)
|
||||
expect(secondEntry.earnedAmount).toBe(NaN)
|
||||
expect(secondEntry.confirmationDuration).toBe(NaN)
|
||||
expect(secondEntry.notes).toBe('unexpected')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,557 +0,0 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table'
|
||||
import { usePagination } from '@table-library/react-table-library/pagination'
|
||||
import { useSort, HeaderCellSort, SortToggleType } from '@table-library/react-table-library/sort'
|
||||
import * as TableTypes from '@table-library/react-table-library/types/table'
|
||||
import { useTheme } from '@table-library/react-table-library/theme'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as Api from '../libs/JmWalletApi'
|
||||
import { useSettings } from '../context/SettingsContext'
|
||||
import Balance from './Balance'
|
||||
import Sprite from './Sprite'
|
||||
import TablePagination from './TablePagination'
|
||||
import styles from './EarnReport.module.css'
|
||||
import { isDebugFeatureEnabled } from '../constants/debugFeatures'
|
||||
import { pseudoRandomNumber } from './Send/helpers'
|
||||
|
||||
const SORT_KEYS = {
|
||||
timestamp: 'TIMESTAMP',
|
||||
cjTotalAmountInSats: 'CJ_TOTAL_AMOUNT_IN_SATS',
|
||||
inputCount: 'INPUT_COUNT',
|
||||
inputAmountInSats: 'INPUT_AMOUNT_IN_SATS',
|
||||
earnedAmountInSats: 'EARNED_AMOUNT_IN_SATS',
|
||||
}
|
||||
|
||||
const TABLE_THEME = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 2fr 2fr 2fr 1fr 2fr 2fr;
|
||||
font-size: 0.9rem;
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(2) div button {
|
||||
justify-content: end;
|
||||
}
|
||||
&:nth-of-type(3) div button {
|
||||
justify-content: end;
|
||||
}
|
||||
&:nth-of-type(4) div button {
|
||||
justify-content: end;
|
||||
}
|
||||
&:nth-of-type(5) div button {
|
||||
justify-content: end;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
&:nth-of-type(2) {
|
||||
text-align: right;
|
||||
}
|
||||
&:nth-of-type(3) {
|
||||
text-align: right;
|
||||
}
|
||||
&:nth-of-type(4) {
|
||||
text-align: right;
|
||||
}
|
||||
&:nth-of-type(5) {
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
}
|
||||
|
||||
type Minutes = number
|
||||
|
||||
interface EarnReportEntry {
|
||||
timestamp: Date
|
||||
cjTotalAmount: Api.AmountSats | null
|
||||
inputCount: number | null
|
||||
inputAmount: Api.AmountSats | null
|
||||
fee: Api.AmountSats | null
|
||||
earnedAmount: Api.AmountSats | null
|
||||
confirmationDuration: Minutes | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
interface EarnReportTableRow extends EarnReportEntry, TableTypes.TableNode {}
|
||||
|
||||
// in the form of yyyy/MM/dd HH:mm:ss - e.g 2009/01/03 02:54:42
|
||||
type RawYielgenTimestamp = string
|
||||
|
||||
const parseYieldgenTimestamp = (val: RawYielgenTimestamp) => {
|
||||
// adding the timezone manually so that the date displays with the users timezone
|
||||
return new Date(Date.parse(`${val} GMT`))
|
||||
}
|
||||
|
||||
const yieldgenReportLineToEarnReportEntry = (line: string): EarnReportEntry | null => {
|
||||
if (!line.includes(',')) return null
|
||||
|
||||
const values = line.split(',')
|
||||
|
||||
// be defensive here - we cannot handle lines with unexpected values
|
||||
if (values.length < 8) return null
|
||||
|
||||
return {
|
||||
timestamp: parseYieldgenTimestamp(values[0]),
|
||||
cjTotalAmount: values[1] !== '' ? parseInt(values[1], 10) : null,
|
||||
inputCount: values[2] !== '' ? parseInt(values[2], 10) : null,
|
||||
inputAmount: values[3] !== '' ? parseInt(values[3], 10) : null,
|
||||
fee: values[4] !== '' ? parseInt(values[4], 10) : null,
|
||||
earnedAmount: values[5] !== '' ? parseInt(values[5], 10) : null,
|
||||
confirmationDuration: values[6] !== '' ? parseFloat(values[6]) : null,
|
||||
notes: values[7] !== '' ? values[7] : null,
|
||||
}
|
||||
}
|
||||
|
||||
type YieldgenReportLinesWithHeader = string[]
|
||||
|
||||
// exported for tests only
|
||||
export const yieldgenReportToEarnReportEntries = (lines: YieldgenReportLinesWithHeader) => {
|
||||
const empty = lines.length <= 1 // report is "empty" if it just contains the header line
|
||||
const linesWithoutHeader = empty ? [] : lines.slice(1, lines.length)
|
||||
|
||||
return linesWithoutHeader
|
||||
.map((line) => yieldgenReportLineToEarnReportEntry(line))
|
||||
.filter((entry) => entry !== null)
|
||||
.map((entry) => entry!)
|
||||
}
|
||||
|
||||
interface EarnReportTableProps {
|
||||
data: TableTypes.Data<EarnReportTableRow>
|
||||
}
|
||||
|
||||
const EarnReportTable = ({ data }: EarnReportTableProps) => {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
|
||||
const tableTheme = useTheme(TABLE_THEME)
|
||||
const pagination = usePagination(data, {
|
||||
state: {
|
||||
page: 0,
|
||||
size: 25,
|
||||
},
|
||||
})
|
||||
|
||||
const tableSort = useSort(
|
||||
data,
|
||||
{
|
||||
state: {
|
||||
sortKey: SORT_KEYS.timestamp,
|
||||
reverse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
sortIcon: {
|
||||
margin: '4px',
|
||||
iconDefault: <Sprite symbol="caret-right" width="20" height="20" />,
|
||||
iconUp: <Sprite symbol="caret-up" width="20" height="20" />,
|
||||
iconDown: <Sprite symbol="caret-down" width="20" height="20" />,
|
||||
},
|
||||
sortToggleType: SortToggleType.AlternateWithReset,
|
||||
sortFns: {
|
||||
[SORT_KEYS.timestamp]: (array) => array.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()),
|
||||
[SORT_KEYS.earnedAmountInSats]: (array) => array.sort((a, b) => +a.earnedAmount - +b.earnedAmount),
|
||||
[SORT_KEYS.cjTotalAmountInSats]: (array) => array.sort((a, b) => +a.cjTotalAmount - +b.cjTotalAmount),
|
||||
[SORT_KEYS.inputCount]: (array) => array.sort((a, b) => +a.inputCount - +b.inputCount),
|
||||
[SORT_KEYS.inputAmountInSats]: (array) => array.sort((a, b) => +a.inputAmount - +b.inputAmount),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
data={data}
|
||||
theme={tableTheme}
|
||||
pagination={pagination}
|
||||
sort={tableSort}
|
||||
layout={{ custom: true, horizontalScroll: true }}
|
||||
className="table striped"
|
||||
>
|
||||
{(tableList: TableTypes.TableProps<EarnReportTableRow>) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.timestamp}>{t('earn.report.heading_timestamp')}</HeaderCellSort>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.earnedAmountInSats}>
|
||||
{t('earn.report.heading_earned')}
|
||||
</HeaderCellSort>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.cjTotalAmountInSats}>
|
||||
{t('earn.report.heading_cj_amount')}
|
||||
</HeaderCellSort>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.inputCount}>{t('earn.report.heading_input_count')}</HeaderCellSort>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.inputAmountInSats}>
|
||||
{t('earn.report.heading_input_value')}
|
||||
</HeaderCellSort>
|
||||
<HeaderCell>{t('earn.report.heading_notes')}</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((item: EarnReportTableRow) => {
|
||||
return (
|
||||
<Row key={item.id} item={item}>
|
||||
<Cell>{item.timestamp.toLocaleString()}</Cell>
|
||||
<Cell>
|
||||
<Balance
|
||||
valueString={item.earnedAmount?.toString() || ''}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={true}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Balance
|
||||
valueString={item.cjTotalAmount?.toString() || ''}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={true}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>{item.inputCount}</Cell>
|
||||
<Cell>
|
||||
<Balance
|
||||
valueString={item.inputAmount?.toString() || ''}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={true}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>{item.notes}</Cell>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
<div className="mt-4 mb-4 mb-lg-0">
|
||||
<TablePagination data={data} pagination={pagination} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatsBoxProps {
|
||||
title: string
|
||||
value: React.ReactNode
|
||||
description?: string
|
||||
}
|
||||
|
||||
function StatsBox({ title, value, description }: StatsBoxProps) {
|
||||
return (
|
||||
<div className="d-flex flex-1 flex-column border rounded p-3 p-md-4">
|
||||
<div className="fs-6 text-center">{title}</div>
|
||||
<div className="fs-4 text-center">{value}</div>
|
||||
{description ? <div>{description}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface EarnReportProps {
|
||||
entries: EarnReportEntry[]
|
||||
refresh: (signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
export function EarnReport({ entries, refresh }: EarnReportProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
const [search, setSearch] = useState('')
|
||||
const [isLoadingRefresh, setIsLoadingRefresh] = useState(false)
|
||||
|
||||
const tableData: TableTypes.Data<EarnReportTableRow> = useMemo(() => {
|
||||
const searchVal = search.replace('.', '').toLowerCase()
|
||||
const filteredEntries =
|
||||
searchVal === ''
|
||||
? entries
|
||||
: entries.filter((entry) => {
|
||||
return (
|
||||
entry.timestamp.toLocaleString().toLowerCase().includes(searchVal) ||
|
||||
entry.cjTotalAmount?.toString().includes(searchVal) ||
|
||||
entry.inputCount?.toString().includes(searchVal) ||
|
||||
entry.inputAmount?.toString().includes(searchVal) ||
|
||||
entry.earnedAmount?.toString().includes(searchVal) ||
|
||||
entry.inputCount?.toString().includes(searchVal) ||
|
||||
entry.notes?.toLowerCase().includes(searchVal)
|
||||
)
|
||||
})
|
||||
const nodes = filteredEntries.map((entry, index) => ({
|
||||
...entry,
|
||||
id: `${index}`,
|
||||
}))
|
||||
|
||||
return { nodes }
|
||||
}, [entries, search])
|
||||
|
||||
const earnedTotal: Api.AmountSats = useMemo(() => {
|
||||
return entries.map((entry) => entry.earnedAmount ?? 0).reduce((previous, current) => previous + current, 0)
|
||||
}, [entries])
|
||||
|
||||
const earned90Days: Api.AmountSats = useMemo(() => {
|
||||
return entries
|
||||
.filter((it) => it.timestamp.getTime() > Date.now() - 90 * 24 * 60 * 60 * 1_000)
|
||||
.map((it) => it.earnedAmount ?? 0)
|
||||
.reduce((previous, current) => previous + current, 0)
|
||||
}, [entries])
|
||||
|
||||
const earned30Days: Api.AmountSats = useMemo(() => {
|
||||
return entries
|
||||
.filter((it) => it.timestamp.getTime() > Date.now() - 30 * 24 * 60 * 60 * 1_000)
|
||||
.map((it) => it.earnedAmount ?? 0)
|
||||
.reduce((previous, current) => previous + current, 0)
|
||||
}, [entries])
|
||||
|
||||
const earned24Hours: Api.AmountSats = useMemo(() => {
|
||||
return entries
|
||||
.filter((it) => it.timestamp.getTime() > Date.now() - 1 * 24 * 60 * 60 * 1_000)
|
||||
.map((it) => it.earnedAmount ?? 0)
|
||||
.reduce((previous, current) => previous + current, 0)
|
||||
}, [entries])
|
||||
|
||||
return (
|
||||
<div className={styles.earnReportContainer}>
|
||||
<div className={styles.titleBar}>
|
||||
<div className="d-flex justify-content-center align-items-center gap-2">
|
||||
<rb.Button
|
||||
className={styles.refreshButton}
|
||||
variant={settings.theme}
|
||||
onClick={() => {
|
||||
if (isLoadingRefresh) return
|
||||
|
||||
setIsLoadingRefresh(true)
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
refresh(abortCtrl.signal).finally(() => {
|
||||
// as refreshing is fast most of the time, add a short delay to avoid flickering
|
||||
setTimeout(() => setIsLoadingRefresh(false), 250)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isLoadingRefresh ? (
|
||||
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" />
|
||||
) : (
|
||||
<Sprite symbol="refresh" width="24" height="24" />
|
||||
)}
|
||||
</rb.Button>
|
||||
<div className="small">
|
||||
{search === '' ? (
|
||||
<>
|
||||
{t('earn.report.text_report_summary', {
|
||||
count: entries.length,
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('earn.report.text_report_summary_filtered', {
|
||||
count: tableData.nodes.length,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<rb.Form.Group controlId="search">
|
||||
<rb.Form.Label className="m-0 pe-2 d-none">{t('earn.report.label_search')}</rb.Form.Label>
|
||||
<rb.Form.Control
|
||||
name="search"
|
||||
placeholder={t('earn.report.placeholder_search')}
|
||||
value={search}
|
||||
disabled={isLoadingRefresh}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</rb.Form.Group>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-3 pt-lg-0">
|
||||
<div className="d-flex flex-wrap justify-content-around align-items-center gap-2">
|
||||
<StatsBox
|
||||
title={t('earn.report.stats.earned_total')}
|
||||
value={
|
||||
<Balance valueString={earnedTotal.toString() || ''} convertToUnit={settings.unit} showBalance={true} />
|
||||
}
|
||||
/>
|
||||
<StatsBox
|
||||
title={t('earn.report.stats.earned_90days')}
|
||||
value={
|
||||
<Balance valueString={earned90Days.toString() || ''} convertToUnit={settings.unit} showBalance={true} />
|
||||
}
|
||||
/>
|
||||
<StatsBox
|
||||
title={t('earn.report.stats.earned_30days')}
|
||||
value={
|
||||
<Balance valueString={earned30Days.toString() || ''} convertToUnit={settings.unit} showBalance={true} />
|
||||
}
|
||||
/>
|
||||
<StatsBox
|
||||
title={t('earn.report.stats.earned_24hours')}
|
||||
value={
|
||||
<Balance valueString={earned24Hours.toString() || ''} convertToUnit={settings.unit} showBalance={true} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className="px-2">
|
||||
<rb.Alert variant="info">{t('earn.alert_empty_report')}</rb.Alert>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-md-2">
|
||||
<EarnReportTable data={tableData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EarnReportOverlay({ show, onHide }: rb.OffcanvasProps) {
|
||||
const { t } = useTranslation()
|
||||
const [alert, setAlert] = useState<SimpleAlert>()
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [entries, setEntries] = useState<EarnReportEntry[] | null>(null)
|
||||
const [__dev_showGenerateDemoReportButton] = useState(isDebugFeatureEnabled('enableDemoEarnReport'))
|
||||
|
||||
const __dev_generateDemoReportEntryButton = () => {
|
||||
const randomTimestamp = new Date(Date.now() - Date.now() * Math.random() * Math.pow(10, pseudoRandomNumber(-5, -1)))
|
||||
setEntries((it) => {
|
||||
const connectedNote = {
|
||||
timestamp: randomTimestamp,
|
||||
cjTotalAmount: null,
|
||||
inputCount: null,
|
||||
inputAmount: null,
|
||||
fee: null,
|
||||
earnedAmount: null,
|
||||
confirmationDuration: null,
|
||||
notes: 'Connected ',
|
||||
}
|
||||
if (!it || it.length === 0) {
|
||||
connectedNote.timestamp = new Date(Date.now() - Date.now() * 0.1)
|
||||
return [connectedNote]
|
||||
}
|
||||
if (it.length > 2 && Math.random() > 0.8) {
|
||||
return [...it, connectedNote]
|
||||
}
|
||||
const randomEntry = {
|
||||
timestamp: randomTimestamp,
|
||||
cjTotalAmount: Math.round(Math.random() * Math.pow(10, pseudoRandomNumber(7, 9))),
|
||||
inputCount: Math.max(1, pseudoRandomNumber(-1, 4)),
|
||||
inputAmount: Math.round(Math.random() * Math.pow(10, pseudoRandomNumber(3, 6))),
|
||||
fee: Math.round(Math.random() * 100 + 1),
|
||||
earnedAmount: Math.round(Math.random() * Math.pow(10, pseudoRandomNumber(1, 3)) + 1),
|
||||
confirmationDuration: Math.round(Math.random() * 100),
|
||||
notes: null,
|
||||
}
|
||||
|
||||
return [...it, randomEntry]
|
||||
})
|
||||
}
|
||||
|
||||
const refresh = useCallback(
|
||||
(signal: AbortSignal) => {
|
||||
return Api.getYieldgenReport({ signal })
|
||||
.then((res) => {
|
||||
if (res.ok) return res.json()
|
||||
// 404 is returned till the maker is started at least once
|
||||
if (res.status === 404) return { yigen_data: [] }
|
||||
return Api.Helper.throwError(res)
|
||||
})
|
||||
.then((data) => data.yigen_data as YieldgenReportLinesWithHeader)
|
||||
.then((linesWithHeader) => yieldgenReportToEarnReportEntries(linesWithHeader))
|
||||
.then((earnReportEntries) => {
|
||||
if (signal.aborted) return
|
||||
setAlert(undefined)
|
||||
setEntries(earnReportEntries)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (signal.aborted) return
|
||||
const message = t('earn.error_loading_report_failed', {
|
||||
reason: e.message || t('global.errors.reason_unknown'),
|
||||
})
|
||||
setAlert({ variant: 'danger', message })
|
||||
})
|
||||
},
|
||||
[t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
|
||||
setIsLoading(true)
|
||||
refresh(abortCtrl.signal).finally(() => {
|
||||
if (abortCtrl.signal.aborted) return
|
||||
setIsLoading(false)
|
||||
setIsInitialized(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortCtrl.abort()
|
||||
}
|
||||
}, [show, refresh])
|
||||
|
||||
return (
|
||||
<rb.Offcanvas
|
||||
className={`offcanvas-fullscreen ${styles.overlayContainer}`}
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
placement="bottom"
|
||||
>
|
||||
<rb.Offcanvas.Header>
|
||||
<rb.Container fluid="lg">
|
||||
<div className="w-100 d-flex">
|
||||
<div className="d-flex align-items-center flex-1">
|
||||
<rb.Offcanvas.Title>{t('earn.report.title')}</rb.Offcanvas.Title>
|
||||
</div>
|
||||
<div>
|
||||
<rb.Button variant="link" className="unstyled pe-0" onClick={onHide}>
|
||||
<Sprite symbol="cancel" width="32" height="32" />
|
||||
</rb.Button>
|
||||
</div>
|
||||
</div>
|
||||
</rb.Container>
|
||||
</rb.Offcanvas.Header>
|
||||
<rb.Offcanvas.Body>
|
||||
<rb.Container fluid="lg" className="py-3">
|
||||
{!isInitialized && isLoading ? (
|
||||
Array(5)
|
||||
.fill('')
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<rb.Placeholder key={index} as="div" animation="wave">
|
||||
<rb.Placeholder xs={12} className={styles['report-line-placeholder']} />
|
||||
</rb.Placeholder>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{__dev_showGenerateDemoReportButton && (
|
||||
<rb.Row>
|
||||
<rb.Col className="px-0 mb-2">
|
||||
<rb.Button
|
||||
className="position-relative"
|
||||
variant="outline-dark"
|
||||
disabled={false}
|
||||
onClick={() => __dev_generateDemoReportEntryButton()}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
{t('earn.report.text_button_generate_demo_report')}
|
||||
<Sprite symbol="plus" width="20" height="20" className="ms-2" />
|
||||
</div>
|
||||
<span className="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-warning">
|
||||
dev
|
||||
</span>
|
||||
</rb.Button>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
|
||||
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
|
||||
{entries && (
|
||||
<rb.Row>
|
||||
<rb.Col className="px-0">
|
||||
<EarnReport entries={entries} refresh={refresh} />
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</rb.Container>
|
||||
</rb.Offcanvas.Body>
|
||||
</rb.Offcanvas>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useRouteError } from 'react-router-dom'
|
||||
import PageTitle from './PageTitle'
|
||||
import { t } from 'i18next'
|
||||
|
||||
interface ErrorViewProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
reason: string
|
||||
stacktrace?: string
|
||||
}
|
||||
|
||||
function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) {
|
||||
return (
|
||||
<div>
|
||||
<PageTitle title={title} subtitle={subtitle} />
|
||||
|
||||
<p>
|
||||
<Trans i18nKey="error_page.report_bug">
|
||||
Please{' '}
|
||||
<a
|
||||
href="https://github.com/joinmarket-webui/jam/issues/new?labels=bug&template=bug_report.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
open an issue on GitHub
|
||||
</a>{' '}
|
||||
for this error to be reviewed and resolved in an upcoming version.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<div className="my-4">
|
||||
<h6>{t('error_page.heading_reason')}</h6>
|
||||
<rb.Alert variant="danger">{reason}</rb.Alert>
|
||||
</div>
|
||||
|
||||
{stacktrace && (
|
||||
<div className="my-4">
|
||||
<h6>{t('error_page.heading_stacktrace')}</h6>
|
||||
<pre className="border p-2">
|
||||
<code>{stacktrace}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UnknownError({ error }: { error: any }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ErrorView
|
||||
title={t('error_page.unknown_error.title')}
|
||||
subtitle={t('error_page.unknown_error.subtitle')}
|
||||
reason={error.message || t('global.errors.reason_unknown')}
|
||||
stacktrace={error.stack}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorWithDetails({ error }: { error: Error }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ErrorView
|
||||
title={t('error_page.error_with_details.title')}
|
||||
subtitle={t('error_page.error_with_details.subtitle')}
|
||||
reason={error.message || t('global.errors.reason_unknown')}
|
||||
stacktrace={error.stack}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ErrorPage() {
|
||||
const error = useRouteError()
|
||||
|
||||
if (error instanceof Error) {
|
||||
return <ErrorWithDetails error={error} />
|
||||
} else {
|
||||
return <UnknownError error={error} />
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { Link, LinkProps } from 'react-router-dom'
|
||||
|
||||
interface Props extends LinkProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ExtendedLink({ disabled, ...props }: Props) {
|
||||
if (disabled) {
|
||||
return (
|
||||
<button disabled className={`${props.className} pe-auto`}>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return <Link {...props}>{props.children}</Link>
|
||||
}
|
||||
|
|
@ -1,202 +1,22 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useSettings, useSettingsDispatch } from '../context/SettingsContext'
|
||||
import { useServiceInfo } from '../context/ServiceInfoContext'
|
||||
import { useWebsocketState } from '../context/WebsocketContext'
|
||||
import { useCurrentWallet } from '../context/WalletContext'
|
||||
import Sprite from './Sprite'
|
||||
import Cheatsheet from './Cheatsheet'
|
||||
import { InfoModal } from './Modal'
|
||||
import { isDebugFeatureEnabled, isDevMode } from '../constants/debugFeatures'
|
||||
import { routes } from '../constants/routes'
|
||||
import { toSemVer } from '../utils'
|
||||
import { OrderbookOverlay } from './Orderbook'
|
||||
import packageInfo from '../../package.json'
|
||||
|
||||
const APP_DISPLAY_VERSION = (() => {
|
||||
const version = toSemVer(packageInfo.version)
|
||||
return !isDevMode() ? version.raw : `${version.major}.${version.minor}.${version.patch + 1}dev`
|
||||
})()
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation()
|
||||
const currentWallet = useCurrentWallet()
|
||||
const settings = useSettings()
|
||||
const serviceInfo = useServiceInfo()
|
||||
const settingsDispatch = useSettingsDispatch()
|
||||
const websocketState = useWebsocketState()
|
||||
|
||||
const [showBetaWarning, setShowBetaWarning] = useState(false)
|
||||
const [showCheatsheet, setShowCheatsheet] = useState(false)
|
||||
const [isShowOrderbook, setIsShowOrderbook] = useState(false)
|
||||
|
||||
const cheatsheetEnabled = useMemo(() => !!currentWallet, [currentWallet])
|
||||
const orderbookEnabled = useMemo(() => !!currentWallet, [currentWallet])
|
||||
const websocketConnected = useMemo(() => websocketState === WebSocket.OPEN, [websocketState])
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout
|
||||
// show the cheatsheet once after the first wallet has been created
|
||||
if (cheatsheetEnabled && settings.showCheatsheet) {
|
||||
timer = setTimeout(() => {
|
||||
setShowCheatsheet(true)
|
||||
settingsDispatch({ showCheatsheet: false })
|
||||
}, 1_000)
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [cheatsheetEnabled, settings, settingsDispatch])
|
||||
import { File } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<>
|
||||
{showBetaWarning && (
|
||||
<InfoModal
|
||||
isShown={showBetaWarning}
|
||||
size="sm"
|
||||
title={t('footer.warning_alert_title')}
|
||||
submitButtonText={t('footer.warning_alert_button_ok')}
|
||||
onCancel={() => setShowBetaWarning(false)}
|
||||
onSubmit={() => setShowBetaWarning(false)}
|
||||
>
|
||||
<p>{t('footer.warning_alert_text')}</p>
|
||||
<p className="mb-0 text-secondary">
|
||||
JoinMarket: v{serviceInfo?.server?.version?.raw || '_unknown'}
|
||||
<br />
|
||||
Jam: v{APP_DISPLAY_VERSION}
|
||||
</p>
|
||||
</InfoModal>
|
||||
)}
|
||||
|
||||
<rb.Nav as="footer" className="border-top py-2">
|
||||
<rb.Container fluid="xl" className="d-flex justify-content-center py-2 px-4">
|
||||
<div className="d-none d-md-flex flex-1 order-0 justify-content-start align-items-center">
|
||||
<div className="text-small text-start text-secondary">
|
||||
<Trans i18nKey="footer.warning">
|
||||
This is pre-alpha software.
|
||||
<rb.Button
|
||||
variant="link"
|
||||
className="text-small text-start border-0 p-0 text-secondary"
|
||||
onClick={() => setShowBetaWarning(true)}
|
||||
>
|
||||
Read this before using.
|
||||
</rb.Button>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex order-1 flex-1 flex-grow-0 justify-content-center align-items-center gap-1">
|
||||
{cheatsheetEnabled && (
|
||||
<>
|
||||
<Cheatsheet show={showCheatsheet} onHide={() => setShowCheatsheet(false)} />
|
||||
<rb.Nav.Item>
|
||||
<rb.Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="nav-link text-start border-0 px-2"
|
||||
onClick={() => setShowCheatsheet(true)}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<Sprite symbol="file-outline" width="24" height="24" />
|
||||
<div className="ps-1">{t('footer.cheatsheet')}</div>
|
||||
</div>
|
||||
</rb.Button>
|
||||
</rb.Nav.Item>
|
||||
</>
|
||||
)}
|
||||
{orderbookEnabled && (
|
||||
<>
|
||||
<OrderbookOverlay
|
||||
show={isShowOrderbook}
|
||||
onHide={() => setIsShowOrderbook(false)}
|
||||
nickname={serviceInfo?.nickname ?? undefined}
|
||||
/>
|
||||
<rb.Nav.Item>
|
||||
<rb.Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="nav-link text-start border-0 px-2"
|
||||
onClick={() => setIsShowOrderbook(true)}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<Sprite symbol="globe" width="24" height="24" />
|
||||
<div className="ps-1">{t('footer.orderbook')}</div>
|
||||
</div>
|
||||
</rb.Button>
|
||||
</rb.Nav.Item>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex flex-1 order-2 justify-content-end align-items-center gap-1">
|
||||
{isDebugFeatureEnabled('devSetupPage') && (
|
||||
<div className="d-none d-md-block text-small text-start">
|
||||
<Link className="text-warning" to={routes.__devSetup}>
|
||||
Dev Setup
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-small text-start text-secondary me-1">
|
||||
<a
|
||||
href="https://github.com/joinmarket-webui/jam/tags"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="d-flex align-items-center text-secondary"
|
||||
>
|
||||
v{APP_DISPLAY_VERSION}
|
||||
</a>
|
||||
</div>
|
||||
<div className="d-flex gap-2 me-1">
|
||||
<a
|
||||
href="https://github.com/joinmarket-webui/jam"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="d-flex align-items-center text-secondary"
|
||||
>
|
||||
<Sprite symbol="github" width="18px" height="18px" />
|
||||
</a>
|
||||
<a
|
||||
href="https://matrix.to/#/%23jam:bitcoin.kyoto"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="d-flex align-items-center text-secondary"
|
||||
>
|
||||
<Sprite symbol="matrix" width="18px" height="18px" />
|
||||
</a>
|
||||
<a
|
||||
href="https://t.me/JoinMarketWebUI"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="d-flex align-items-center text-secondary"
|
||||
>
|
||||
<Sprite symbol="telegram" width="18px" height="18px" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-secondary">|</div>
|
||||
<div className="d-flex">
|
||||
<rb.OverlayTrigger
|
||||
delay={{ hide: 300, show: 200 }}
|
||||
placement="top"
|
||||
overlay={(props) => (
|
||||
<rb.Tooltip {...props}>
|
||||
{websocketConnected ? (
|
||||
<>{t('footer.websocket_connected')}</>
|
||||
) : (
|
||||
<>{t('footer.websocket_disconnected')}</>
|
||||
)}
|
||||
</rb.Tooltip>
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={`mx-1 ${websocketConnected ? 'text-success' : 'text-secondary'}`}
|
||||
data-testid="connection-indicator-icon"
|
||||
>
|
||||
<Sprite symbol="node" width="24px" height="24px" />
|
||||
</span>
|
||||
</rb.OverlayTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</rb.Container>
|
||||
</rb.Nav>
|
||||
</>
|
||||
<footer className="flex justify-between items-center p-4 bg-white text-xs opacity-60 text-black dark:bg-[#181b20] dark:text-white transition-colors duration-300">
|
||||
<span className="flex-1">
|
||||
This is beta software. <br />
|
||||
<a href="#" className="underline">
|
||||
Read this before using.
|
||||
</a>
|
||||
</span>
|
||||
<div className="flex-1 flex items-center hover:underline justify-center">
|
||||
<Button variant="ghost" size="sm">
|
||||
<File />
|
||||
Cheatsheet
|
||||
</Button>
|
||||
</div>
|
||||
<span className="flex-1 text-right opacity-70">© 2025 Hodlers</span>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,684 +0,0 @@
|
|||
import { useState, useMemo, useCallback } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { Formik, FormikErrors } from 'formik'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
import * as Api from '../libs/JmWalletApi'
|
||||
import { useServiceInfo, useDispatchServiceInfo } from '../context/ServiceInfoContext'
|
||||
import { useRefreshConfigValues, useUpdateConfigValues } from '../context/ServiceConfigContext'
|
||||
import PageTitle from './PageTitle'
|
||||
import Sprite from './Sprite'
|
||||
import Accordion from './Accordion'
|
||||
import WalletCreationForm, { CreateWalletFormValues } from './WalletCreationForm'
|
||||
import MnemonicPhraseInput from './MnemonicPhraseInput'
|
||||
import PreventLeavingPageByMistake from './PreventLeavingPageByMistake'
|
||||
import { CreatedWalletInfo, WalletCreationInfoSummary } from './WalletCreationConfirmation'
|
||||
import { isDevMode, isDebugFeatureEnabled } from '../constants/debugFeatures'
|
||||
import { routes, Route } from '../constants/routes'
|
||||
import {
|
||||
SEGWIT_ACTIVATION_BLOCK,
|
||||
DUMMY_MNEMONIC_PHRASE,
|
||||
walletDisplayName,
|
||||
isValidNumber,
|
||||
walletDisplayNameToFileName,
|
||||
} from '../utils'
|
||||
import { JM_GAPLIMIT_DEFAULT, JM_GAPLIMIT_CONFIGKEY } from '../constants/jm'
|
||||
|
||||
type ImportWalletDetailsFormValues = {
|
||||
mnemonicPhrase: MnemonicPhrase
|
||||
blockheight: number
|
||||
gaplimit: number
|
||||
}
|
||||
|
||||
const GAPLIMIT_SUGGESTIONS = {
|
||||
normal: JM_GAPLIMIT_DEFAULT,
|
||||
heavy: JM_GAPLIMIT_DEFAULT * 4,
|
||||
}
|
||||
|
||||
const MIN_BLOCKHEIGHT_VALUE = 0
|
||||
/**
|
||||
* 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.
|
||||
* This value represents an upper limit based on declining performance of JM when many
|
||||
* addresses have to be monitored. On network `regtest`, importing 10_000 addresses in
|
||||
* an empty wallet takes ~10min and requesting the `/display` endpoint takes another
|
||||
* ~10min. At this point, JM becomes practically unusable. However, goal is to find a
|
||||
* balance between usability and freedom of users to do what they are trying to do.
|
||||
*/
|
||||
const MAX_GAPLIMIT_VALUE = 10_000
|
||||
/**
|
||||
* A gaplimit threshold at which a warning is displayed that with the given value a
|
||||
* decline in performance is to be expected. Importing 500 addresses (per jar!) leads to
|
||||
* the `/display` endpoint taking more than ~15s.
|
||||
*/
|
||||
const GAPLIMIT_WARN_THRESHOLD = 250
|
||||
|
||||
const initialImportWalletDetailsFormValues: ImportWalletDetailsFormValues = isDevMode()
|
||||
? {
|
||||
mnemonicPhrase: new Array<string>(12).fill(''),
|
||||
blockheight: MIN_BLOCKHEIGHT_VALUE,
|
||||
gaplimit: GAPLIMIT_SUGGESTIONS.heavy,
|
||||
}
|
||||
: {
|
||||
mnemonicPhrase: new Array<string>(12).fill(''),
|
||||
blockheight: SEGWIT_ACTIVATION_BLOCK,
|
||||
gaplimit: GAPLIMIT_SUGGESTIONS.normal,
|
||||
}
|
||||
|
||||
interface ImportWalletDetailsFormProps {
|
||||
initialValues?: ImportWalletDetailsFormValues
|
||||
submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
|
||||
onCancel: () => void
|
||||
onSubmit: (values: ImportWalletDetailsFormValues) => Promise<void>
|
||||
}
|
||||
|
||||
type RecoveredWalletWithAuth = Pick<CreatedWalletInfo, 'walletFileName'> & {
|
||||
auth: Api.ApiAuthContext
|
||||
}
|
||||
|
||||
const ImportWalletDetailsForm = ({
|
||||
initialValues = initialImportWalletDetailsFormValues,
|
||||
submitButtonText,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ImportWalletDetailsFormProps) => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [__dev_showFillerButton] = useState(isDebugFeatureEnabled('importDummyMnemonicPhrase'))
|
||||
|
||||
const validate = useCallback(
|
||||
(values: ImportWalletDetailsFormValues) => {
|
||||
const errors = {} as FormikErrors<ImportWalletDetailsFormValues>
|
||||
const isMnemonicPhraseValid = values.mnemonicPhrase.every((it) => it.length > 0)
|
||||
if (!isMnemonicPhraseValid) {
|
||||
errors.mnemonicPhrase = t('import_wallet.import_details.feedback_invalid_menmonic_phrase')
|
||||
}
|
||||
|
||||
if (
|
||||
!isValidNumber(values.blockheight) ||
|
||||
values.blockheight < MIN_BLOCKHEIGHT_VALUE ||
|
||||
values.blockheight > MAX_BLOCKHEIGHT_VALUE
|
||||
) {
|
||||
errors.blockheight = t('import_wallet.import_details.feedback_invalid_blockheight', {
|
||||
min: MIN_BLOCKHEIGHT_VALUE.toLocaleString(),
|
||||
})
|
||||
}
|
||||
if (
|
||||
!isValidNumber(values.gaplimit) ||
|
||||
values.gaplimit < MIN_GAPLIMIT_VALUE ||
|
||||
values.gaplimit > MAX_GAPLIMIT_VALUE
|
||||
) {
|
||||
errors.gaplimit = t('import_wallet.import_details.feedback_invalid_gaplimit', {
|
||||
min: MIN_GAPLIMIT_VALUE.toLocaleString(),
|
||||
max: MAX_GAPLIMIT_VALUE.toLocaleString(),
|
||||
})
|
||||
}
|
||||
return errors
|
||||
},
|
||||
[t],
|
||||
)
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit}>
|
||||
{({
|
||||
handleSubmit,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
setFieldValue,
|
||||
values,
|
||||
touched,
|
||||
errors,
|
||||
isSubmitting,
|
||||
submitCount,
|
||||
}) => {
|
||||
const hasImportDetailsSectionErrors = !!errors.blockheight || !!errors.gaplimit
|
||||
const showGaplimitWarning = !errors.gaplimit && values.gaplimit > GAPLIMIT_WARN_THRESHOLD
|
||||
return (
|
||||
<rb.Form onSubmit={handleSubmit} noValidate lang={i18n.resolvedLanguage || i18n.language}>
|
||||
<MnemonicPhraseInput
|
||||
mnemonicPhrase={values.mnemonicPhrase}
|
||||
onChange={(val) => setFieldValue('mnemonicPhrase', val, true)}
|
||||
isDisabled={(_) => isSubmitting}
|
||||
/>
|
||||
{!!errors.mnemonicPhrase && (
|
||||
<>
|
||||
<div
|
||||
className={classNames('mb-2', 'text-danger', {
|
||||
'd-none': submitCount === 0,
|
||||
})}
|
||||
>
|
||||
{errors.mnemonicPhrase}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{__dev_showFillerButton && (
|
||||
<rb.Button
|
||||
variant="outline-dark"
|
||||
className="w-100 mb-4 position-relative"
|
||||
onClick={() => setFieldValue('mnemonicPhrase', DUMMY_MNEMONIC_PHRASE, true)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Fill with dummy mnemonic phrase
|
||||
<span className="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-warning">
|
||||
dev
|
||||
</span>
|
||||
</rb.Button>
|
||||
)}
|
||||
<Accordion
|
||||
title={t('import_wallet.import_details.import_options')}
|
||||
variant={hasImportDetailsSectionErrors ? 'danger' : showGaplimitWarning ? 'warning' : undefined}
|
||||
defaultOpen={true}
|
||||
>
|
||||
<rb.Form.Group controlId="blockheight" className="mb-4">
|
||||
<rb.Form.Label>{t('import_wallet.import_details.label_blockheight')}</rb.Form.Label>
|
||||
<rb.Form.Text className="d-block text-secondary mb-2">
|
||||
{t('import_wallet.import_details.description_blockheight')}
|
||||
</rb.Form.Text>
|
||||
<rb.InputGroup hasValidation>
|
||||
<rb.InputGroup.Text id="blockheight-addon1">
|
||||
<Sprite symbol="block" width="24" height="24" name="Block" />
|
||||
</rb.InputGroup.Text>
|
||||
<rb.Form.Control
|
||||
aria-label={t('import_wallet.import_details.label_blockheight')}
|
||||
className="slashed-zeroes"
|
||||
name="blockheight"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
size="lg"
|
||||
value={values.blockheight}
|
||||
disabled={isSubmitting}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
isValid={touched.blockheight && !errors.blockheight}
|
||||
isInvalid={touched.blockheight && !!errors.blockheight}
|
||||
min={MIN_BLOCKHEIGHT_VALUE}
|
||||
max={MAX_BLOCKHEIGHT_VALUE}
|
||||
step={1_000}
|
||||
required
|
||||
/>
|
||||
<rb.Form.Control.Feedback type="invalid">{errors.blockheight}</rb.Form.Control.Feedback>
|
||||
</rb.InputGroup>
|
||||
</rb.Form.Group>
|
||||
<rb.Form.Group controlId="gaplimit" className="mb-4">
|
||||
<rb.Form.Label>{t('import_wallet.import_details.label_gaplimit')}</rb.Form.Label>
|
||||
|
||||
<rb.Form.Text className="d-block text-secondary mb-2">
|
||||
{t('import_wallet.import_details.description_gaplimit')}
|
||||
</rb.Form.Text>
|
||||
<rb.InputGroup hasValidation>
|
||||
<rb.InputGroup.Text id="gaplimit-addon1">
|
||||
<Sprite symbol="gaplimit" width="24" height="24" name="Gaplimit" />
|
||||
</rb.InputGroup.Text>
|
||||
<rb.Form.Control
|
||||
aria-label={t('import_wallet.import_details.label_gaplimit')}
|
||||
className="slashed-zeroes"
|
||||
name="gaplimit"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
size="lg"
|
||||
value={values.gaplimit}
|
||||
disabled={isSubmitting}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
isValid={touched.gaplimit && !errors.gaplimit}
|
||||
isInvalid={touched.gaplimit && !!errors.gaplimit}
|
||||
min={MIN_GAPLIMIT_VALUE}
|
||||
max={MAX_GAPLIMIT_VALUE}
|
||||
step={1}
|
||||
required
|
||||
/>
|
||||
<rb.Form.Control.Feedback type="invalid">{errors.gaplimit}</rb.Form.Control.Feedback>
|
||||
</rb.InputGroup>
|
||||
{showGaplimitWarning && (
|
||||
<rb.Alert variant="warning" className="d-flex align-items-center mt-2">
|
||||
{t('import_wallet.import_details.alert_high_gaplimit_value')}
|
||||
</rb.Alert>
|
||||
)}
|
||||
</rb.Form.Group>
|
||||
</Accordion>
|
||||
<rb.Button className="w-100 mb-4" variant="dark" size="lg" type="submit" disabled={isSubmitting}>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
{isSubmitting && (
|
||||
<rb.Spinner
|
||||
as="span"
|
||||
animation="border"
|
||||
size="sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
className="me-2"
|
||||
/>
|
||||
)}
|
||||
{submitButtonText(isSubmitting)}
|
||||
</div>
|
||||
</rb.Button>
|
||||
<div className="d-flex mb-4 gap-4">
|
||||
<rb.Button variant="none" hidden={isSubmitting} disabled={isSubmitting} onClick={() => onCancel()}>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<Sprite symbol="arrow-left" width="20" height="20" className="me-2" />
|
||||
{t('global.back')}
|
||||
</div>
|
||||
</rb.Button>
|
||||
</div>
|
||||
</rb.Form>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
|
||||
type ImportWalletConfirmationFormValues = {
|
||||
walletDetails: CreateWalletFormValues
|
||||
importDetails: ImportWalletDetailsFormValues
|
||||
}
|
||||
|
||||
interface ImportWalletConfirmationProps {
|
||||
walletDetails: CreateWalletFormValues
|
||||
importDetails: ImportWalletDetailsFormValues
|
||||
submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
|
||||
onCancel: () => void
|
||||
onSubmit: (values: ImportWalletConfirmationFormValues) => Promise<void>
|
||||
}
|
||||
|
||||
const ImportWalletConfirmation = ({
|
||||
walletDetails,
|
||||
importDetails,
|
||||
submitButtonText,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ImportWalletConfirmationProps) => {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const walletInfo = useMemo<CreatedWalletInfo>(
|
||||
() => ({
|
||||
walletFileName: walletDisplayNameToFileName(walletDetails.walletName),
|
||||
password: walletDetails.password,
|
||||
seedphrase: importDetails.mnemonicPhrase.join(' '),
|
||||
}),
|
||||
[walletDetails, importDetails],
|
||||
)
|
||||
|
||||
const showGaplimitWarning = useMemo(() => importDetails.gaplimit > GAPLIMIT_WARN_THRESHOLD, [importDetails])
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
walletDetails,
|
||||
importDetails,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ handleSubmit, values, isSubmitting, submitCount }) => (
|
||||
<rb.Form onSubmit={handleSubmit} noValidate lang={i18n.resolvedLanguage || i18n.language}>
|
||||
<WalletCreationInfoSummary walletInfo={walletInfo} revealSensitiveInfo={!isSubmitting && submitCount === 0} />
|
||||
|
||||
<Accordion
|
||||
title={t('import_wallet.import_details.import_options')}
|
||||
variant={showGaplimitWarning ? 'warning' : undefined}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<div>{t('import_wallet.import_details.label_blockheight')}</div>
|
||||
<div className="text-secondary small">{t('import_wallet.import_details.description_blockheight')}</div>
|
||||
<div className="fs-4">{values.importDetails.blockheight}</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div>{t('import_wallet.import_details.label_gaplimit')}</div>
|
||||
<div className="text-secondary small">{t('import_wallet.import_details.description_gaplimit')}</div>
|
||||
<div className="fs-4">{values.importDetails.gaplimit}</div>
|
||||
|
||||
{showGaplimitWarning && (
|
||||
<rb.Alert variant="warning" className="d-flex align-items-center mt-2">
|
||||
{t('import_wallet.import_details.alert_high_gaplimit_value')}
|
||||
</rb.Alert>
|
||||
)}
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<rb.Button className="w-100 mb-4" variant="dark" size="lg" type="submit" disabled={isSubmitting}>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
{isSubmitting && (
|
||||
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
|
||||
)}
|
||||
{submitButtonText(isSubmitting)}
|
||||
</div>
|
||||
</rb.Button>
|
||||
|
||||
{isSubmitting && (
|
||||
<div className="text-center text-muted small mb-4">
|
||||
<p>{t('create_wallet.hint_duration_text')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex mb-4 gap-4">
|
||||
<rb.Button variant="none" hidden={isSubmitting} disabled={isSubmitting} onClick={() => onCancel()}>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<Sprite symbol="arrow-left" width="20" height="20" className="me-2" />
|
||||
{t('global.back')}
|
||||
</div>
|
||||
</rb.Button>
|
||||
</div>
|
||||
</rb.Form>
|
||||
)}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
|
||||
enum ImportWalletSteps {
|
||||
wallet_details,
|
||||
import_details,
|
||||
confirm_and_submit,
|
||||
success,
|
||||
}
|
||||
|
||||
interface ImportWalletProps {
|
||||
parentRoute: Route
|
||||
startWallet: (name: Api.WalletFileName, auth: Api.ApiAuthContext) => void
|
||||
}
|
||||
|
||||
export default function ImportWallet({ parentRoute, startWallet }: ImportWalletProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const serviceInfo = useServiceInfo()
|
||||
const dispatchServiceInfo = useDispatchServiceInfo()
|
||||
const refreshConfigValues = useRefreshConfigValues()
|
||||
const updateConfigValues = useUpdateConfigValues()
|
||||
|
||||
const [alert, setAlert] = useState<SimpleAlert>()
|
||||
const [createWalletFormValues, setCreateWalletFormValues] = useState<CreateWalletFormValues>()
|
||||
const [importDetailsFormValues, setImportDetailsFormValues] = useState<ImportWalletDetailsFormValues>()
|
||||
const [recoveredWallet, setRecoveredWallet] = useState<RecoveredWalletWithAuth>()
|
||||
|
||||
const isRecovered = useMemo(() => !!recoveredWallet?.walletFileName && !!recoveredWallet?.auth, [recoveredWallet])
|
||||
const canRecover = useMemo(
|
||||
() => !isRecovered && !serviceInfo?.walletFileName && !serviceInfo?.rescanning,
|
||||
[isRecovered, serviceInfo],
|
||||
)
|
||||
|
||||
const [step, setStep] = useState<ImportWalletSteps>(ImportWalletSteps.wallet_details)
|
||||
const nextStep = useCallback(
|
||||
() =>
|
||||
setStep((old) => {
|
||||
switch (old) {
|
||||
case ImportWalletSteps.wallet_details:
|
||||
return ImportWalletSteps.import_details
|
||||
case ImportWalletSteps.import_details:
|
||||
return ImportWalletSteps.confirm_and_submit
|
||||
case ImportWalletSteps.confirm_and_submit:
|
||||
return ImportWalletSteps.success
|
||||
default:
|
||||
return old
|
||||
}
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const previousStep = useCallback(() => {
|
||||
setAlert(undefined)
|
||||
setStep((old) => {
|
||||
switch (old) {
|
||||
case ImportWalletSteps.import_details:
|
||||
return ImportWalletSteps.wallet_details
|
||||
case ImportWalletSteps.confirm_and_submit:
|
||||
return ImportWalletSteps.import_details
|
||||
case ImportWalletSteps.success:
|
||||
return ImportWalletSteps.success // cannot go back from success page
|
||||
default:
|
||||
return old
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const recoverWallet = useCallback(
|
||||
async (
|
||||
signal: AbortSignal,
|
||||
{
|
||||
walletname,
|
||||
password,
|
||||
seedphrase,
|
||||
gaplimit,
|
||||
blockheight,
|
||||
}: {
|
||||
walletname: Api.WalletFileName
|
||||
password: string
|
||||
seedphrase: string
|
||||
gaplimit: number
|
||||
blockheight: number
|
||||
},
|
||||
) => {
|
||||
setAlert(undefined)
|
||||
|
||||
try {
|
||||
// Step #1: recover wallet
|
||||
const recoverResponse = await Api.postWalletRecover({ signal }, { walletname, password, seedphrase })
|
||||
const recoverBody = await (recoverResponse.ok ? recoverResponse.json() : Api.Helper.throwError(recoverResponse))
|
||||
|
||||
const { walletname: walletFileName } = recoverBody
|
||||
let auth: Api.ApiAuthContext = Api.Helper.parseAuthProps(recoverBody)
|
||||
setRecoveredWallet({ walletFileName, auth })
|
||||
|
||||
// Step #2: update the gaplimit config value if necessary
|
||||
const originalGaplimit = await refreshConfigValues({
|
||||
signal,
|
||||
keys: [JM_GAPLIMIT_CONFIGKEY],
|
||||
wallet: { walletFileName, token: auth.token },
|
||||
})
|
||||
.then((it) => it[JM_GAPLIMIT_CONFIGKEY.section] || {})
|
||||
.then((it) => parseInt(it[JM_GAPLIMIT_CONFIGKEY.field] || String(JM_GAPLIMIT_DEFAULT), 10))
|
||||
.then((it) => it || JM_GAPLIMIT_DEFAULT)
|
||||
|
||||
const gaplimitUpdateNecessary = gaplimit !== originalGaplimit
|
||||
if (gaplimitUpdateNecessary) {
|
||||
console.info('Will update gaplimit from %d to %d', originalGaplimit, gaplimit)
|
||||
|
||||
await updateConfigValues({
|
||||
signal,
|
||||
updates: [
|
||||
{
|
||||
key: JM_GAPLIMIT_CONFIGKEY,
|
||||
value: String(gaplimit),
|
||||
},
|
||||
],
|
||||
wallet: { walletFileName, token: auth.token },
|
||||
})
|
||||
}
|
||||
|
||||
// Step #3: lock and unlock the wallet (for new addresses to be imported)
|
||||
const lockResponse = await Api.getWalletLock({ walletFileName, token: auth.token })
|
||||
if (!lockResponse.ok) await Api.Helper.throwError(lockResponse)
|
||||
|
||||
const unlockResponse = await Api.postWalletUnlock({ walletFileName }, { password })
|
||||
const unlockBody = await (unlockResponse.ok ? unlockResponse.json() : Api.Helper.throwError(unlockResponse))
|
||||
|
||||
auth = Api.Helper.parseAuthProps(unlockBody)
|
||||
|
||||
// Step #4: reset `gaplimit´ to previous value if necessary
|
||||
if (gaplimitUpdateNecessary) {
|
||||
console.info('Will reset gaplimit to previous value %d', originalGaplimit)
|
||||
await updateConfigValues({
|
||||
signal,
|
||||
updates: [
|
||||
{
|
||||
key: JM_GAPLIMIT_CONFIGKEY,
|
||||
value: String(originalGaplimit),
|
||||
},
|
||||
],
|
||||
wallet: { walletFileName, token: auth.token },
|
||||
})
|
||||
}
|
||||
|
||||
// Step #5: invoke rescanning the timechain
|
||||
console.info('Will start rescanning timechain from block %d', blockheight)
|
||||
|
||||
const rescanResponse = await Api.getRescanBlockchain({
|
||||
signal,
|
||||
walletFileName,
|
||||
token: unlockBody.token,
|
||||
blockheight,
|
||||
})
|
||||
if (!rescanResponse.ok) {
|
||||
await Api.Helper.throwError(rescanResponse)
|
||||
} else {
|
||||
dispatchServiceInfo({
|
||||
rescanning: true,
|
||||
})
|
||||
}
|
||||
|
||||
startWallet(walletFileName, auth)
|
||||
nextStep()
|
||||
} catch (e: any) {
|
||||
if (signal.aborted) return
|
||||
const message = t('import_wallet.error_importing_failed', {
|
||||
reason: e.message || t('global.errors.reason_unknown'),
|
||||
})
|
||||
setAlert({ variant: 'danger', message })
|
||||
}
|
||||
},
|
||||
[
|
||||
setRecoveredWallet,
|
||||
startWallet,
|
||||
nextStep,
|
||||
setAlert,
|
||||
refreshConfigValues,
|
||||
updateConfigValues,
|
||||
dispatchServiceInfo,
|
||||
t,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="import-wallet">
|
||||
<>
|
||||
{step === ImportWalletSteps.wallet_details && <PageTitle title={t('import_wallet.wallet_details.title')} />}
|
||||
{step === ImportWalletSteps.import_details && (
|
||||
<PageTitle
|
||||
title={t('import_wallet.import_details.title')}
|
||||
subtitle={t('import_wallet.import_details.subtitle')}
|
||||
/>
|
||||
)}
|
||||
{step === ImportWalletSteps.confirm_and_submit && <PageTitle title={t('import_wallet.confirmation.title')} />}
|
||||
{step === ImportWalletSteps.success && (
|
||||
<PageTitle
|
||||
title={t('import_wallet.success.title')}
|
||||
subtitle={t('import_wallet.success.subtitle')}
|
||||
success={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
|
||||
{!canRecover && !isRecovered ? (
|
||||
<>
|
||||
{serviceInfo?.walletFileName && (
|
||||
<rb.Alert variant="warning">
|
||||
<Trans
|
||||
i18nKey="import_wallet.alert_other_wallet_unlocked"
|
||||
values={{
|
||||
walletName: walletDisplayName(serviceInfo.walletFileName),
|
||||
}}
|
||||
>
|
||||
Currently <strong>walletName</strong> is active. You need to lock it first.
|
||||
<Link to={routes.walletList} className="alert-link">
|
||||
Go back
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</rb.Alert>
|
||||
)}
|
||||
{serviceInfo?.rescanning === true && (
|
||||
<rb.Alert variant="warning" data-testid="alert-rescanning">
|
||||
<Trans i18nKey="import_wallet.alert_rescan_in_progress">
|
||||
Rescanning the timechain is currently in progress. Please wait until the process finishes and then try
|
||||
again.
|
||||
<Link to={routes.walletList} className="alert-link">
|
||||
Go back
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</rb.Alert>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{step !== ImportWalletSteps.success && <PreventLeavingPageByMistake />}
|
||||
{step === ImportWalletSteps.wallet_details && (
|
||||
<WalletCreationForm
|
||||
initialValues={createWalletFormValues}
|
||||
onCancel={() => navigate(routes[parentRoute])}
|
||||
onSubmit={async (values) => {
|
||||
setCreateWalletFormValues(values)
|
||||
nextStep()
|
||||
}}
|
||||
submitButtonText={(isSubmitting) =>
|
||||
t(
|
||||
isSubmitting
|
||||
? 'import_wallet.wallet_details.text_button_submitting'
|
||||
: 'import_wallet.wallet_details.text_button_submit',
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{step === ImportWalletSteps.import_details && (
|
||||
<ImportWalletDetailsForm
|
||||
initialValues={importDetailsFormValues}
|
||||
submitButtonText={(isSubmitting) =>
|
||||
t(
|
||||
isSubmitting
|
||||
? 'import_wallet.import_details.text_button_submitting'
|
||||
: 'import_wallet.import_details.text_button_submit',
|
||||
)
|
||||
}
|
||||
onCancel={() => previousStep()}
|
||||
onSubmit={async (values) => {
|
||||
setImportDetailsFormValues(values)
|
||||
nextStep()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === ImportWalletSteps.confirm_and_submit && (
|
||||
<ImportWalletConfirmation
|
||||
walletDetails={createWalletFormValues!}
|
||||
importDetails={importDetailsFormValues!}
|
||||
submitButtonText={(isSubmitting) =>
|
||||
t(
|
||||
isSubmitting
|
||||
? 'import_wallet.confirmation.text_button_submitting'
|
||||
: 'import_wallet.confirmation.text_button_submit',
|
||||
)
|
||||
}
|
||||
onCancel={() => previousStep()}
|
||||
onSubmit={(values) => {
|
||||
const abortCtrl = new AbortController()
|
||||
|
||||
return recoverWallet(abortCtrl.signal, {
|
||||
walletname: walletDisplayNameToFileName(values.walletDetails.walletName),
|
||||
password: values.walletDetails.password,
|
||||
seedphrase: values.importDetails.mnemonicPhrase.join(' '),
|
||||
gaplimit: values.importDetails.gaplimit,
|
||||
blockheight: values.importDetails.blockheight,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === ImportWalletSteps.success && (
|
||||
<div className="d-flex justify-content-center my-4 gap-4">
|
||||
<Link
|
||||
className="btn btn-lg btn-dark d-inline-flex justify-content-center align-items-center"
|
||||
to={routes.wallet}
|
||||
>
|
||||
{t('import_wallet.success.text_button_submit')}
|
||||
<Sprite symbol="arrow-right" width="20" height="20" className="ms-2" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
.input {
|
||||
height: 3.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-loader {
|
||||
height: 3.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.walletLink {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
|
@ -1,602 +0,0 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Formik, FormikErrors, FormikValues, useFormikContext } from 'formik'
|
||||
import * as Api from '../libs/JmWalletApi'
|
||||
import { useSettings } from '../context/SettingsContext'
|
||||
import { useServiceInfo, useReloadServiceInfo, Schedule, StateFlag } from '../context/ServiceInfoContext'
|
||||
import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo, WalletInfo } from '../context/WalletContext'
|
||||
import { isDebugFeatureEnabled } from '../constants/debugFeatures'
|
||||
import { buildCoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
|
||||
import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
|
||||
import PageTitle from './PageTitle'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import Sprite from './Sprite'
|
||||
import Balance from './Balance'
|
||||
import ScheduleProgress from './ScheduleProgress'
|
||||
import { ConfirmModal, ConfirmModalProps } from './Modal'
|
||||
import FeeConfigModal from './settings/FeeConfigModal'
|
||||
import { useFeeConfigValues } from '../hooks/Fees'
|
||||
import styles from './Jam.module.css'
|
||||
|
||||
const DEST_ADDRESS_COUNT_PROD = 3
|
||||
const DEST_ADDRESS_COUNT_TEST = 1
|
||||
|
||||
const getNewAddressesForTesting = (
|
||||
walletInfo: WalletInfo,
|
||||
count: number,
|
||||
mixdepth: number,
|
||||
): Array<Api.BitcoinAddress> => {
|
||||
const externalBranch = walletInfo.data.display.walletinfo.accounts[mixdepth].branches.find((branch) => {
|
||||
return branch.branch.split('\t')[0] === 'external addresses'
|
||||
})
|
||||
|
||||
const newEntries = (externalBranch?.entries || []).filter((entry) => entry.status === 'new').slice(0, count)
|
||||
|
||||
if (newEntries.length !== count) {
|
||||
throw new Error(`Cannot find enough fresh addresses in mixdepth ${mixdepth}`)
|
||||
}
|
||||
|
||||
return newEntries.map((it) => it.address)
|
||||
}
|
||||
|
||||
const getNewAddressesForTestingOrEmpty = (
|
||||
walletInfo: WalletInfo | undefined,
|
||||
count: number,
|
||||
mixdepth = 0,
|
||||
): Array<Api.BitcoinAddress | ''> => {
|
||||
if (!walletInfo) {
|
||||
return Array(count).fill('')
|
||||
}
|
||||
|
||||
try {
|
||||
return getNewAddressesForTesting(walletInfo, count, mixdepth)
|
||||
} catch (e) {
|
||||
console.log('Error while getting test addresses', e)
|
||||
return Array(count).fill('')
|
||||
}
|
||||
}
|
||||
|
||||
const addressValueKeys = (addressCount: number) =>
|
||||
Array(addressCount)
|
||||
.fill('')
|
||||
.map((_, index) => `dest${index + 1}`)
|
||||
|
||||
const isValidAddress = (candidate: any) => {
|
||||
return typeof candidate === 'string' && candidate !== ''
|
||||
}
|
||||
|
||||
const isAddressReused = (
|
||||
walletInfo: WalletInfo,
|
||||
destination: Api.BitcoinAddress,
|
||||
inputAddresses: Api.BitcoinAddress[],
|
||||
) => {
|
||||
if (!destination) return false
|
||||
|
||||
const knownAddress = walletInfo.addressSummary[destination] || false
|
||||
const alreadyUsed = knownAddress && walletInfo.addressSummary[destination]?.status !== 'new'
|
||||
const duplicateEntry = inputAddresses.filter((it) => it === destination).length > 1
|
||||
|
||||
return alreadyUsed || duplicateEntry
|
||||
}
|
||||
|
||||
interface ValueListenerProps {
|
||||
handler: (values?: any) => Promise<FormikErrors<any>>
|
||||
addressCount: number
|
||||
}
|
||||
|
||||
const ValuesListener = ({ handler, addressCount }: ValueListenerProps) => {
|
||||
const { values } = useFormikContext<any>()
|
||||
|
||||
useEffect(() => {
|
||||
const allValuesPresent = addressValueKeys(addressCount)
|
||||
.map((key) => values[key])
|
||||
.every((val) => val !== '')
|
||||
if (allValuesPresent) {
|
||||
handler()
|
||||
}
|
||||
}, [values, handler, addressCount])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function useLatestTruthy<T>(val: T): [T | undefined, () => void] {
|
||||
const [prev, setPrev] = useState<T | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!!val) {
|
||||
setPrev(val)
|
||||
}
|
||||
}, [val])
|
||||
|
||||
return [prev, () => setPrev(undefined)]
|
||||
}
|
||||
|
||||
interface SchedulerSuccessMessageProps {
|
||||
schedule: Schedule
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function SchedulerSuccessMessage({ schedule, onConfirm }: SchedulerSuccessMessageProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
success={true}
|
||||
center={true}
|
||||
title={t('scheduler.success.title')}
|
||||
subtitle={t('scheduler.success.subtitle', { count: schedule.length })}
|
||||
/>
|
||||
|
||||
<div className="d-flex justify-content-center">
|
||||
<rb.Button
|
||||
variant="outline-dark"
|
||||
className="border-0 mb-2 d-inline-flex align-items-center"
|
||||
onClick={() => onConfirm()}
|
||||
>
|
||||
{t('scheduler.success.text_button_submit')}
|
||||
<Sprite symbol="caret-right" width="24" height="24" className="ms-1" />
|
||||
</rb.Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ScheduleConfirmModalProps = Omit<ConfirmModalProps, 'title'>
|
||||
|
||||
function ScheduleConfirmModal(props: ScheduleConfirmModalProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<ConfirmModal title={t('scheduler.confirm_modal.title')} {...props}>
|
||||
{t('scheduler.confirm_modal.body')}
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||
|
||||
interface JamProps {
|
||||
wallet: CurrentWallet
|
||||
}
|
||||
|
||||
export default function Jam({ wallet }: JamProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
const serviceInfo = useServiceInfo()
|
||||
const reloadServiceInfo = useReloadServiceInfo()
|
||||
const walletInfo = useCurrentWalletInfo()
|
||||
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
|
||||
|
||||
const [alert, setAlert] = useState<SimpleAlert>()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showFeeConfigModal, setShowFeeConfigModal] = useState(false)
|
||||
const [isWaitingSchedulerStart, setIsWaitingSchedulerStart] = useState(false)
|
||||
const [isWaitingSchedulerStop, setIsWaitingSchedulerStop] = useState(false)
|
||||
const [currentSchedule, setCurrentSchedule] = useState<Schedule | null>(null)
|
||||
const [lastKnownSchedule, resetLastKnownSchedule] = useLatestTruthy(currentSchedule ?? undefined)
|
||||
const [isShowSuccessMessage, setIsShowSuccessMessage] = useState(false)
|
||||
const [feeConfigValues, reloadFeeConfigValues] = useFeeConfigValues()
|
||||
const [showScheduleConfirmModal, setShowScheduleConfirmModal] = useState(false)
|
||||
const maxFeesConfigMissing = useMemo(
|
||||
() =>
|
||||
feeConfigValues && (feeConfigValues.max_cj_fee_abs === undefined || feeConfigValues.max_cj_fee_rel === undefined),
|
||||
[feeConfigValues],
|
||||
)
|
||||
|
||||
const isRescanningInProgress = useMemo(() => serviceInfo?.rescanning === true, [serviceInfo])
|
||||
|
||||
const collaborativeOperationRunning = useMemo(
|
||||
() => serviceInfo?.coinjoinInProgress || serviceInfo?.makerRunning || false,
|
||||
[serviceInfo],
|
||||
)
|
||||
|
||||
const isOperationDisabled = useMemo(
|
||||
() => maxFeesConfigMissing || collaborativeOperationRunning || isRescanningInProgress,
|
||||
[maxFeesConfigMissing, collaborativeOperationRunning, isRescanningInProgress],
|
||||
)
|
||||
|
||||
const schedulerPreconditionSummary = useMemo(
|
||||
() => buildCoinjoinRequirementSummary(walletInfo?.data.utxos.utxos || []),
|
||||
[walletInfo],
|
||||
)
|
||||
|
||||
const [useInsecureTestingSettings, setUseInsecureTestingSettings] = useState(false)
|
||||
const addressCount = useMemo(
|
||||
() => (useInsecureTestingSettings ? DEST_ADDRESS_COUNT_TEST : DEST_ADDRESS_COUNT_PROD),
|
||||
[useInsecureTestingSettings],
|
||||
)
|
||||
|
||||
const initialFormValues = useMemo<FormikValues>(() => {
|
||||
let destinationAddresses: Array<Api.BitcoinAddress | ''> = Array(addressCount).fill('')
|
||||
if (useInsecureTestingSettings) {
|
||||
// prefill with addresses marked as "new"
|
||||
destinationAddresses = getNewAddressesForTestingOrEmpty(walletInfo, addressCount)
|
||||
}
|
||||
|
||||
return destinationAddresses.reduce((obj, addr, index) => ({ ...obj, [`dest${index + 1}`]: addr }), {})
|
||||
}, [addressCount, useInsecureTestingSettings, walletInfo])
|
||||
|
||||
const reloadData = useCallback(
|
||||
({ signal }: { signal: AbortSignal }) => {
|
||||
setAlert(undefined)
|
||||
setIsLoading(true)
|
||||
|
||||
return Promise.all([reloadServiceInfo({ signal }), reloadCurrentWalletInfo.reloadUtxos({ signal })])
|
||||
.catch((err) => {
|
||||
if (signal.aborted) return
|
||||
// reusing "wallet failed" message here is okay, as session info also contains wallet information
|
||||
const message = t('global.errors.error_loading_wallet_failed', {
|
||||
reason: err.message || t('global.errors.reason_unknown'),
|
||||
})
|
||||
setAlert({ variant: 'danger', message })
|
||||
})
|
||||
.finally(() => {
|
||||
if (signal.aborted) return
|
||||
setIsLoading(false)
|
||||
})
|
||||
},
|
||||
[reloadServiceInfo, reloadCurrentWalletInfo, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const abortCtrl = new AbortController()
|
||||
reloadData({ signal: abortCtrl.signal })
|
||||
return () => {
|
||||
abortCtrl.abort()
|
||||
}
|
||||
}, [collaborativeOperationRunning, reloadData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!serviceInfo) return
|
||||
|
||||
const scheduleUpdate = serviceInfo.schedule
|
||||
setCurrentSchedule(scheduleUpdate)
|
||||
|
||||
setIsWaitingSchedulerStart((current) => (current && scheduleUpdate ? false : current))
|
||||
setIsWaitingSchedulerStop((current) => (current && !scheduleUpdate ? false : current))
|
||||
|
||||
if (scheduleUpdate && process.env.NODE_ENV === 'development') {
|
||||
console.table(scheduleUpdate)
|
||||
}
|
||||
}, [serviceInfo])
|
||||
|
||||
useEffect(() => {
|
||||
const stillRunningOrManualAbort =
|
||||
!walletInfo || isWaitingSchedulerStop || currentSchedule !== null || lastKnownSchedule === undefined
|
||||
|
||||
if (stillRunningOrManualAbort) {
|
||||
setIsShowSuccessMessage(false)
|
||||
} else {
|
||||
const isInMempoolOrSuccess = (it: StateFlag) => it === 1 || typeof it === 'string'
|
||||
|
||||
const firstEntriesSuccess = lastKnownSchedule
|
||||
.slice(0, -1)
|
||||
.map((it) => it[6])
|
||||
.every((it) => it === 1 || typeof it === 'string')
|
||||
|
||||
const lastEntryState = lastKnownSchedule[lastKnownSchedule.length - 1][6]
|
||||
const lastEntrySuccess = isInMempoolOrSuccess(lastEntryState)
|
||||
|
||||
// Workaround to prevent race conditions: Since the schedule info is polled,
|
||||
// it'll be possible that the latest known state still has the success flag
|
||||
// of the last entry set to `0`, although the schedule was completed successfully.
|
||||
// In this case, additionally check that every remaining UTXO is frozen
|
||||
// (indicating the opteration was successfully completed).
|
||||
// Hint: In dev mode, this will only work if you send coins to an external wallet.
|
||||
const allUtxosFrozen = walletInfo.data.utxos.utxos.every((it) => it.frozen)
|
||||
|
||||
setIsShowSuccessMessage(firstEntriesSuccess && (lastEntrySuccess || allUtxosFrozen))
|
||||
}
|
||||
}, [currentSchedule, lastKnownSchedule, isWaitingSchedulerStop, walletInfo])
|
||||
|
||||
const startSchedule = async (values: FormikValues) => {
|
||||
if (isLoading || collaborativeOperationRunning || isOperationDisabled) {
|
||||
return
|
||||
}
|
||||
|
||||
setAlert(undefined)
|
||||
setShowScheduleConfirmModal(false)
|
||||
setIsWaitingSchedulerStart(true)
|
||||
|
||||
const destinations = addressValueKeys(addressCount).map((key) => values[key])
|
||||
|
||||
const body: Api.StartSchedulerRequest = {
|
||||
destination_addresses: destinations,
|
||||
}
|
||||
|
||||
// Make sure schedule testing is really only used in dev mode.
|
||||
if (isDebugFeatureEnabled('insecureScheduleTesting') && useInsecureTestingSettings) {
|
||||
// for a proper description of all parameters see
|
||||
// https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.8/jmclient/jmclient/cli_options.py#L268
|
||||
body.tumbler_options = {
|
||||
addrcount: addressCount,
|
||||
minmakercount: 1,
|
||||
makercountrange: [1, 0],
|
||||
mixdepthcount: addressCount,
|
||||
mintxcount: 1,
|
||||
txcountparams: [1, 0],
|
||||
timelambda: 0.025, // 0.025 minutes := 1.5 seconds
|
||||
stage1_timelambda_increase: 1.0,
|
||||
liquiditywait: 13,
|
||||
waittime: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
return Api.postSchedulerStart({ ...wallet, signal: abortCtrl.signal }, body)
|
||||
.then((res) => (res.ok ? true : Api.Helper.throwError(res, t('scheduler.error_starting_schedule_failed'))))
|
||||
.then((_) => reloadServiceInfo({ signal: abortCtrl.signal }))
|
||||
.catch((err) => {
|
||||
if (abortCtrl.signal.aborted) return
|
||||
setAlert({ variant: 'danger', message: err.message })
|
||||
setIsWaitingSchedulerStart(false)
|
||||
})
|
||||
}
|
||||
|
||||
const stopSchedule = async () => {
|
||||
if (isLoading || !collaborativeOperationRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
setAlert(undefined)
|
||||
setIsWaitingSchedulerStop(true)
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
return Api.getTakerStop({ ...wallet, signal: abortCtrl.signal })
|
||||
.then((res) => (res.ok ? true : Api.Helper.throwError(res, t('scheduler.error_stopping_schedule_failed'))))
|
||||
.then((_) => reloadServiceInfo({ signal: abortCtrl.signal }))
|
||||
.catch((err) => {
|
||||
if (abortCtrl.signal.aborted) return
|
||||
setAlert({ variant: 'danger', message: err.message })
|
||||
setIsWaitingSchedulerStop(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={t('scheduler.title')} subtitle={t('scheduler.subtitle')} />
|
||||
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
|
||||
{isLoading || !serviceInfo || !walletInfo || isWaitingSchedulerStart || isWaitingSchedulerStop ? (
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder xs={12} className={styles['input-loader']} />
|
||||
</rb.Placeholder>
|
||||
) : (
|
||||
<>
|
||||
{collaborativeOperationRunning ? (
|
||||
<>
|
||||
{!currentSchedule ? (
|
||||
<rb.Alert variant="info">{t('send.text_coinjoin_already_running')}</rb.Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<ScheduleProgress schedule={currentSchedule} />
|
||||
</div>
|
||||
|
||||
<rb.Button
|
||||
className="w-100 mb-4"
|
||||
variant="dark"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
onClick={async () => {
|
||||
await stopSchedule()
|
||||
}}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">{t('scheduler.button_stop')}</div>
|
||||
</rb.Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isShowSuccessMessage && lastKnownSchedule ? (
|
||||
<div className="py-4">
|
||||
<SchedulerSuccessMessage
|
||||
schedule={lastKnownSchedule}
|
||||
onConfirm={async () => {
|
||||
setIsShowSuccessMessage(false)
|
||||
resetLastKnownSchedule()
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
await reloadData({ signal: abortCtrl.signal })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{maxFeesConfigMissing && (
|
||||
<rb.Alert className="slashed-zeroes" variant="danger">
|
||||
{t('send.taker_error_message_max_fees_config_missing')}
|
||||
|
||||
<rb.Alert.Link onClick={() => setShowFeeConfigModal(true)}>
|
||||
{t('settings.show_fee_config')}
|
||||
</rb.Alert.Link>
|
||||
</rb.Alert>
|
||||
)}
|
||||
<rb.Fade
|
||||
in={!schedulerPreconditionSummary.isFulfilled}
|
||||
mountOnEnter={true}
|
||||
unmountOnExit={true}
|
||||
className="mb-4"
|
||||
>
|
||||
<CoinjoinPreconditionViolationAlert
|
||||
summary={schedulerPreconditionSummary}
|
||||
i18nPrefix="scheduler.precondition."
|
||||
/>
|
||||
</rb.Fade>
|
||||
|
||||
<div className="d-flex align-items-center justify-content-between mb-4">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Sprite symbol="checkmark" width="25" height="25" className="text-secondary" />
|
||||
<div className="d-flex flex-column">
|
||||
<div>{t('scheduler.complete_wallet_title')}</div>
|
||||
<div className="text-secondary text-small">{t('scheduler.complete_wallet_subtitle')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<Balance
|
||||
valueString={`${walletInfo.balanceSummary.calculatedAvailableBalanceInSats}`}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={settings.showBalance}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
<p className="text-secondary mb-4">{t('scheduler.description_destination_addresses')}</p>
|
||||
|
||||
<Formik
|
||||
initialValues={initialFormValues}
|
||||
validate={(values) => {
|
||||
const errors = {} as FormikErrors<FormikValues>
|
||||
|
||||
const addressDict = addressValueKeys(addressCount).map((key) => {
|
||||
return {
|
||||
key,
|
||||
address: values[key],
|
||||
}
|
||||
})
|
||||
const addresses = addressDict.map((it) => it.address)
|
||||
|
||||
addressDict.forEach((addressEntry) => {
|
||||
if (!isValidAddress(addressEntry.address)) {
|
||||
errors[addressEntry.key] = t('scheduler.feedback_invalid_destination_address') as string
|
||||
} else if (isAddressReused(walletInfo, addressEntry.address, addresses)) {
|
||||
errors[addressEntry.key] = t('scheduler.feedback_reused_destination_address') as string
|
||||
}
|
||||
})
|
||||
|
||||
return errors
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
await startSchedule(values)
|
||||
}}
|
||||
>
|
||||
{({
|
||||
values,
|
||||
isSubmitting,
|
||||
handleSubmit,
|
||||
handleBlur,
|
||||
handleChange,
|
||||
setFieldValue,
|
||||
validateForm,
|
||||
isValid,
|
||||
touched,
|
||||
errors,
|
||||
}) => (
|
||||
<>
|
||||
<ValuesListener handler={validateForm} addressCount={addressCount} />
|
||||
<rb.Form onSubmit={handleSubmit} noValidate>
|
||||
{isDebugFeatureEnabled('insecureScheduleTesting') && (
|
||||
<rb.Form.Group className="mb-4" controlId="offertype">
|
||||
<ToggleSwitch
|
||||
label={
|
||||
<>
|
||||
Use insecure testing settings
|
||||
<span className="ms-2 badge rounded-pill bg-warning">dev</span>
|
||||
</>
|
||||
}
|
||||
subtitle={
|
||||
"This is completely insecure but makes testing the schedule much faster. This option won't be available in production."
|
||||
}
|
||||
toggledOn={useInsecureTestingSettings}
|
||||
onToggle={async (isToggled) => {
|
||||
setUseInsecureTestingSettings(isToggled)
|
||||
if (isToggled) {
|
||||
try {
|
||||
const newAddresses = getNewAddressesForTestingOrEmpty(
|
||||
walletInfo,
|
||||
DEST_ADDRESS_COUNT_TEST,
|
||||
)
|
||||
newAddresses.forEach((newAddress, index) => {
|
||||
setFieldValue(`dest${index + 1}`, newAddress, true)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Could not get internal addresses.', e)
|
||||
|
||||
addressValueKeys(DEST_ADDRESS_COUNT_TEST).forEach((key) => {
|
||||
setFieldValue(key, '', true)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
addressValueKeys(DEST_ADDRESS_COUNT_PROD).forEach((key) => {
|
||||
setFieldValue(key, '', false)
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={isOperationDisabled || isSubmitting}
|
||||
/>
|
||||
</rb.Form.Group>
|
||||
)}
|
||||
{addressValueKeys(addressCount).map((key, index) => {
|
||||
return (
|
||||
<rb.Form.Group className="mb-4" key={key} controlId={key}>
|
||||
<rb.Form.Label>
|
||||
{t('scheduler.label_destination_input', { destination: index + 1 })}
|
||||
</rb.Form.Label>
|
||||
<rb.Form.Control
|
||||
name={key}
|
||||
value={values[key]}
|
||||
placeholder={t('scheduler.placeholder_destination_input')}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
isInvalid={touched[key] && !!errors[key]}
|
||||
className={`${styles.input} slashed-zeroes`}
|
||||
disabled={isOperationDisabled || isSubmitting}
|
||||
/>
|
||||
<rb.Form.Control.Feedback type="invalid">
|
||||
<>{errors[key]}</>
|
||||
</rb.Form.Control.Feedback>
|
||||
</rb.Form.Group>
|
||||
)
|
||||
})}
|
||||
|
||||
<p className="text-secondary mb-4">{t('scheduler.description_fees')}</p>
|
||||
<rb.Button
|
||||
className="w-100 mb-4"
|
||||
variant="dark"
|
||||
size="lg"
|
||||
disabled={isOperationDisabled || isSubmitting || !isValid}
|
||||
onClick={() => setShowScheduleConfirmModal(true)}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
{t('scheduler.button_start')}
|
||||
<Sprite symbol="caret-right" width="24" height="24" className="ms-1" />
|
||||
</div>
|
||||
</rb.Button>
|
||||
<ScheduleConfirmModal
|
||||
isShown={showScheduleConfirmModal}
|
||||
onCancel={() => setShowScheduleConfirmModal(false)}
|
||||
onConfirm={handleSubmit}
|
||||
disabled={isOperationDisabled || isSubmitting || !isValid}
|
||||
/>
|
||||
</rb.Form>
|
||||
</>
|
||||
)}
|
||||
</Formik>
|
||||
|
||||
<rb.Row className="mt-2 mb-4">
|
||||
<rb.Col className="d-flex justify-content-center">
|
||||
<rb.Button
|
||||
variant="outline-dark"
|
||||
className="border-0 mb-2 d-inline-flex align-items-center"
|
||||
onClick={() => setShowFeeConfigModal(true)}
|
||||
>
|
||||
<Sprite symbol="coins" width="24" height="24" className="me-1" />
|
||||
{t('settings.show_fee_config')}
|
||||
</rb.Button>
|
||||
{showFeeConfigModal && (
|
||||
<FeeConfigModal
|
||||
show={showFeeConfigModal}
|
||||
onSuccess={() => reloadFeeConfigValues()}
|
||||
onHide={() => setShowFeeConfigModal(false)}
|
||||
defaultActiveSectionKey={'cj_fee'}
|
||||
/>
|
||||
)}
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
115
src/components/JamLanding.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { Card } from './ui/card'
|
||||
import { Button } from './ui/button'
|
||||
import { Jar } from './layout/Jar'
|
||||
import { Info, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip'
|
||||
import { Alert, AlertDescription } from './ui/alert'
|
||||
import { useJamDisplayContext } from './layout/display-mode-context'
|
||||
|
||||
export default function JamLanding() {
|
||||
const {
|
||||
displayMode,
|
||||
toggleDisplayMode,
|
||||
formatAmount,
|
||||
getLogo,
|
||||
jars,
|
||||
totalBalance,
|
||||
isLoading,
|
||||
error,
|
||||
refetchWalletData,
|
||||
} = useJamDisplayContext()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-lg opacity-80 text-gray-400">{displayMode === 'btc' ? 'Bitcoin' : 'Satoshi'}</div>
|
||||
<div className="text-4xl font-light tracking-wider mb-2 flex justify-center items-center cursor-pointer select-none min-h-[56px]">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[56px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
onClick={toggleDisplayMode}
|
||||
title="Click to toggle sats/bitcoin"
|
||||
className="tabular-nums min-w-[250px] text-center"
|
||||
>
|
||||
{formatAmount(totalBalance)}{' '}
|
||||
</span>
|
||||
<span className="flex items-center min-h-[48px]">{getLogo('lg')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 justify-center mt-10">
|
||||
<Button className="px-12 cursor-pointer">↓ Receive</Button>
|
||||
<Button className="px-16 cursor-pointer" variant="outline">
|
||||
↑ Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4 max-w-2xl">
|
||||
<AlertDescription>
|
||||
Error loading wallet data: {error.message}
|
||||
<Button variant="outline" size="sm" onClick={() => refetchWalletData()} className="ml-2">
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Card className="w-full max-w-2xl border-0 shadow-none text-black dark:text-white dark:bg-[#181b20] p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center w-full justify-center">
|
||||
<span className="opacity-80 font-light">Wallet distribution</span>
|
||||
<div className="text-black opacity-80 mx-3 dark:text-white">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info size={16} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Select a jar to get started</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : jars.length > 0 ? (
|
||||
jars.map((jar) => (
|
||||
<Tooltip key={jar.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex flex-col cursor-pointer hover:scale-105 transition-all duration-300 items-center">
|
||||
<Jar
|
||||
name={jar.name}
|
||||
amount={jar.balance}
|
||||
color={jar.color}
|
||||
displayMode={displayMode}
|
||||
totalBalance={totalBalance}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open {jar.name} Jar</TooltipContent>
|
||||
</Tooltip>
|
||||
))
|
||||
) : (
|
||||
<div className="flex-1 text-center py-4 text-gray-500">No accounts found in wallet</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex justify-end w-full max-w-2xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetchWalletData()}
|
||||
className="flex gap-2 items-center text-gray-500"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
:global .modal-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.modal :global .modal-content {
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border-radius: 1rem !important;
|
||||
box-shadow: 0px 0px 24px rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex !important;
|
||||
justify-content: flex-start !important;
|
||||
background-color: transparent !important;
|
||||
padding: 1.25rem !important;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
width: 100%;
|
||||
font-size: 1rem !important;
|
||||
font-weight: 400 !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
.modalTitle > div:first-child {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modalTitle .cancelButton {
|
||||
padding: 0 0 0 1rem;
|
||||
color: var(--bs-body-color);
|
||||
background-color: transparent !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
gap: 1rem;
|
||||
background-color: transparent !important;
|
||||
padding: 1rem 1.25rem 1.25rem 1.25rem !important;
|
||||
}
|
||||
|
||||
.modalFooter :global .btn {
|
||||
flex-grow: 1;
|
||||
min-height: 2.8rem;
|
||||
font-weight: 500;
|
||||
border-color: none !important;
|
||||
}
|
||||
|
||||
.jarsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
.jarsContainer {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { jarFillLevel, SelectableJar } from './jars/Jar'
|
||||
import { AccountBalances } from '../context/BalanceSummary'
|
||||
import { AmountSats } from '../libs/JmWalletApi'
|
||||
import Sprite from './Sprite'
|
||||
import styles from './JarSelectorModal.module.css'
|
||||
|
||||
interface JarSelectorModalProps {
|
||||
isShown: boolean
|
||||
title: string
|
||||
accountBalances: AccountBalances
|
||||
totalBalance: AmountSats
|
||||
disabledJar?: JarIndex
|
||||
onCancel: () => void
|
||||
onConfirm: (jarIndex: JarIndex) => Promise<void>
|
||||
}
|
||||
|
||||
export default function JarSelectorModal({
|
||||
isShown,
|
||||
title,
|
||||
accountBalances,
|
||||
totalBalance,
|
||||
disabledJar,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: JarSelectorModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isConfirming, setIsConfirming] = useState(false)
|
||||
const [selectedJar, setSelectedJar] = useState<JarIndex>()
|
||||
|
||||
const sortedAccountBalances = useMemo(() => {
|
||||
if (!accountBalances) return []
|
||||
return Object.values(accountBalances).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex)
|
||||
}, [accountBalances])
|
||||
|
||||
const cancel = () => {
|
||||
setSelectedJar(undefined)
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
if (selectedJar === undefined) return
|
||||
|
||||
setIsConfirming(true)
|
||||
onConfirm(selectedJar)
|
||||
.then(() => setSelectedJar(undefined))
|
||||
.finally(() => setIsConfirming(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<rb.Modal
|
||||
show={isShown}
|
||||
keyboard={true}
|
||||
onEscapeKeyDown={onCancel}
|
||||
onHide={onCancel}
|
||||
centered={true}
|
||||
animation={true}
|
||||
className={styles.modal}
|
||||
size="lg"
|
||||
>
|
||||
<rb.Modal.Header className={styles.modalHeader}>
|
||||
<rb.Modal.Title className={styles.modalTitle}>
|
||||
<div>
|
||||
<div>{title}</div>
|
||||
<rb.Button onClick={cancel} className={styles.cancelButton}>
|
||||
<Sprite symbol="cancel" width="26" height="26" />
|
||||
</rb.Button>
|
||||
</div>
|
||||
</rb.Modal.Title>
|
||||
</rb.Modal.Header>
|
||||
<rb.Modal.Body className={styles.modalBody}>
|
||||
<div className={styles.jarsContainer}>
|
||||
{sortedAccountBalances.map((account) => {
|
||||
return (
|
||||
<SelectableJar
|
||||
key={account.accountIndex}
|
||||
index={account.accountIndex}
|
||||
balance={account.calculatedAvailableBalanceInSats}
|
||||
frozenBalance={account.calculatedFrozenOrLockedBalanceInSats}
|
||||
isSelectable={account.accountIndex !== disabledJar}
|
||||
isSelected={account.accountIndex === selectedJar}
|
||||
fillLevel={jarFillLevel(account.calculatedTotalBalanceInSats, totalBalance)}
|
||||
onClick={(jarIndex) => setSelectedJar(jarIndex)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</rb.Modal.Body>
|
||||
<rb.Modal.Footer className={styles.modalFooter}>
|
||||
<rb.Button variant="light" onClick={cancel} className="d-flex flex-1 justify-content-center align-items-center">
|
||||
<Sprite symbol="cancel" width="26" height="26" />
|
||||
<div>{t('modal.confirm_button_reject')}</div>
|
||||
</rb.Button>
|
||||
<rb.Button
|
||||
disabled={isConfirming || selectedJar === undefined}
|
||||
variant="dark"
|
||||
onClick={confirm}
|
||||
className="d-flex flex-1 justify-content-center align-items-center"
|
||||
>
|
||||
{isConfirming ? (
|
||||
<>
|
||||
<rb.Spinner as="span" animation="border" size="sm" role="status" />
|
||||
</>
|
||||
) : (
|
||||
<>{t('modal.confirm_button_accept')}</>
|
||||
)}
|
||||
</rb.Button>
|
||||
</rb.Modal.Footer>
|
||||
</rb.Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
.jarsTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.jarsTitle .infoIcon {
|
||||
color: var(--bs-gray-500);
|
||||
border: 1px solid var(--bs-gray-500);
|
||||
border-radius: 50%;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.jarsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 2rem;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.jarsContainer :global .jar-container-hook {
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.jarsContainer :global .jar-info-container-hook {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.jarsContainer :global .jar-balance-container-hook {
|
||||
justify-content: start !important;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
.jarsContainer {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.jarsContainer :global .jar-container-hook {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: inherit;
|
||||
}
|
||||
.jarsContainer :global .jar-info-container-hook {
|
||||
align-items: center !important;
|
||||
}
|
||||
.jarsContainer :global .jar-balance-container-hook {
|
||||
justify-content: center !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { useMemo } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccountBalances } from '../context/BalanceSummary'
|
||||
import { AmountSats } from '../libs/JmWalletApi'
|
||||
import { JarProps, OpenableJar, jarFillLevel } from './jars/Jar'
|
||||
import Sprite from './Sprite'
|
||||
|
||||
import styles from './Jars.module.css'
|
||||
|
||||
type JarsProps = Pick<JarProps, 'size'> & {
|
||||
accountBalances: AccountBalances
|
||||
totalBalance: AmountSats
|
||||
onClick: (jarIndex: JarIndex) => void
|
||||
}
|
||||
|
||||
const Jars = ({ size, accountBalances, totalBalance, onClick }: JarsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const sortedAccountBalances = useMemo(() => {
|
||||
if (!accountBalances) return []
|
||||
return Object.values(accountBalances).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex)
|
||||
}, [accountBalances])
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column align-items-center gap-5">
|
||||
<rb.OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={
|
||||
<rb.Popover>
|
||||
<rb.Popover.Body>{t('current_wallet.jars_title_popover')}</rb.Popover.Body>
|
||||
</rb.Popover>
|
||||
}
|
||||
>
|
||||
<div className={styles.jarsTitle}>
|
||||
<div>{t('current_wallet.jars_title')}</div>
|
||||
<Sprite className={styles.infoIcon} symbol="info" width="18" height="18" />
|
||||
</div>
|
||||
</rb.OverlayTrigger>
|
||||
<div className={styles.jarsContainer}>
|
||||
{sortedAccountBalances.map((account) => {
|
||||
const jarIsEmpty = account.calculatedTotalBalanceInSats === 0
|
||||
|
||||
return (
|
||||
<OpenableJar
|
||||
key={account.accountIndex}
|
||||
size={size}
|
||||
index={account.accountIndex}
|
||||
balance={account.calculatedAvailableBalanceInSats}
|
||||
frozenBalance={account.calculatedFrozenOrLockedBalanceInSats}
|
||||
fillLevel={jarFillLevel(account.calculatedTotalBalanceInSats, totalBalance)}
|
||||
tooltipText={
|
||||
account.accountIndex === 0 && jarIsEmpty
|
||||
? t('current_wallet.jar_tooltip_empty_jar_0')
|
||||
: t('current_wallet.jar_tooltip')
|
||||
}
|
||||
onClick={() => onClick(account.accountIndex)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Jars }
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
|
||||
type LayoutVariant = 'wide' | ''
|
||||
|
||||
interface ColProps {
|
||||
variant?: LayoutVariant
|
||||
}
|
||||
|
||||
const Col = ({ variant, children }: PropsWithChildren<ColProps>) => {
|
||||
if (variant === 'wide') {
|
||||
return <rb.Col>{children}</rb.Col>
|
||||
}
|
||||
|
||||
return (
|
||||
<rb.Col lg={10} xl={10} xxl={8}>
|
||||
{children}
|
||||
</rb.Col>
|
||||
)
|
||||
}
|
||||
|
||||
interface LayoutProps {
|
||||
variant?: LayoutVariant
|
||||
}
|
||||
|
||||
const Layout = ({ variant, children }: PropsWithChildren<LayoutProps>) => {
|
||||
return (
|
||||
<rb.Row className="justify-content-center">
|
||||
<Col variant={variant}>{children}</Col>
|
||||
</rb.Row>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
.logContentPlaceholder {
|
||||
height: 2.625rem;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.overlayContainer .logContentContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) {
|
||||
.overlayContainer .logContentContainer {
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.overlayContainer .logContentContainer .titleBar {
|
||||
min-height: 3.6rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem 0.8rem 0.5rem;
|
||||
background-color: var(--bs-gray-100);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) {
|
||||
.overlayContainer .logContentContainer .titleBar {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
.overlayContainer .logContentContainer > .titleBar {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .overlayContainer .logContentContainer .titleBar {
|
||||
background-color: var(--bs-gray-800);
|
||||
}
|
||||
|
||||
.overlayContainer .logContentContainer .titleBar .refreshButton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.1rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.logContent {
|
||||
min-height: 300px;
|
||||
max-height: 60vh;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Helper as ApiHelper } from '../libs/JmWalletApi'
|
||||
import { fetchLog } from '../libs/JamApi'
|
||||
import { useSettings } from '../context/SettingsContext'
|
||||
import { CurrentWallet } from '../context/WalletContext'
|
||||
import Sprite from './Sprite'
|
||||
import styles from './LogOverlay.module.css'
|
||||
import { isDevMode } from '../constants/debugFeatures'
|
||||
|
||||
const JMWALLETD_LOG_FILE_NAME = 'jmwalletd_stdout.log'
|
||||
|
||||
interface LogContentProps {
|
||||
content: string
|
||||
refresh: (signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
export function LogContent({ content, refresh }: LogContentProps) {
|
||||
const logContentRef = useRef<HTMLPreElement>(null)
|
||||
const settings = useSettings()
|
||||
const [isLoadingRefresh, setIsLoadingRefresh] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!content || !logContentRef.current) return
|
||||
|
||||
logContentRef.current.scroll({
|
||||
top: logContentRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}, [content, logContentRef])
|
||||
|
||||
return (
|
||||
<div className={styles.logContentContainer}>
|
||||
<div className={styles.titleBar}>
|
||||
<div className="d-flex justify-content-center align-items-center gap-2">
|
||||
<rb.Button
|
||||
className={styles.refreshButton}
|
||||
variant={settings.theme}
|
||||
onClick={() => {
|
||||
if (isLoadingRefresh) return
|
||||
|
||||
setIsLoadingRefresh(true)
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
refresh(abortCtrl.signal).finally(() => {
|
||||
// as refreshing is fast most of the time, add a short delay to avoid flickering
|
||||
setTimeout(() => setIsLoadingRefresh(false), 250)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isLoadingRefresh ? (
|
||||
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" />
|
||||
) : (
|
||||
<Sprite symbol="refresh" width="24" height="24" />
|
||||
)}
|
||||
</rb.Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2 px-2">
|
||||
<pre ref={logContentRef} className={styles.logContent}>
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type LogOverlayProps = rb.OffcanvasProps & {
|
||||
currentWallet: CurrentWallet
|
||||
}
|
||||
|
||||
export function LogOverlay({ currentWallet, show, onHide }: LogOverlayProps) {
|
||||
const { t } = useTranslation()
|
||||
const [alert, setAlert] = useState<SimpleAlert>()
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [content, setContent] = useState<string>()
|
||||
|
||||
const refresh = useCallback(
|
||||
(signal: AbortSignal) => {
|
||||
return fetchLog({ token: currentWallet.token, signal, fileName: JMWALLETD_LOG_FILE_NAME })
|
||||
.then((res) => (res.ok ? res.text() : ApiHelper.throwError(res)))
|
||||
.then((data) => {
|
||||
if (signal.aborted) return
|
||||
setAlert(undefined)
|
||||
setContent(data)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (signal.aborted) return
|
||||
setAlert({
|
||||
variant: 'danger',
|
||||
message: t('logs.error_loading_logs_failed', {
|
||||
reason: e.message || t('global.errors.reason_unknown'),
|
||||
}),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (signal.aborted) return
|
||||
setIsLoading(false)
|
||||
})
|
||||
},
|
||||
[currentWallet, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
|
||||
setIsLoading(true)
|
||||
refresh(abortCtrl.signal).finally(() => {
|
||||
if (abortCtrl.signal.aborted) return
|
||||
setIsLoading(false)
|
||||
setIsInitialized(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortCtrl.abort()
|
||||
}
|
||||
}, [show, refresh])
|
||||
|
||||
return (
|
||||
<rb.Offcanvas
|
||||
className={`offcanvas-fullscreen ${styles.overlayContainer}`}
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
placement="bottom"
|
||||
>
|
||||
<rb.Offcanvas.Header>
|
||||
<rb.Container fluid="lg">
|
||||
<div className="w-100 d-flex">
|
||||
<div className="d-flex align-items-center flex-1">
|
||||
<rb.Offcanvas.Title>{t('logs.title')}</rb.Offcanvas.Title>
|
||||
</div>
|
||||
<div>
|
||||
<rb.Button variant="link" className="unstyled pe-0" onClick={onHide}>
|
||||
<Sprite symbol="cancel" width="32" height="32" />
|
||||
</rb.Button>
|
||||
</div>
|
||||
</div>
|
||||
</rb.Container>
|
||||
</rb.Offcanvas.Header>
|
||||
<rb.Offcanvas.Body>
|
||||
<rb.Container fluid="lg" className="py-3">
|
||||
{!isInitialized && isLoading ? (
|
||||
Array(5)
|
||||
.fill('')
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<rb.Placeholder key={index} as="div" animation="wave">
|
||||
<rb.Placeholder xs={12} className={styles.logContentPlaceholder} />
|
||||
</rb.Placeholder>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{alert && !content && isDevMode() && (
|
||||
<div className="my-4">
|
||||
<span className="badge rounded-pill bg-warning me-2">dev</span>
|
||||
In order to test the log file feature, start the application with
|
||||
<code className="mx-2">npm run dev:secondary</code>.
|
||||
</div>
|
||||
)}
|
||||
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
|
||||
{content && (
|
||||
<rb.Row>
|
||||
<rb.Col className="px-0">
|
||||
<LogContent content={content} refresh={refresh} />
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</rb.Container>
|
||||
</rb.Offcanvas.Body>
|
||||
</rb.Offcanvas>
|
||||
)
|
||||
}
|
||||
286
src/components/Login.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { setSession } from '@/lib/session'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { AlertCircle, Wallet, Lock, Loader2, Eye, EyeOff, RefreshCwIcon } from 'lucide-react'
|
||||
import { formatWalletName } from '@/lib/utils'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { listwalletsOptions, unlockwalletMutation } from '@/lib/jm-api/generated/client/@tanstack/react-query.gen'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import { Skeleton } from './ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'
|
||||
|
||||
const LoginFormSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col space-y-6">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-[75px]" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-[75px]" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div> </div>
|
||||
<div> </div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface LoginFormProps {
|
||||
wallets: string[]
|
||||
isSubmitting: boolean
|
||||
onSubmit: (val: { walletFileName: string; password: string }) => Promise<void>
|
||||
}
|
||||
|
||||
const LoginForm = ({ wallets, isSubmitting, onSubmit }: LoginFormProps) => {
|
||||
const [selectedWallet, setSelectedWallet] = useState<string | undefined>(
|
||||
wallets.length !== 1 ? undefined : wallets[0],
|
||||
)
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
|
||||
useEffect(
|
||||
function preselectWalletIfOnlyOneExists() {
|
||||
if (wallets.length !== 1) return
|
||||
setSelectedWallet(wallets[0])
|
||||
},
|
||||
[wallets],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (selectedWallet === undefined) return
|
||||
|
||||
onSubmit({ walletFileName: selectedWallet, password })
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wallet-select">Wallet</Label>
|
||||
<Select
|
||||
value={selectedWallet ?? ''}
|
||||
onValueChange={setSelectedWallet}
|
||||
disabled={isSubmitting || wallets.length === 0}
|
||||
required
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={wallets.length > 0 ? 'Select a wallet' : 'No wallets found.'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{wallets?.map((wallet, index) => (
|
||||
<SelectItem key={index} value={wallet}>
|
||||
{formatWalletName(wallet)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Enter your password"
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting || !selectedWallet} size="lg">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Unlocking...
|
||||
</>
|
||||
) : (
|
||||
'Unlock'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const client = useApiClient()
|
||||
|
||||
const listwalletsQuery = useQuery({
|
||||
...listwalletsOptions({ client }),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isLoadingWallets = useMemo(() => listwalletsQuery.isFetching, [listwalletsQuery.isFetching])
|
||||
const listwalletsError = useMemo(() => {
|
||||
if (!listwalletsQuery.error) return undefined
|
||||
return {
|
||||
message: `Failed to load wallets`,
|
||||
error_description: listwalletsQuery.error.message || 'Unknown reason.',
|
||||
}
|
||||
}, [listwalletsQuery.error])
|
||||
|
||||
const wallets = useMemo(() => listwalletsQuery.data?.wallets, [listwalletsQuery.data])
|
||||
|
||||
const unlockWallet = useMutation({
|
||||
...unlockwalletMutation({ client }),
|
||||
retry: false,
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully unlocked wallet.')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to unlock wallet: ${error.message || 'Unknown reason.'}`)
|
||||
},
|
||||
})
|
||||
|
||||
const isUnlockingWallet = useMemo(() => unlockWallet.isPending, [unlockWallet.isPending])
|
||||
|
||||
const handleSubmit = async (data: { walletFileName: string; password: string }) => {
|
||||
try {
|
||||
const response = await unlockWallet.mutateAsync({
|
||||
path: {
|
||||
walletname: encodeURIComponent(data.walletFileName),
|
||||
},
|
||||
body: {
|
||||
password: data.password,
|
||||
},
|
||||
})
|
||||
|
||||
setSession({
|
||||
walletFileName: response.walletname,
|
||||
auth: { token: response.token, refresh_token: response.refresh_token },
|
||||
})
|
||||
|
||||
await navigate('/')
|
||||
} catch (error: unknown) {
|
||||
console.error('Error unlocking wallet', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||
{isLoadingWallets ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<Wallet className="h-6 w-6 text-primary" onClick={async () => await listwalletsQuery.refetch()} />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Welcome to Jam</CardTitle>
|
||||
{!isLoadingWallets && wallets !== undefined && wallets.length > 0 && (
|
||||
<CardDescription>Select a wallet and enter your password to continue.</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{isLoadingWallets ? (
|
||||
<CardContent className="space-y-6">
|
||||
<LoginFormSkeleton />
|
||||
</CardContent>
|
||||
) : (
|
||||
<>
|
||||
{listwalletsError ? (
|
||||
<CardContent className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{listwalletsError.message}</AlertTitle>
|
||||
<AlertDescription>{listwalletsError.error_description}</AlertDescription>
|
||||
</Alert>
|
||||
<Button variant="ghost" size="sm" onClick={async () => await listwalletsQuery.refetch()}>
|
||||
<RefreshCwIcon className="h-4 w-4" /> Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="space-y-6">
|
||||
{wallets!.length === 0 ? (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">It looks like you do not have a wallet, yet.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Button className="w-full" size="lg" onClick={async () => await navigate('/create-wallet')}>
|
||||
Create new wallet
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="w-full">
|
||||
<Button variant="secondary" className="w-full" size="lg" disabled>
|
||||
Import existing wallet
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Not yet implemented.</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LoginForm wallets={wallets || []} isSubmitting={isUnlockingWallet} onSubmit={handleSubmit} />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={async () => await navigate('/create-wallet')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Create a new wallet
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="w-full">
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={async () => await navigate('/create-wallet')}
|
||||
disabled
|
||||
>
|
||||
Import an existing wallet
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Not yet implemented.</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
.walletHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.walletHeader .titlePlaceholder {
|
||||
width: 5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.walletHeader .subtitlePlaceholder {
|
||||
width: 12rem;
|
||||
height: 1.8rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
:global(.jm-rescan-in-progress) .walletHeader {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
:global(.jm-rescan-in-progress) .walletBody {
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.jarsPlaceholder {
|
||||
width: 100%;
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
import { useEffect, useState, useMemo } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useServiceInfo } from '../context/ServiceInfoContext'
|
||||
import { useSettings, useSettingsDispatch } from '../context/SettingsContext'
|
||||
import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
|
||||
import * as Api from '../libs/JmWalletApi'
|
||||
import { routes } from '../constants/routes'
|
||||
import Balance from './Balance'
|
||||
import Sprite from './Sprite'
|
||||
import { ExtendedLink } from './ExtendedLink'
|
||||
import { JarDetailsOverlay } from './jar_details/JarDetailsOverlay'
|
||||
import { Jars } from './Jars'
|
||||
import styles from './MainWalletView.module.css'
|
||||
import Divider from './Divider'
|
||||
|
||||
interface WalletHeaderProps {
|
||||
walletName: string
|
||||
balance: Api.AmountSats
|
||||
unit: Unit
|
||||
showBalance: boolean
|
||||
}
|
||||
|
||||
const WalletHeader = ({ walletName, balance, unit, showBalance }: WalletHeaderProps) => {
|
||||
return (
|
||||
<div className={styles.walletHeader}>
|
||||
<h1 className="text-secondary fs-6">{walletName}</h1>
|
||||
<h2>
|
||||
<Balance
|
||||
valueString={balance.toString()}
|
||||
convertToUnit={unit}
|
||||
showBalance={showBalance}
|
||||
enableVisibilityToggle={false}
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const WalletHeaderPlaceholder = () => {
|
||||
return (
|
||||
<div className={styles.walletHeader}>
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder className={styles.titlePlaceholder} />
|
||||
</rb.Placeholder>
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder className={styles.subtitlePlaceholder} />
|
||||
</rb.Placeholder>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const WalletHeaderRescanning = ({
|
||||
walletName,
|
||||
isLoading,
|
||||
serviceInfo,
|
||||
}: {
|
||||
walletName: string
|
||||
isLoading: boolean
|
||||
serviceInfo: any
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={styles.walletHeader}>
|
||||
<h1 className="text-secondary fs-6">{walletName}</h1>
|
||||
{isLoading ? (
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder className={styles.subtitlePlaceholder} />
|
||||
</rb.Placeholder>
|
||||
) : (
|
||||
<h2>
|
||||
{serviceInfo?.rescanProgress !== undefined
|
||||
? t('current_wallet.text_rescan_in_progress_with_progress', {
|
||||
progress: Math.floor(serviceInfo.rescanProgress * 100),
|
||||
})
|
||||
: t('current_wallet.text_rescan_in_progress')}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MainWalletViewProps {
|
||||
wallet: CurrentWallet
|
||||
}
|
||||
|
||||
export default function MainWalletView({ wallet }: MainWalletViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const serviceInfo = useServiceInfo()
|
||||
const settings = useSettings()
|
||||
const settingsDispatch = useSettingsDispatch()
|
||||
const currentWalletInfo = useCurrentWalletInfo()
|
||||
const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
|
||||
|
||||
const [alert, setAlert] = useState<SimpleAlert>()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showJars, setShowJars] = useState(false)
|
||||
|
||||
const jars = useMemo(() => currentWalletInfo?.data.display.walletinfo.accounts, [currentWalletInfo])
|
||||
|
||||
const [selectedJarIndex, setSelectedJarIndex] = useState(0)
|
||||
const [isAccountOverlayShown, setIsAccountOverlayShown] = useState(false)
|
||||
|
||||
const onJarClicked = (jarIndex: JarIndex) => {
|
||||
if (jarIndex === 0) {
|
||||
const isEmpty = currentWalletInfo?.balanceSummary.accountBalances[jarIndex]?.calculatedTotalBalanceInSats === 0
|
||||
|
||||
if (isEmpty) {
|
||||
navigate(routes.receive, { state: { account: jarIndex } })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedJarIndex(jarIndex)
|
||||
setIsAccountOverlayShown(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const abortCtrl = new AbortController()
|
||||
|
||||
setAlert(undefined)
|
||||
setIsLoading(true)
|
||||
|
||||
reloadCurrentWalletInfo
|
||||
.reloadUtxos({ signal: abortCtrl.signal })
|
||||
.catch((err) => {
|
||||
if (abortCtrl.signal.aborted) return
|
||||
const message = err.message || t('current_wallet.error_loading_failed')
|
||||
setAlert({ variant: 'danger', message })
|
||||
})
|
||||
.finally(() => {
|
||||
if (abortCtrl.signal.aborted) return
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
return () => abortCtrl.abort()
|
||||
}, [reloadCurrentWalletInfo, t])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{alert && (
|
||||
<rb.Row>
|
||||
<rb.Col>
|
||||
<rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
|
||||
{currentWalletInfo && jars && (
|
||||
<JarDetailsOverlay
|
||||
jars={jars}
|
||||
initialJarIndex={selectedJarIndex}
|
||||
walletInfo={currentWalletInfo}
|
||||
wallet={wallet}
|
||||
isShown={isAccountOverlayShown}
|
||||
onHide={() => setIsAccountOverlayShown(false)}
|
||||
/>
|
||||
)}
|
||||
{serviceInfo?.rescanning === true ? (
|
||||
<rb.Row>
|
||||
<WalletHeaderRescanning walletName={wallet.displayName} isLoading={isLoading} serviceInfo={serviceInfo} />
|
||||
</rb.Row>
|
||||
) : !currentWalletInfo || isLoading ? (
|
||||
<rb.Row>
|
||||
<WalletHeaderPlaceholder />
|
||||
</rb.Row>
|
||||
) : (
|
||||
<rb.Row
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (!settings.showBalance) {
|
||||
settingsDispatch({ unit: 'BTC', showBalance: true })
|
||||
} else if (settings.unit === 'BTC') {
|
||||
settingsDispatch({ unit: 'sats', showBalance: true })
|
||||
} else {
|
||||
settingsDispatch({ unit: 'BTC', showBalance: false })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WalletHeader
|
||||
walletName={wallet.displayName}
|
||||
balance={currentWalletInfo.balanceSummary.calculatedTotalBalanceInSats}
|
||||
unit={settings.unit}
|
||||
showBalance={settings.showBalance}
|
||||
/>
|
||||
</rb.Row>
|
||||
)}
|
||||
<div className={styles.walletBody}>
|
||||
<rb.Row className="mt-4 mb-5 d-flex justify-content-center">
|
||||
<rb.Col xs={10} md={8}>
|
||||
<rb.Row>
|
||||
<rb.Col>
|
||||
{/* Always receive to first jar. */}
|
||||
<ExtendedLink
|
||||
to={routes.receive}
|
||||
state={{ account: 0 }}
|
||||
className={`${styles.sendReceiveButton} btn btn-lg btn-outline-dark w-100`}
|
||||
disabled={isLoading || serviceInfo?.rescanning}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<Sprite symbol="receive" width="24" height="24" className="me-1" />
|
||||
{t('current_wallet.button_deposit')}
|
||||
</div>
|
||||
</ExtendedLink>
|
||||
</rb.Col>
|
||||
<rb.Col>
|
||||
<ExtendedLink
|
||||
to={routes.send}
|
||||
className={`${styles.sendReceiveButton} btn btn-lg btn-outline-dark w-100`}
|
||||
disabled={isLoading || serviceInfo?.rescanning}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
<Sprite symbol="send" width="24" height="24" className="me-1" />
|
||||
{t('current_wallet.button_withdraw')}
|
||||
</div>
|
||||
</ExtendedLink>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
<rb.Collapse in={!serviceInfo?.rescanning && showJars}>
|
||||
<rb.Row>
|
||||
<div className="mb-5">
|
||||
<div>
|
||||
{!currentWalletInfo || isLoading ? (
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder className={styles.jarsPlaceholder} />
|
||||
</rb.Placeholder>
|
||||
) : (
|
||||
<Jars
|
||||
size="lg"
|
||||
accountBalances={currentWalletInfo.balanceSummary.accountBalances}
|
||||
totalBalance={currentWalletInfo.balanceSummary.calculatedTotalBalanceInSats}
|
||||
onClick={onJarClicked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</rb.Row>
|
||||
</rb.Collapse>
|
||||
<Divider
|
||||
toggled={showJars}
|
||||
onToggle={() => setShowJars((current) => !current)}
|
||||
disabled={serviceInfo?.rescanning}
|
||||
xs={showJars ? 12 : 10}
|
||||
md={showJars ? 12 : 8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Bip39MnemonicWordInput } from './MnemonicWordInput'
|
||||
|
||||
interface MnemonicPhraseInputProps {
|
||||
columns?: number
|
||||
mnemonicPhrase: MnemonicPhrase
|
||||
isDisabled?: (index: number) => boolean
|
||||
isValid?: (index: number) => boolean
|
||||
onChange: (value: MnemonicPhrase) => void
|
||||
}
|
||||
|
||||
export default function MnemonicPhraseInput({
|
||||
columns = 3,
|
||||
mnemonicPhrase,
|
||||
isDisabled,
|
||||
isValid,
|
||||
onChange,
|
||||
}: MnemonicPhraseInputProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const inputRefs = useRef<HTMLInputElement[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < mnemonicPhrase.length && isValid && isValid(activeIndex)) {
|
||||
const nextIndex = activeIndex + 1
|
||||
setActiveIndex(nextIndex)
|
||||
|
||||
if (inputRefs.current[nextIndex]) {
|
||||
inputRefs.current[nextIndex].focus()
|
||||
}
|
||||
}
|
||||
}, [mnemonicPhrase, activeIndex, isValid])
|
||||
|
||||
return (
|
||||
<div className="container slashed-zeroes p-0">
|
||||
{mnemonicPhrase.map((_, outerIndex) => {
|
||||
if (outerIndex % columns !== 0) return null
|
||||
|
||||
const wordGroup = mnemonicPhrase.slice(outerIndex, Math.min(outerIndex + columns, mnemonicPhrase.length))
|
||||
|
||||
return (
|
||||
<div className="row mb-4" key={outerIndex}>
|
||||
{wordGroup.map((givenWord, innerIndex) => {
|
||||
const wordIndex = outerIndex + innerIndex
|
||||
const isCurrentActive = wordIndex === activeIndex
|
||||
return (
|
||||
<div className="col" key={wordIndex}>
|
||||
<Bip39MnemonicWordInput
|
||||
forwardRef={(el: HTMLInputElement) => (inputRefs.current[wordIndex] = el)}
|
||||
index={wordIndex}
|
||||
value={givenWord}
|
||||
setValue={(value, i) => {
|
||||
const newPhrase = mnemonicPhrase.map((old, index) => (index === i ? value : old))
|
||||
onChange(newPhrase)
|
||||
}}
|
||||
isValid={isValid ? isValid(wordIndex) : undefined}
|
||||
disabled={isDisabled ? isDisabled(wordIndex) : undefined}
|
||||
onFocus={() => setActiveIndex(wordIndex)}
|
||||
autoFocus={isCurrentActive}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
.input {
|
||||
height: 3.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.seedwordIndexBackup {
|
||||
width: 5ch;
|
||||
justify-content: right;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { render, screen } from '../testUtils'
|
||||
import { Bip39MnemonicWordInput, MnemonicWordInputProps } from './MnemonicWordInput'
|
||||
|
||||
const NOOP = () => {}
|
||||
|
||||
describe('<Bip39MnemonicWordInput />', () => {
|
||||
const validBip39MnemonicWord = 'abandon'
|
||||
const invalidBip39MnemonicWord = 'not a bip39 word!'
|
||||
|
||||
const setup = (props: MnemonicWordInputProps) => {
|
||||
render(<Bip39MnemonicWordInput {...props} />)
|
||||
}
|
||||
|
||||
it('should render without errors', async () => {
|
||||
setup({ index: 0, value: '', setValue: NOOP })
|
||||
|
||||
expect(await screen.findByTestId('mnemonic-word-input')).toBeVisible()
|
||||
})
|
||||
|
||||
it('should report if input is NOT included in the BIP-39 wordlist', async () => {
|
||||
setup({ index: 0, value: invalidBip39MnemonicWord, setValue: NOOP })
|
||||
|
||||
const input = await screen.findByTestId('mnemonic-word-input')
|
||||
expect(input).toBeVisible()
|
||||
expect(input).toHaveClass('is-invalid')
|
||||
expect(input).not.toHaveClass('is-valid')
|
||||
})
|
||||
|
||||
it('should report if input IS INCLUDED in the BIP-39 wordlist', async () => {
|
||||
setup({ index: 0, value: validBip39MnemonicWord, setValue: NOOP })
|
||||
|
||||
const input = await screen.findByTestId('mnemonic-word-input')
|
||||
expect(input).toBeVisible()
|
||||
expect(input).toHaveClass('is-valid')
|
||||
expect(input).not.toHaveClass('is-invalid')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { useMemo } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MNEMONIC_WORDS } from '../constants/bip39words'
|
||||
import styles from './MnemonicWordInput.module.css'
|
||||
|
||||
export interface MnemonicWordInputProps {
|
||||
forwardRef?: (el: HTMLInputElement) => void
|
||||
index: number
|
||||
value: string
|
||||
setValue: (value: string, index: number) => void
|
||||
isValid?: boolean
|
||||
disabled?: boolean
|
||||
onFocus?: () => void
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
const MnemonicWordInput = ({
|
||||
forwardRef,
|
||||
index,
|
||||
value,
|
||||
setValue,
|
||||
isValid,
|
||||
disabled,
|
||||
onFocus,
|
||||
autoFocus,
|
||||
}: MnemonicWordInputProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<rb.InputGroup>
|
||||
<rb.InputGroup.Text className={styles.seedwordIndexBackup}>{index + 1}.</rb.InputGroup.Text>
|
||||
<rb.Form.Control
|
||||
data-testid="mnemonic-word-input"
|
||||
ref={forwardRef}
|
||||
type="text"
|
||||
placeholder={`${t('create_wallet.placeholder_seed_word_input')} ${index + 1}`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value.trim(), index)}
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
isInvalid={isValid === false && value.length > 0}
|
||||
isValid={isValid === true}
|
||||
onFocus={onFocus}
|
||||
autoFocus={autoFocus}
|
||||
required
|
||||
/>
|
||||
</rb.InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export const Bip39MnemonicWordInput = ({ value, ...props }: MnemonicWordInputProps) => {
|
||||
const isBip39Value = useMemo(() => MNEMONIC_WORDS.includes(value), [value])
|
||||
|
||||
return <MnemonicWordInput {...props} value={value} isValid={isBip39Value && (props.isValid ?? true)} />
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
:global .modal-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.modal :global .modal-content {
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border-radius: 1rem !important;
|
||||
box-shadow: 0px 0px 24px rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
padding: 1.25rem 1.25rem 0 1.25rem !important;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-size: 1.3rem !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
text-align: center !important;
|
||||
font-size: 1rem !important;
|
||||
font-weight: 400 !important;
|
||||
padding: 0.25rem 1.25rem 1rem 1.25rem !important;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
gap: 1rem;
|
||||
background-color: transparent !important;
|
||||
padding: 1rem 1.25rem 1.25rem 1.25rem !important;
|
||||
}
|
||||
|
||||
.modalFooter :global .btn {
|
||||
--bs-btn-border-color: var(--bs-border-color);
|
||||
flex-grow: 1;
|
||||
min-height: 2.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import { ReactNode, PropsWithChildren } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Sprite from './Sprite'
|
||||
import styles from './Modal.module.css'
|
||||
|
||||
type BaseModalProps = Pick<rb.ModalProps, 'className' | 'backdrop' | 'size'> &
|
||||
Pick<rb.ModalHeaderProps, 'closeButton'> & {
|
||||
isShown: boolean
|
||||
title: ReactNode | string
|
||||
onCancel: () => void
|
||||
headerClassName?: rb.ModalHeaderProps['className']
|
||||
titleClassName?: rb.ModalTitleProps['className']
|
||||
}
|
||||
|
||||
const BaseModal = ({
|
||||
isShown,
|
||||
title,
|
||||
children,
|
||||
onCancel,
|
||||
size,
|
||||
backdrop = 'static',
|
||||
closeButton = false,
|
||||
className = styles.modal,
|
||||
headerClassName = styles.modalHeader,
|
||||
titleClassName = styles.modalTitle,
|
||||
}: PropsWithChildren<BaseModalProps>) => {
|
||||
return (
|
||||
<rb.Modal
|
||||
show={isShown}
|
||||
keyboard={true}
|
||||
onEscapeKeyDown={() => onCancel()}
|
||||
onHide={() => onCancel()}
|
||||
centered={true}
|
||||
animation={true}
|
||||
backdrop={backdrop}
|
||||
size={size}
|
||||
className={className}
|
||||
>
|
||||
<rb.Modal.Header className={headerClassName} closeButton={closeButton}>
|
||||
<rb.Modal.Title className={titleClassName}>{title}</rb.Modal.Title>
|
||||
</rb.Modal.Header>
|
||||
{children}
|
||||
</rb.Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export type InfoModalProps = Omit<BaseModalProps, 'backdrop'> & {
|
||||
onSubmit: () => void
|
||||
submitButtonText: React.ReactNode | string
|
||||
}
|
||||
|
||||
const InfoModal = ({
|
||||
children,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
submitButtonText,
|
||||
...baseModalProps
|
||||
}: PropsWithChildren<InfoModalProps>) => {
|
||||
return (
|
||||
<BaseModal {...baseModalProps} onCancel={onCancel} backdrop={true}>
|
||||
<rb.Modal.Body className={styles.modalBody}>{children}</rb.Modal.Body>
|
||||
<rb.Modal.Footer className={styles.modalFooter}>
|
||||
<rb.Button variant="outline-dark" onClick={() => onSubmit()}>
|
||||
{submitButtonText}
|
||||
</rb.Button>
|
||||
</rb.Modal.Footer>
|
||||
</BaseModal>
|
||||
)
|
||||
}
|
||||
|
||||
export type ConfirmModalProps = Omit<BaseModalProps, 'closeButton'> & {
|
||||
onConfirm: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ConfirmModal = ({
|
||||
children,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
disabled = false,
|
||||
...baseModalProps
|
||||
}: PropsWithChildren<ConfirmModalProps>) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<BaseModal {...baseModalProps} onCancel={onCancel}>
|
||||
<rb.Modal.Body className={styles.modalBody}>{children}</rb.Modal.Body>
|
||||
<rb.Modal.Footer className={styles.modalFooter}>
|
||||
<rb.Button
|
||||
variant="outline-dark"
|
||||
onClick={() => onCancel()}
|
||||
className="d-flex justify-content-center align-items-center"
|
||||
>
|
||||
<Sprite symbol="cancel" width="26" height="26" />
|
||||
<div>{t('modal.confirm_button_reject')}</div>
|
||||
</rb.Button>
|
||||
<rb.Button variant="outline-dark" onClick={() => onConfirm()} disabled={disabled}>
|
||||
{t('modal.confirm_button_accept')}
|
||||
</rb.Button>
|
||||
</rb.Modal.Footer>
|
||||
</BaseModal>
|
||||
)
|
||||
}
|
||||
|
||||
export { BaseModal, InfoModal, ConfirmModal }
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
:global(.jm-rescan-in-progress) :global(.center-nav-link),
|
||||
:global(.jm-rescan-in-progress) :global(.center-nav-link-divider) {
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.balancePlaceholder {
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
.loadingIndicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:global(.jam-reload-wallet-info-in-progress) .walletSprite {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:global(.jam-reload-wallet-info-in-progress) .loadingIndicator {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.offcanvasBody {
|
||||
font-size: calc(var(--bs-body-font-size) * 1.5);
|
||||
}
|
||||
|
||||
.offcanvasBody :global(.nav-link) {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -1,402 +1,95 @@
|
|||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Link, NavLink, To } from 'react-router-dom'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
import Sprite from './Sprite'
|
||||
import Balance from './Balance'
|
||||
import { TabActivityIndicator, JoiningIndicator } from './ActivityIndicators'
|
||||
import { useSettings, useSettingsDispatch } from '../context/SettingsContext'
|
||||
import { CurrentWallet, useCurrentWallet, useCurrentWalletInfo } from '../context/WalletContext'
|
||||
import { useServiceInfo, useSessionConnectionError } from '../context/ServiceInfoContext'
|
||||
import { routes } from '../constants/routes'
|
||||
import { AmountSats } from '../libs/JmWalletApi'
|
||||
import { Wallet, Sun, Moon, Settings, LogOut, Loader2 } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
import { Badge } from './ui/badge'
|
||||
import { clearSession } from '@/lib/session'
|
||||
import type { Jar } from './layout/display-mode-context'
|
||||
|
||||
import styles from './Navbar.module.css'
|
||||
|
||||
const BalanceLoadingIndicator = () => {
|
||||
return (
|
||||
<rb.Placeholder as="div" animation="wave">
|
||||
<rb.Placeholder className={styles.balancePlaceholder} />
|
||||
</rb.Placeholder>
|
||||
)
|
||||
interface NavbarProps {
|
||||
theme: string
|
||||
toggleTheme: () => void
|
||||
toggleDisplayMode: () => void
|
||||
formatAmount: (amount: number) => string
|
||||
getLogo: (size: 'sm' | 'lg') => React.ReactNode
|
||||
jars: Jar[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
interface WalletPreviewProps {
|
||||
rescanProgress?: number
|
||||
wallet: CurrentWallet
|
||||
rescanInProgress: boolean
|
||||
totalBalance?: AmountSats
|
||||
unit: Unit
|
||||
showBalance?: boolean
|
||||
}
|
||||
export function Navbar({
|
||||
theme,
|
||||
toggleTheme,
|
||||
toggleDisplayMode,
|
||||
formatAmount,
|
||||
getLogo,
|
||||
jars,
|
||||
isLoading = false,
|
||||
}: NavbarProps) {
|
||||
const handleLogout = () => {
|
||||
clearSession()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
const WalletPreview = ({
|
||||
wallet,
|
||||
rescanInProgress,
|
||||
rescanProgress,
|
||||
totalBalance,
|
||||
unit,
|
||||
showBalance = false,
|
||||
}: WalletPreviewProps) => {
|
||||
const { t } = useTranslation()
|
||||
const totalBalance = jars.reduce((acc, jar) => acc + jar.balance, 0)
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="d-flex align-items-center justify-content-center text-body" style={{ minWidth: '2rem' }}>
|
||||
<Sprite className={styles.walletSprite} symbol="wallet" width="30" height="30" />
|
||||
<rb.Spinner
|
||||
className={styles.loadingIndicator}
|
||||
as="span"
|
||||
animation="border"
|
||||
size="sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex flex-column ms-2 fs-6">
|
||||
{wallet && <div className="fw-normal">{wallet.displayName}</div>}
|
||||
<div className="text-body">
|
||||
{rescanInProgress ? (
|
||||
<div className="cursor-wait">
|
||||
{rescanProgress !== undefined
|
||||
? t('navbar.text_rescan_in_progress_with_progress', { progress: Math.floor(rescanProgress * 100) })
|
||||
: t('navbar.text_rescan_in_progress')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{totalBalance === undefined ? (
|
||||
<BalanceLoadingIndicator />
|
||||
) : (
|
||||
<Balance
|
||||
valueString={`${totalBalance}`}
|
||||
convertToUnit={unit}
|
||||
showBalance={showBalance}
|
||||
enableVisibilityToggle={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CenterNavProps {
|
||||
makerRunning: boolean
|
||||
schedulerRunning: boolean
|
||||
singleCoinJoinRunning: boolean
|
||||
rescanInProgress: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const CenterNav = ({
|
||||
makerRunning,
|
||||
schedulerRunning,
|
||||
singleCoinJoinRunning,
|
||||
rescanInProgress,
|
||||
onClick,
|
||||
}: CenterNavProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<rb.Nav className="justify-content-center align-items-stretch">
|
||||
<rb.Nav.Item className="d-flex align-items-stretch">
|
||||
<NavLink
|
||||
to={routes.receive}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
|
||||
active: isActive,
|
||||
disabled: rescanInProgress,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('navbar.tab_receive')}
|
||||
</NavLink>
|
||||
</rb.Nav.Item>
|
||||
<div className="d-none d-md-flex align-items-center center-nav-link-divider">»</div>
|
||||
<rb.Nav.Item className="d-flex align-items-stretch">
|
||||
<NavLink
|
||||
to={routes.send}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
|
||||
active: isActive,
|
||||
disabled: rescanInProgress,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
{t('navbar.tab_send')}
|
||||
<TabActivityIndicator isOn={singleCoinJoinRunning} className="ms-1" />
|
||||
</div>
|
||||
</NavLink>
|
||||
</rb.Nav.Item>
|
||||
<div className="d-none d-md-flex align-items-center center-nav-link-divider">»</div>
|
||||
<rb.Nav.Item className="d-flex align-items-stretch">
|
||||
<NavLink
|
||||
to={routes.earn}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
|
||||
active: isActive,
|
||||
disabled: rescanInProgress,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
{t('navbar.tab_earn')}
|
||||
<TabActivityIndicator isOn={makerRunning} />
|
||||
</div>
|
||||
</NavLink>
|
||||
</rb.Nav.Item>
|
||||
<div className="d-none d-md-flex align-items-center center-nav-link-divider">|</div>
|
||||
<rb.Nav.Item className="d-flex align-items-stretch">
|
||||
<NavLink
|
||||
to={routes.jam}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
classNames('center-nav-link nav-link d-flex align-items-center justify-content-center', {
|
||||
active: isActive,
|
||||
disabled: rescanInProgress,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
{t('navbar.tab_sweep')}
|
||||
<TabActivityIndicator isOn={schedulerRunning} />
|
||||
</div>
|
||||
</NavLink>
|
||||
</rb.Nav.Item>
|
||||
</rb.Nav>
|
||||
)
|
||||
}
|
||||
|
||||
interface TrailingNavProps {
|
||||
joiningRoute?: To
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const TrailingNav = ({ joiningRoute, onClick }: TrailingNavProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<rb.Nav className="justify-content-center align-items-stretch">
|
||||
{joiningRoute && (
|
||||
<rb.Nav.Item className="d-flex align-items-stretch">
|
||||
<NavLink
|
||||
to={joiningRoute}
|
||||
onClick={onClick}
|
||||
className="nav-link d-flex justify-content-center align-items-center"
|
||||
>
|
||||
<rb.Navbar.Text className="d-md-none">{t('navbar.joining_in_progress')}</rb.Navbar.Text>
|
||||
<JoiningIndicator isOn={true} title={t('navbar.joining_in_progress')} />
|
||||
</NavLink>
|
||||
</rb.Nav.Item>
|
||||
)}
|
||||
<rb.Nav.Item className="d-none d-md-flex align-items-stretch">
|
||||
<FastThemeToggle />
|
||||
</rb.Nav.Item>
|
||||
<rb.Nav.Item className="d-flex align-items-stretch">
|
||||
<NavLink
|
||||
to={routes.settings}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
'nav-link d-flex align-items-center justify-content-center' + (isActive ? ' active' : '')
|
||||
}
|
||||
>
|
||||
<Sprite symbol="gear" width="30" height="30" className="d-none d-md-inline-block" />
|
||||
<span className="d-inline-block d-md-none">{t('navbar.menu_mobile_settings')}</span>
|
||||
</NavLink>
|
||||
</rb.Nav.Item>
|
||||
</rb.Nav>
|
||||
)
|
||||
}
|
||||
|
||||
function FastThemeToggle() {
|
||||
const settings = useSettings()
|
||||
const settingsDispatch = useSettingsDispatch()
|
||||
const isLightTheme = useMemo(() => settings.theme === window.JM.THEMES[0], [settings])
|
||||
|
||||
const setTheme = useCallback(
|
||||
(theme: string) => {
|
||||
if (window.JM.THEMES.includes(theme)) {
|
||||
document.documentElement.setAttribute(window.JM.THEME_ROOT_ATTR, theme)
|
||||
settingsDispatch({ theme })
|
||||
}
|
||||
},
|
||||
[settingsDispatch],
|
||||
)
|
||||
|
||||
return (
|
||||
<rb.Button
|
||||
variant="link"
|
||||
className="unstyled"
|
||||
onClick={() => setTheme(isLightTheme ? window.JM.THEMES[1] : window.JM.THEMES[0])}
|
||||
>
|
||||
<Sprite symbol={isLightTheme ? window.JM.THEMES[0] : window.JM.THEMES[1]} width="30" height="30" />
|
||||
</rb.Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
const currentWallet = useCurrentWallet()
|
||||
const currentWalletInfo = useCurrentWalletInfo()
|
||||
|
||||
const serviceInfo = useServiceInfo()
|
||||
const sessionConnectionError = useSessionConnectionError()
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const makerRunning = useMemo(() => serviceInfo?.makerRunning === true, [serviceInfo])
|
||||
const rescanInProgress = useMemo(() => serviceInfo?.rescanning === true, [serviceInfo])
|
||||
const schedulerRunning = useMemo(
|
||||
() => (serviceInfo?.coinjoinInProgress && serviceInfo?.schedule !== null) || false,
|
||||
[serviceInfo],
|
||||
)
|
||||
const singleCoinJoinRunning = useMemo(
|
||||
() => (serviceInfo?.coinjoinInProgress && serviceInfo?.schedule === null) || false,
|
||||
[serviceInfo],
|
||||
)
|
||||
|
||||
const joiningRoute = useMemo(() => {
|
||||
if (schedulerRunning) return routes.jam
|
||||
if (singleCoinJoinRunning) return routes.send
|
||||
if (makerRunning) return routes.earn
|
||||
|
||||
return undefined
|
||||
}, [makerRunning, schedulerRunning, singleCoinJoinRunning])
|
||||
|
||||
const height = '75px'
|
||||
|
||||
return (
|
||||
<rb.Navbar
|
||||
id="mainNav"
|
||||
bg={settings.theme === 'light' ? 'white' : 'dark'}
|
||||
sticky="top"
|
||||
expand="md"
|
||||
variant={settings.theme}
|
||||
expanded={isExpanded}
|
||||
onToggle={(expanded) => setIsExpanded(expanded)}
|
||||
className="border-bottom py-0"
|
||||
>
|
||||
<rb.Container fluid="xl" className="align-items-stretch">
|
||||
{sessionConnectionError ? (
|
||||
<rb.Navbar.Text className="d-flex align-items-center" style={{ height: height }}>
|
||||
No Connection
|
||||
</rb.Navbar.Text>
|
||||
) : (
|
||||
<>
|
||||
{!currentWallet ? (
|
||||
<>
|
||||
<Link
|
||||
to={routes.home}
|
||||
className="navbar-brand nav-link d-flex align-items-center ps-0 ps-sm-2 ps-xl-0"
|
||||
style={{ height: height }}
|
||||
>
|
||||
<Sprite symbol="logo" width="30" height="30" className="d-inline-block align-top" />
|
||||
<span className="ms-2">{t('navbar.title')}</span>
|
||||
</Link>
|
||||
<div className="d-flex d-md-none align-items-center">
|
||||
<rb.Navbar.Toggle id="mainNavToggle">
|
||||
<span>{t('navbar.menu')}</span>
|
||||
</rb.Navbar.Toggle>
|
||||
</div>
|
||||
<rb.Nav.Item className="d-none d-md-flex align-items-center pe-2">
|
||||
<FastThemeToggle />
|
||||
</rb.Nav.Item>
|
||||
<rb.Navbar.Offcanvas className={`navbar-offcanvas navbar-${settings.theme}`} placement="end">
|
||||
<rb.Offcanvas.Header>
|
||||
<rb.Offcanvas.Title>{t('navbar.title')}</rb.Offcanvas.Title>
|
||||
</rb.Offcanvas.Header>
|
||||
<rb.Offcanvas.Body className={styles.offcanvasBody}>
|
||||
<rb.Nav className="ms-auto">
|
||||
<rb.Nav.Item>
|
||||
<NavLink
|
||||
to={routes.createWallet}
|
||||
onClick={() => isExpanded && setIsExpanded(false)}
|
||||
className="nav-link d-flex align-items-center justify-content-center"
|
||||
>
|
||||
{t('navbar.button_create_wallet')}
|
||||
</NavLink>
|
||||
</rb.Nav.Item>
|
||||
<rb.Nav.Item>
|
||||
<NavLink
|
||||
to={routes.importWallet}
|
||||
onClick={() => isExpanded && setIsExpanded(false)}
|
||||
className="nav-link d-flex align-items-center justify-content-center"
|
||||
>
|
||||
{t('navbar.button_import_wallet')}
|
||||
</NavLink>
|
||||
</rb.Nav.Item>
|
||||
</rb.Nav>
|
||||
</rb.Offcanvas.Body>
|
||||
</rb.Navbar.Offcanvas>
|
||||
</>
|
||||
<header className="flex items-center justify-between px-6 py-4 bg-gray-100 text-black dark:bg-[#23262b] dark:text-white transition-colors duration-300">
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<Wallet className="mr-3" strokeWidth={1} />
|
||||
<div className="flex flex-col relative">
|
||||
<span className="flex font-thin -mb-1">Satoshi</span>
|
||||
<Badge className="absolute right-3 -translate-x-1/2 -top-1 z-10" variant="dev">
|
||||
dev
|
||||
</Badge>
|
||||
<div className="text-lg font-light tracking-wider flex items-center min-h-[40px]">
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
) : (
|
||||
<>
|
||||
<rb.Nav className="d-flex flex-1 align-items-stretch">
|
||||
<rb.Nav.Item className="d-flex align-items-stretch">
|
||||
<NavLink
|
||||
to={routes.wallet}
|
||||
style={{ height: height }}
|
||||
className={({ isActive }) =>
|
||||
'leading-nav-link nav-link d-flex align-items-center' + (isActive ? ' active' : '')
|
||||
}
|
||||
>
|
||||
<WalletPreview
|
||||
wallet={currentWallet}
|
||||
rescanInProgress={rescanInProgress}
|
||||
rescanProgress={serviceInfo?.rescanProgress}
|
||||
totalBalance={currentWalletInfo?.balanceSummary.calculatedTotalBalanceInSats}
|
||||
showBalance={settings.showBalance}
|
||||
unit={settings.unit}
|
||||
/>
|
||||
</NavLink>
|
||||
</rb.Nav.Item>
|
||||
</rb.Nav>
|
||||
<div className="d-flex d-md-none align-items-center">
|
||||
<rb.Navbar.Toggle id="mainNavToggle">
|
||||
<span>{t('navbar.menu_mobile')}</span>
|
||||
</rb.Navbar.Toggle>
|
||||
</div>
|
||||
<rb.Navbar.Offcanvas className={`navbar-offcanvas navbar-${settings.theme}`} placement="end">
|
||||
<rb.Offcanvas.Header>
|
||||
<rb.Offcanvas.Title>{t('navbar.title')}</rb.Offcanvas.Title>
|
||||
</rb.Offcanvas.Header>
|
||||
<rb.Offcanvas.Body className={styles.offcanvasBody}>
|
||||
<CenterNav
|
||||
makerRunning={makerRunning}
|
||||
schedulerRunning={schedulerRunning}
|
||||
singleCoinJoinRunning={singleCoinJoinRunning}
|
||||
rescanInProgress={rescanInProgress}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
/>
|
||||
<TrailingNav joiningRoute={joiningRoute} onClick={() => setIsExpanded(!isExpanded)} />
|
||||
</rb.Offcanvas.Body>
|
||||
</rb.Navbar.Offcanvas>
|
||||
<rb.Container className="d-none d-md-flex flex-1 flex-grow-0 align-items-stretch">
|
||||
<CenterNav
|
||||
makerRunning={makerRunning}
|
||||
schedulerRunning={schedulerRunning}
|
||||
singleCoinJoinRunning={singleCoinJoinRunning}
|
||||
rescanInProgress={rescanInProgress}
|
||||
/>
|
||||
</rb.Container>
|
||||
<rb.Container className="d-none d-md-flex flex-1 align-items-stretch">
|
||||
<div className="ms-auto d-flex align-items-stretch">
|
||||
<TrailingNav joiningRoute={joiningRoute} />
|
||||
</div>
|
||||
</rb.Container>
|
||||
<span
|
||||
className="tabular-nums text-center select-none cursor-pointer"
|
||||
onClick={toggleDisplayMode}
|
||||
title="Click to toggle sats/bitcoin"
|
||||
>
|
||||
{formatAmount(totalBalance)}
|
||||
</span>
|
||||
<span className="flex items-center min-h-[32px]">{getLogo('sm')}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</rb.Container>
|
||||
</rb.Navbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8 text-sm justify-center items-center flex-1 min-w-0">
|
||||
<span className="opacity-70 cursor-pointer hover:underline">Receive</span>
|
||||
<span className="opacity-70 hover:underline cursor-pointer relative">
|
||||
<span>Earn</span>
|
||||
<span className="text-[#6ee7b7] text-[8px] absolute -top-1 -right-2">●</span>
|
||||
</span>
|
||||
<span className="opacity-70 cursor-pointer hover:underline">Send</span>
|
||||
<span className=" text-gray-400 dark:text-gray-600">|</span>
|
||||
<span className="opacity-70 cursor-pointer hover:underline">Sweep</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-end flex-1 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label="Toggle dark/light mode"
|
||||
className="text-black dark:text-white dark:hover:bg-zinc-700 hover:bg-zinc-200"
|
||||
>
|
||||
{theme === 'dark' ? <Sun /> : <Moon />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-black dark:text-white">
|
||||
<Settings />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
aria-label="Logout"
|
||||
className="text-black dark:text-white dark:hover:bg-zinc-700 hover:bg-zinc-200"
|
||||
>
|
||||
<LogOut />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
.icon {
|
||||
min-height: 19ch;
|
||||
}
|
||||
.title {
|
||||
min-height: 4ch;
|
||||
}
|
||||
.description {
|
||||
min-height: 17ch;
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import { useCallback, useState, useMemo } from 'react'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Sprite from './Sprite'
|
||||
import { useSettingsDispatch } from '../context/SettingsContext'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
export default function Onboarding() {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState(0)
|
||||
const settingsDispatch = useSettingsDispatch()
|
||||
|
||||
const steps = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('onboarding.screen_1_title'),
|
||||
description: t('onboarding.screen_1_description'),
|
||||
icon: <Sprite symbol="welcome" width="11rem" height="11rem" />,
|
||||
},
|
||||
{
|
||||
title: t('onboarding.screen_2_title'),
|
||||
description: t('onboarding.screen_2_description'),
|
||||
icon: <Sprite symbol="collab" width="10rem" height="10rem" />,
|
||||
},
|
||||
{
|
||||
title: t('onboarding.screen_3_title'),
|
||||
description: t('onboarding.screen_3_description'),
|
||||
icon: <Sprite symbol="key" width="11rem" height="11rem" />,
|
||||
},
|
||||
{
|
||||
title: t('onboarding.screen_4_title'),
|
||||
description: t('onboarding.screen_4_description'),
|
||||
icon: <Sprite symbol="handshake" width="11rem" height="11rem" />,
|
||||
},
|
||||
{
|
||||
title: t('onboarding.screen_5_title'),
|
||||
description: t('onboarding.screen_5_description'),
|
||||
icon: <Sprite symbol="shield-outline" width="11rem" height="11rem" />,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
)
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (step < steps.length) {
|
||||
setStep(step + 1)
|
||||
} else {
|
||||
settingsDispatch({ showOnboarding: false })
|
||||
}
|
||||
}, [step, steps.length, settingsDispatch])
|
||||
|
||||
const back = () => setStep((current) => Math.max(0, current - 1))
|
||||
|
||||
if (step === 0) {
|
||||
return (
|
||||
<>
|
||||
<div className="text-center mt-3 mb-4">
|
||||
<Sprite symbol="logo" width="128px" height="128px" className="mb-4" />
|
||||
<h1>{t('onboarding.splashscreen_title')}</h1>
|
||||
<h2 className="fw-normal text-secondary mb-5">{t('onboarding.splashscreen_subtitle')}</h2>
|
||||
<rb.Button className="w-100 mb-2" size="lg" variant="dark" onClick={next}>
|
||||
{t('onboarding.splashscreen_button_get_started')}
|
||||
</rb.Button>
|
||||
<rb.Button
|
||||
className="w-100 mb-2"
|
||||
size="lg"
|
||||
variant="outline-dark"
|
||||
onClick={() => settingsDispatch({ showOnboarding: false })}
|
||||
>
|
||||
{t('onboarding.splashscreen_button_skip_intro')}
|
||||
</rb.Button>
|
||||
</div>
|
||||
<div className="text-secondary mb-2">
|
||||
<p className="text-center mb-4">
|
||||
{t('onboarding.splashscreen_description_line1')}
|
||||
<br />
|
||||
{t('onboarding.splashscreen_description_line2')}
|
||||
</p>
|
||||
<div className="text-center fw-bolder">{t('onboarding.splashscreen_warning_title')}</div>
|
||||
<p className="text-justify">
|
||||
<Trans i18nKey="onboarding.splashscreen_warning_text">
|
||||
While JoinMarket is tried and tested, Jam is not. It is in a beta stage, so use with caution.
|
||||
<a
|
||||
href="https://github.com/joinmarket-webui/jam/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-secondary"
|
||||
>
|
||||
Help us improve the project on GitHub.
|
||||
</a>
|
||||
<a href="https://jamdocs.org" target="_blank" rel="noopener noreferrer" className="link-secondary">
|
||||
read the documentation
|
||||
</a>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
const content = steps[step - 1]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-center mt-3">
|
||||
<div className={`${styles.icon} d-flex justify-content-center align-items-center mb-4`}>{content.icon}</div>
|
||||
<h2 className={`${styles.title} d-flex justify-content-center align-items-center mb-2`}>{content.title}</h2>
|
||||
<div className={`${styles.description} d-flex justify-content-center align-items-center text-secondary mb-4`}>
|
||||
{content.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex flex-column align-items-center gap-2">
|
||||
<rb.Button className="w-50" variant="dark" size="lg" onClick={next}>
|
||||
{step === steps.length ? t('onboarding.button_complete') : t('onboarding.button_next')}
|
||||
</rb.Button>
|
||||
<rb.Button className="w-50" variant="none" size="sm" onClick={back}>
|
||||
{t('global.back')}
|
||||
</rb.Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
.orderbookContentPlaceholder {
|
||||
height: 2.625rem;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.overlayContainer .orderbookContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) {
|
||||
.overlayContainer .orderbookContainer {
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.overlayContainer .orderbookContainer .titleBar {
|
||||
min-height: 3.6rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem 0.8rem 0.5rem;
|
||||
background-color: var(--bs-gray-100);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) {
|
||||
.overlayContainer .orderbookContainer .titleBar {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
.overlayContainer .orderbookContainer .titleBar {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .overlayContainer .orderbookContainer .titleBar {
|
||||
background-color: var(--bs-gray-800);
|
||||
}
|
||||
|
||||
.overlayContainer .orderbookContainer .titleBar .refreshButton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.1rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.orderbookContainer tr.highlighted td {
|
||||
background-color: rgba(var(--bs-success-rgb), 0.33) !important;
|
||||
}
|
||||
.orderbookContainer tr:hover.highlighted td {
|
||||
background-color: rgba(var(--bs-success-rgb), 0.66) !important;
|
||||
}
|
||||
|
|
@ -1,742 +0,0 @@
|
|||
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table'
|
||||
import { usePagination } from '@table-library/react-table-library/pagination'
|
||||
import { useSort, HeaderCellSort, SortToggleType } from '@table-library/react-table-library/sort'
|
||||
import * as TableTypes from '@table-library/react-table-library/types/table'
|
||||
import { useTheme } from '@table-library/react-table-library/theme'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import { TFunction, i18n } from 'i18next'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AmountSats, Helper as ApiHelper } from '../libs/JmWalletApi'
|
||||
import * as ObwatchApi from '../libs/JmObwatchApi'
|
||||
import { useSettings } from '../context/SettingsContext'
|
||||
import Balance from './Balance'
|
||||
import Sprite from './Sprite'
|
||||
import TablePagination from './TablePagination'
|
||||
import { BTC, factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '../utils'
|
||||
import { isDebugFeatureEnabled, isDevMode } from '../constants/debugFeatures'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { pseudoRandomNumber } from './Send/helpers'
|
||||
import { JM_DUST_THRESHOLD } from '../constants/jm'
|
||||
import * as fb from './fb/utils'
|
||||
import styles from './Orderbook.module.css'
|
||||
|
||||
const TABLE_THEME = {
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 1fr 5rem 1fr 2fr 2fr 2fr 2fr;
|
||||
font-size: 0.9rem;
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(3) div button {
|
||||
justify-content: center;
|
||||
}
|
||||
&:nth-of-type(4) div button {
|
||||
justify-content: end;
|
||||
}
|
||||
&:nth-of-type(5) div button {
|
||||
justify-content: end;
|
||||
}
|
||||
&:nth-of-type(6) div button {
|
||||
justify-content: end;
|
||||
}
|
||||
&:nth-of-type(7) div button {
|
||||
justify-content: end;
|
||||
}
|
||||
&:nth-of-type(8) div button {
|
||||
justify-content: end;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
&:nth-of-type(3) {
|
||||
text-align: center;
|
||||
}
|
||||
&:nth-of-type(4) {
|
||||
text-align: right;
|
||||
}
|
||||
&:nth-of-type(5) {
|
||||
text-align: right;
|
||||
}
|
||||
&:nth-of-type(6) {
|
||||
text-align: right;
|
||||
}
|
||||
&:nth-of-type(7) {
|
||||
text-align: right;
|
||||
}
|
||||
&:nth-of-type(8) {
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
}
|
||||
|
||||
const withTooltip = (node: ReactElement, tooltip: string, overlayProps?: Partial<rb.OverlayTriggerProps>) => {
|
||||
return (
|
||||
<rb.OverlayTrigger {...overlayProps} overlay={(props) => <rb.Tooltip {...props}>{tooltip}</rb.Tooltip>}>
|
||||
{node}
|
||||
</rb.OverlayTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
const renderOrderType = (type: OrderTypeProps) => {
|
||||
const elem = <rb.Badge bg={type.badgeColor}>{type.displayValue}</rb.Badge>
|
||||
return type.tooltip ? withTooltip(elem, type.tooltip) : elem
|
||||
}
|
||||
|
||||
type OrderTypeProps = {
|
||||
value: string // original value, example: 'sw0reloffer', 'swreloffer', 'reloffer', 'sw0absoffer', 'swabsoffer', 'absoffer'
|
||||
displayValue: string // example: "absolute" or "relative" (respecting i18n)
|
||||
badgeColor: 'info' | 'primary' | 'secondary'
|
||||
tooltip?: 'Native SW Absolute Fee' | 'Native SW Relative Fee' | string
|
||||
isAbsolute?: boolean
|
||||
isRelative?: boolean
|
||||
}
|
||||
interface OrderTableEntry {
|
||||
type: OrderTypeProps
|
||||
counterparty: string // example: "J5Bv3JSxPFWm2Yjb"
|
||||
orderId: string // example: "0" (not unique!)
|
||||
fee: {
|
||||
value: number
|
||||
displayValue: string // example: "250" (abs offers) or "0.000100%" (rel offers)
|
||||
}
|
||||
minerFeeContribution: string // example: "0"
|
||||
minimumSize: string // example: "27300"
|
||||
maximumSize: string // example: "237499972700"
|
||||
bondValue: {
|
||||
value: number
|
||||
displayValue: string // example: "0" (no fb) or "114557102085.28133"
|
||||
locktime?: number
|
||||
displayLocktime?: string
|
||||
displayExpiresIn?: string
|
||||
amount?: AmountSats
|
||||
}
|
||||
}
|
||||
|
||||
interface OrderTableRow extends OrderTableEntry, TableTypes.TableNode {}
|
||||
|
||||
// `TableNode` is known to have same properties as `OrderTableEntry`, hence prefer casting over object destructuring
|
||||
const asOrderTableEntry = (tableNode: TableTypes.TableNode) => tableNode as unknown as OrderTableRow
|
||||
|
||||
const SORT_KEYS = {
|
||||
type: 'TYPE',
|
||||
counterparty: 'COUNTERPARTY',
|
||||
fee: 'FEE',
|
||||
minimumSize: 'MINIMUM_SIZE',
|
||||
maximumSize: 'MAXIMUM_SIZE',
|
||||
minerFeeContribution: 'MINER_FEE_CONTRIBUTION',
|
||||
bondValue: 'BOND_VALUE',
|
||||
}
|
||||
|
||||
const orderTypeProps = (offer: ObwatchApi.Offer, t: TFunction): OrderTypeProps => {
|
||||
if (isAbsoluteOffer(offer.ordertype)) {
|
||||
return {
|
||||
value: offer.ordertype,
|
||||
displayValue: t('orderbook.text_offer_type_absolute'),
|
||||
badgeColor: 'info',
|
||||
tooltip: offer.ordertype === 'sw0absoffer' ? 'Native SW Absolute Fee' : offer.ordertype,
|
||||
isAbsolute: true,
|
||||
}
|
||||
}
|
||||
if (isRelativeOffer(offer.ordertype)) {
|
||||
return {
|
||||
value: offer.ordertype,
|
||||
displayValue: t('orderbook.text_offer_type_relative'),
|
||||
badgeColor: 'primary',
|
||||
tooltip: offer.ordertype === 'sw0reloffer' ? 'Native SW Relative Fee' : offer.ordertype,
|
||||
isRelative: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: offer.ordertype,
|
||||
displayValue: offer.ordertype,
|
||||
badgeColor: 'secondary',
|
||||
}
|
||||
}
|
||||
|
||||
const renderOrderFee = (val: string, settings: any) => {
|
||||
return val.includes('%') ? (
|
||||
<span className="font-monospace">{val}</span>
|
||||
) : (
|
||||
<Balance valueString={val} convertToUnit={settings.unit} showBalance={true} />
|
||||
)
|
||||
}
|
||||
|
||||
const renderOrderAsRow = (item: OrderTableRow, settings: any) => {
|
||||
return (
|
||||
<Row key={item.id} item={item} className={item.__highlighted ? styles.highlighted : ''}>
|
||||
<Cell className="font-monospace">{item.counterparty}</Cell>
|
||||
<Cell>{item.orderId}</Cell>
|
||||
<Cell>{renderOrderType(item.type)}</Cell>
|
||||
<Cell>{renderOrderFee(item.fee.displayValue, settings)}</Cell>
|
||||
<Cell>
|
||||
<Balance valueString={item.minimumSize} convertToUnit={settings.unit} showBalance={true} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Balance valueString={item.maximumSize} convertToUnit={settings.unit} showBalance={true} />
|
||||
</Cell>
|
||||
<Cell hide={true}>
|
||||
<Balance valueString={item.minerFeeContribution} convertToUnit={settings.unit} showBalance={true} />
|
||||
</Cell>
|
||||
<Cell className="font-monospace">
|
||||
{item.bondValue.value > 0 ? (
|
||||
<rb.OverlayTrigger
|
||||
popperConfig={{
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 10],
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
overlay={(props) => (
|
||||
<rb.Tooltip {...props}>
|
||||
<Balance
|
||||
valueString={String(item.bondValue.amount)}
|
||||
colored={false}
|
||||
convertToUnit={BTC}
|
||||
showBalance={true}
|
||||
/>
|
||||
<div className="small">
|
||||
{item.bondValue.displayLocktime} ({item.bondValue.displayExpiresIn})
|
||||
</div>
|
||||
</rb.Tooltip>
|
||||
)}
|
||||
>
|
||||
<span>{item.bondValue.displayValue}</span>
|
||||
</rb.OverlayTrigger>
|
||||
) : (
|
||||
<>{item.bondValue.displayValue}</>
|
||||
)}
|
||||
</Cell>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
interface OrderbookTableProps {
|
||||
data: TableTypes.Data<OrderTableRow>
|
||||
}
|
||||
|
||||
const OrderbookTable = ({ data }: OrderbookTableProps) => {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
|
||||
const tableTheme = useTheme(TABLE_THEME)
|
||||
const pagination = usePagination(data, {
|
||||
state: {
|
||||
page: 0,
|
||||
size: 25,
|
||||
},
|
||||
})
|
||||
|
||||
const tableSort = useSort(
|
||||
data,
|
||||
{
|
||||
state: {
|
||||
sortKey: SORT_KEYS.minimumSize,
|
||||
reverse: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sortIcon: {
|
||||
margin: '4px',
|
||||
iconDefault: <Sprite symbol="caret-right" width="20" height="20" />,
|
||||
iconUp: <Sprite symbol="caret-up" width="20" height="20" />,
|
||||
iconDown: <Sprite symbol="caret-down" width="20" height="20" />,
|
||||
},
|
||||
sortToggleType: SortToggleType.AlternateWithReset,
|
||||
sortFns: {
|
||||
[SORT_KEYS.type]: (array) => array.sort((a, b) => a.type.displayValue.localeCompare(b.type.displayValue)),
|
||||
[SORT_KEYS.fee]: (array) =>
|
||||
array.sort((a, b) => {
|
||||
const aOrder = asOrderTableEntry(a)
|
||||
const bOrder = asOrderTableEntry(b)
|
||||
|
||||
if (aOrder.type.isAbsolute !== bOrder.type.isAbsolute) {
|
||||
return aOrder.type.isAbsolute === true ? 1 : -1
|
||||
}
|
||||
return aOrder.fee.value - bOrder.fee.value
|
||||
}),
|
||||
[SORT_KEYS.minimumSize]: (array) => array.sort((a, b) => a.minimumSize - b.minimumSize),
|
||||
[SORT_KEYS.maximumSize]: (array) => array.sort((a, b) => a.maximumSize - b.maximumSize),
|
||||
[SORT_KEYS.minerFeeContribution]: (array) =>
|
||||
array.sort((a, b) => a.minerFeeContribution - b.minerFeeContribution),
|
||||
|
||||
[SORT_KEYS.counterparty]: (array) =>
|
||||
array.sort((a, b) => {
|
||||
const val = a.counterparty.localeCompare(b.counterparty)
|
||||
return val !== 0 ? val : +a.orderId - +b.orderId
|
||||
}),
|
||||
[SORT_KEYS.bondValue]: (array) => array.sort((a, b) => a.bondValue.value - b.bondValue.value),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const pinnedOfferRows = useMemo(
|
||||
() =>
|
||||
data.nodes
|
||||
.filter((item: OrderTableRow) => item.__pinned === true)
|
||||
.map((item: OrderTableRow) => renderOrderAsRow(item, settings)),
|
||||
[data, settings],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
data={data}
|
||||
theme={tableTheme}
|
||||
pagination={pagination}
|
||||
sort={tableSort}
|
||||
layout={{ custom: true, horizontalScroll: true }}
|
||||
className="table striped"
|
||||
>
|
||||
{(tableList: TableTypes.TableProps<OrderTableRow>) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.counterparty}>
|
||||
{t('orderbook.table.heading_counterparty')}
|
||||
</HeaderCellSort>
|
||||
<HeaderCell>{t('orderbook.table.heading_order_id')}</HeaderCell>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.type}>{t('orderbook.table.heading_type')}</HeaderCellSort>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.fee}>{t('orderbook.table.heading_fee')}</HeaderCellSort>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.minimumSize}>
|
||||
{t('orderbook.table.heading_minimum_size')}
|
||||
</HeaderCellSort>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.maximumSize}>
|
||||
{t('orderbook.table.heading_maximum_size')}
|
||||
</HeaderCellSort>
|
||||
<HeaderCellSort hide={true} sortKey={SORT_KEYS.minerFeeContribution}>
|
||||
{t('orderbook.table.heading_miner_fee_contribution')}
|
||||
</HeaderCellSort>
|
||||
<HeaderCellSort sortKey={SORT_KEYS.bondValue}>{t('orderbook.table.heading_bond_value')}</HeaderCellSort>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{pinnedOfferRows}
|
||||
{tableList
|
||||
.filter((item: OrderTableRow) => item.__pinned !== true)
|
||||
.map((item: OrderTableRow) => renderOrderAsRow(item, settings))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
<div className="mt-4 mb-4 mb-lg-0">
|
||||
<TablePagination data={data} pagination={pagination} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const offerToTableEntry = (
|
||||
offer: ObwatchApi.Offer,
|
||||
fidelityBond: ObwatchApi.FidelityBond | undefined,
|
||||
i18n: i18n,
|
||||
): OrderTableEntry => {
|
||||
return {
|
||||
type: orderTypeProps(offer, i18n.t),
|
||||
counterparty: offer.counterparty,
|
||||
orderId: String(offer.oid),
|
||||
fee:
|
||||
typeof offer.cjfee === 'number'
|
||||
? {
|
||||
value: offer.cjfee,
|
||||
displayValue: String(offer.cjfee),
|
||||
}
|
||||
: (() => {
|
||||
const value = parseFloat(offer.cjfee)
|
||||
return {
|
||||
value,
|
||||
displayValue: factorToPercentage(value).toFixed(4) + '%',
|
||||
}
|
||||
})(),
|
||||
minerFeeContribution: String(offer.txfee),
|
||||
minimumSize: String(offer.minsize),
|
||||
maximumSize: String(offer.maxsize),
|
||||
bondValue: {
|
||||
value: offer.fidelity_bond_value,
|
||||
displayValue: String(offer.fidelity_bond_value.toFixed(0)),
|
||||
locktime: fidelityBond?.locktime,
|
||||
displayLocktime:
|
||||
fidelityBond?.locktime !== undefined ? new Date(fidelityBond.locktime * 1_000).toDateString() : undefined,
|
||||
displayExpiresIn:
|
||||
fidelityBond?.locktime !== undefined
|
||||
? fb.time.humanReadableDuration({
|
||||
to: fidelityBond.locktime * 1_000,
|
||||
locale: i18n.resolvedLanguage || i18n.language,
|
||||
})
|
||||
: undefined,
|
||||
amount: fidelityBond?.amount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface OrderbookProps {
|
||||
entries: OrderTableEntry[]
|
||||
reload: (signal: AbortSignal) => Promise<void>
|
||||
isReloading: boolean
|
||||
refresh: (signal: AbortSignal) => Promise<void>
|
||||
isRefreshing: boolean
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
export function Orderbook({ entries, reload, isReloading, refresh, isRefreshing, nickname }: OrderbookProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
const [search, setSearch] = useState('')
|
||||
const [isHighlightOwnOffers, setIsHighlightOwnOffers] = useState(false)
|
||||
const [isPinToTopOwnOffers, setIsPinToTopOwnOffers] = useState(false)
|
||||
const [highlightedOrders, setHighlightedOrders] = useState<OrderTableEntry[]>([])
|
||||
const [pinToTopOrders, setPinToTopOrders] = useState<OrderTableEntry[]>([])
|
||||
const isLoading = useMemo(() => isReloading || isRefreshing, [isReloading, isRefreshing])
|
||||
|
||||
const tableData: TableTypes.Data<OrderTableRow> = useMemo(() => {
|
||||
const searchVal = search.replace('.', '').toLowerCase()
|
||||
const filteredOrders =
|
||||
searchVal === ''
|
||||
? entries
|
||||
: entries.filter((entry) => {
|
||||
return (
|
||||
entry.type.displayValue.toLowerCase().includes(searchVal) ||
|
||||
entry.counterparty.toLowerCase().includes(searchVal) ||
|
||||
entry.fee.displayValue.replace('.', '').toLowerCase().includes(searchVal) ||
|
||||
entry.minimumSize.replace('.', '').toLowerCase().includes(searchVal) ||
|
||||
entry.maximumSize.replace('.', '').toLowerCase().includes(searchVal) ||
|
||||
entry.minerFeeContribution.replace('.', '').toLowerCase().includes(searchVal) ||
|
||||
entry.bondValue.displayValue.replace('.', '').toLowerCase().includes(searchVal) ||
|
||||
entry.orderId.toLowerCase().includes(searchVal)
|
||||
)
|
||||
})
|
||||
const nodes = filteredOrders.map((order) => ({
|
||||
...order,
|
||||
id: `${order.counterparty}_${order.orderId}`,
|
||||
__highlighted: highlightedOrders.includes(order),
|
||||
__pinned: pinToTopOrders.includes(order),
|
||||
}))
|
||||
|
||||
return { nodes }
|
||||
}, [entries, search, highlightedOrders, pinToTopOrders])
|
||||
|
||||
const counterpartyCount = useMemo(() => new Set(entries.map((it) => it.counterparty)).size, [entries])
|
||||
const counterpartyCountFiltered = useMemo(
|
||||
() => new Set(tableData.nodes.map((it) => it.counterparty)).size,
|
||||
[tableData],
|
||||
)
|
||||
|
||||
const ownOffers = useMemo(() => {
|
||||
return nickname ? entries.filter((it) => it.counterparty === nickname) : []
|
||||
}, [nickname, entries])
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedOrders(isHighlightOwnOffers ? ownOffers : [])
|
||||
}, [ownOffers, isHighlightOwnOffers])
|
||||
|
||||
useEffect(() => {
|
||||
setPinToTopOrders(isPinToTopOwnOffers ? ownOffers : [])
|
||||
}, [ownOffers, isPinToTopOwnOffers])
|
||||
|
||||
return (
|
||||
<div className={styles.orderbookContainer}>
|
||||
<div className={styles.titleBar}>
|
||||
<div className="d-flex justify-content-center align-items-center gap-3">
|
||||
<rb.SplitButton
|
||||
size="sm"
|
||||
variant={`${settings.theme === 'dark' ? 'outline-dark' : 'outline-dark'}`}
|
||||
title={
|
||||
<div className={styles.refreshButton} title={t('orderbook.button_reload_title')}>
|
||||
{isLoading ? (
|
||||
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" />
|
||||
) : (
|
||||
<Sprite symbol="refresh" width="24" height="24" />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (isLoading) return
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
console.log('Reloading orderbook...')
|
||||
reload(abortCtrl.signal).finally(() => {
|
||||
console.log('Finished reloading orderbook.')
|
||||
})
|
||||
}}
|
||||
>
|
||||
<rb.Dropdown.Item
|
||||
eventKey="1"
|
||||
active={false}
|
||||
title={t('orderbook.button_refresh_title')}
|
||||
onClick={() => {
|
||||
if (isLoading) return
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
console.log('Refreshing orderbook...')
|
||||
refresh(abortCtrl.signal).finally(() => {
|
||||
console.log('Finished refreshing orderbook.')
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('orderbook.button_refresh_text')}
|
||||
</rb.Dropdown.Item>
|
||||
</rb.SplitButton>
|
||||
<div className="small">
|
||||
{search === '' ? (
|
||||
<>
|
||||
{t('orderbook.text_orderbook_summary', {
|
||||
count: entries.length,
|
||||
counterpartyCount,
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('orderbook.text_orderbook_summary_filtered', {
|
||||
count: tableData.nodes.length,
|
||||
counterpartyCount: counterpartyCountFiltered,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<rb.Form.Group controlId="search">
|
||||
<rb.Form.Label className="m-0 pe-2 d-none">{t('orderbook.label_search')}</rb.Form.Label>
|
||||
<rb.Form.Control
|
||||
name="search"
|
||||
placeholder={t('orderbook.placeholder_search')}
|
||||
value={search}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</rb.Form.Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-md-3 pb-2">
|
||||
{entries.length === 0 ? (
|
||||
<rb.Alert variant="info">{t('orderbook.alert_empty_orderbook')}</rb.Alert>
|
||||
) : (
|
||||
<>
|
||||
{nickname && (
|
||||
<>
|
||||
<div className="d-flex flex-column gap-2 mb-3 ps-3 ps-md-0 pt-3 pt-lg-0">
|
||||
<ToggleSwitch
|
||||
label={t('orderbook.label_highlight_own_orders')}
|
||||
subtitle={ownOffers.length === 0 ? t('orderbook.text_highlight_own_orders_subtitle') : undefined}
|
||||
toggledOn={isHighlightOwnOffers}
|
||||
onToggle={(isToggled) => setIsHighlightOwnOffers(isToggled)}
|
||||
disabled={isLoading || ownOffers.length === 0}
|
||||
/>
|
||||
{ownOffers.length > 0 && (
|
||||
<ToggleSwitch
|
||||
label={t('orderbook.label_pin_to_top_own_orders')}
|
||||
subtitle={t('orderbook.text_pin_to_top_own_orders_subtitle')}
|
||||
toggledOn={isPinToTopOwnOffers}
|
||||
onToggle={(isToggled) => {
|
||||
setIsPinToTopOwnOffers(isToggled)
|
||||
if (isToggled) {
|
||||
setIsHighlightOwnOffers(true)
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-3 ps-3 ps-md-0 pt-3 pt-lg-0"></div>
|
||||
</>
|
||||
)}
|
||||
<OrderbookTable data={tableData} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type OrderbookOverlayProps = rb.OffcanvasProps & {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
export function OrderbookOverlay({ nickname, show, onHide }: OrderbookOverlayProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [alert, setAlert] = useState<SimpleAlert>()
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [isReloading, setIsReloading] = useState(true)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [offers, setOffers] = useState<ObwatchApi.Offer[]>()
|
||||
const [fidelityBonds, setFidelityBonds] = useState<Map<string, ObwatchApi.FidelityBond>>()
|
||||
const [__dev_showGenerateDemoOfferButton] = useState(isDebugFeatureEnabled('enableDemoOrderbook'))
|
||||
const tableEntries = useMemo(() => {
|
||||
return (
|
||||
offers &&
|
||||
offers.map((offer) => offerToTableEntry(offer, fidelityBonds && fidelityBonds.get(offer.counterparty), i18n))
|
||||
)
|
||||
}, [offers, fidelityBonds, i18n])
|
||||
|
||||
const __dev_generateDemoReportEntryButton = () => {
|
||||
const randomMinsize = pseudoRandomNumber(JM_DUST_THRESHOLD, JM_DUST_THRESHOLD + 100_000)
|
||||
const randomOrdertype = Math.random() > 0.5 ? 'sw0absoffer' : 'sw0reloffer'
|
||||
const randomCounterparty = `demo_` + pseudoRandomNumber(0, 10)
|
||||
setOffers((it) => {
|
||||
const randomOffer = {
|
||||
counterparty: randomCounterparty,
|
||||
oid: (it || []).filter((e) => e.counterparty === randomCounterparty).length,
|
||||
ordertype: randomOrdertype,
|
||||
minsize: randomMinsize,
|
||||
maxsize: randomMinsize + pseudoRandomNumber(21_000, 21_000_000),
|
||||
txfee: 0,
|
||||
cjfee: randomOrdertype === 'sw0absoffer' ? pseudoRandomNumber(0, 10_000) : Math.random().toFixed(5),
|
||||
fidelity_bond_value: Math.random() > 0.25 ? 0 : pseudoRandomNumber(1_000, 21_000_000),
|
||||
}
|
||||
return [...(it || []), randomOffer]
|
||||
})
|
||||
}
|
||||
|
||||
const reload = useCallback(
|
||||
(signal: AbortSignal, delay: number = 200) => {
|
||||
setIsReloading(true)
|
||||
return (
|
||||
ObwatchApi.fetchOrderbook({ signal })
|
||||
// show the loader a little longer to avoid flickering
|
||||
.then((it) => new Promise<ObwatchApi.OrderbookJson>((resolve) => setTimeout(() => resolve(it), delay)))
|
||||
.then((orderbook) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
setIsReloading(false)
|
||||
setAlert(undefined)
|
||||
setOffers(orderbook.offers || [])
|
||||
setFidelityBonds(new Map((orderbook.fidelitybonds || []).map((it) => [it.counterparty, it])))
|
||||
|
||||
if (isDevMode()) {
|
||||
console.table(orderbook.offers)
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (signal.aborted) return
|
||||
const message = t('orderbook.error_loading_orderbook_failed', {
|
||||
reason: e.message || t('global.errors.reason_unknown'),
|
||||
})
|
||||
setAlert({ variant: 'danger', message })
|
||||
})
|
||||
.finally(() => {
|
||||
setIsReloading(false)
|
||||
})
|
||||
)
|
||||
},
|
||||
[t],
|
||||
)
|
||||
|
||||
const refresh = useCallback(
|
||||
(signal: AbortSignal) => {
|
||||
setIsRefreshing(true)
|
||||
return ObwatchApi.refreshOrderbook({ signal, redirect: 'manual' })
|
||||
.then((res) => {
|
||||
if (!res.ok && res.type !== 'opaqueredirect') {
|
||||
// e.g. error is raised if ob-watcher is not running
|
||||
return ApiHelper.throwError(res)
|
||||
}
|
||||
return reload(signal, 0)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (signal.aborted) return
|
||||
const message = t('orderbook.error_loading_orderbook_failed', {
|
||||
reason: e.message || t('global.errors.reason_unknown'),
|
||||
})
|
||||
setAlert({ variant: 'danger', message })
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRefreshing(false)
|
||||
})
|
||||
},
|
||||
[reload, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
|
||||
const abortCtrl = new AbortController()
|
||||
reload(abortCtrl.signal).finally(() => {
|
||||
if (abortCtrl.signal.aborted) return
|
||||
setIsInitialized(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortCtrl.abort()
|
||||
}
|
||||
}, [show, reload])
|
||||
|
||||
return (
|
||||
<rb.Offcanvas
|
||||
className={`offcanvas-fullscreen ${styles.overlayContainer}`}
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
placement="bottom"
|
||||
>
|
||||
<rb.Offcanvas.Header>
|
||||
<rb.Container fluid="lg">
|
||||
<div className="w-100 d-flex">
|
||||
<div className="d-flex align-items-center flex-1">
|
||||
<rb.Offcanvas.Title>{t('orderbook.title')}</rb.Offcanvas.Title>
|
||||
</div>
|
||||
<div>
|
||||
<rb.Button variant="link" className="unstyled pe-0" onClick={onHide}>
|
||||
<Sprite symbol="cancel" width="32" height="32" />
|
||||
</rb.Button>
|
||||
</div>
|
||||
</div>
|
||||
</rb.Container>
|
||||
</rb.Offcanvas.Header>
|
||||
<rb.Offcanvas.Body>
|
||||
<rb.Container fluid="lg" className="py-3">
|
||||
{!isInitialized && isReloading ? (
|
||||
Array(5)
|
||||
.fill('')
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<rb.Placeholder key={index} as="div" animation="wave">
|
||||
<rb.Placeholder xs={12} className={styles.orderbookContentPlaceholder} />
|
||||
</rb.Placeholder>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{__dev_showGenerateDemoOfferButton && (
|
||||
<rb.Row>
|
||||
<rb.Col className="px-0 mb-2">
|
||||
<rb.Button
|
||||
className="position-relative"
|
||||
variant="outline-dark"
|
||||
disabled={false}
|
||||
onClick={() => __dev_generateDemoReportEntryButton()}
|
||||
>
|
||||
<div className="d-flex justify-content-center align-items-center">
|
||||
Generate demo entry
|
||||
<Sprite symbol="plus" width="20" height="20" className="ms-2" />
|
||||
</div>
|
||||
<span className="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-warning">
|
||||
dev
|
||||
</span>
|
||||
</rb.Button>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
|
||||
{isInitialized && (
|
||||
<rb.Row>
|
||||
<rb.Col className="px-0">
|
||||
<Orderbook
|
||||
nickname={nickname}
|
||||
entries={tableEntries || []}
|
||||
reload={reload}
|
||||
isReloading={isReloading}
|
||||
refresh={refresh}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</rb.Container>
|
||||
</rb.Offcanvas.Body>
|
||||
</rb.Offcanvas>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import Sprite from './Sprite'
|
||||
import classNames from 'classnames'
|
||||
|
||||
interface PageTitleProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
success?: boolean
|
||||
center?: boolean
|
||||
}
|
||||
|
||||
export default function PageTitle({ title, subtitle, success = false, center = false }: PageTitleProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames('mb-4', {
|
||||
'text-center': center,
|
||||
})}
|
||||
>
|
||||
{success && (
|
||||
<div
|
||||
className={classNames('mb-2', {
|
||||
'd-flex align-items-center justify-content-center': center,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
style={{
|
||||
width: '3rem',
|
||||
height: '3rem',
|
||||
backgroundColor: 'rgba(39, 174, 96, 1)',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<Sprite symbol="checkmark" width="24" height="30" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '1.3rem', fontWeight: '600', marginBottom: '0.5rem' }}>{title}</div>
|
||||
{subtitle && <p className="text-secondary">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
.infoIcon {
|
||||
margin: 2px 0 0 0.25rem;
|
||||
color: var(--bs-gray-500);
|
||||
border: 1px solid var(--bs-gray-500);
|
||||
border-radius: 50%;
|
||||
cursor: help;
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import { PropsWithChildren, useMemo, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import * as rb from 'react-bootstrap'
|
||||
import Sprite from './Sprite'
|
||||
import Balance from './Balance'
|
||||
import { Settings, useSettings } from '../context/SettingsContext'
|
||||
import { FeeValues, TxFee, useEstimatedMaxCollaboratorFee } from '../hooks/Fees'
|
||||
import { ConfirmModal, ConfirmModalProps } from './Modal'
|
||||
import { AmountSats, BitcoinAddress } from '../libs/JmWalletApi'
|
||||
import { jarInitial } from './jars/Jar'
|
||||
import { isValidNumber } from '../utils'
|
||||
import styles from './PaymentConfirmModal.module.css'
|
||||
import { Utxos } from '../context/WalletContext'
|
||||
import { UtxoListDisplay } from './Send/ShowUtxos'
|
||||
import Divider from './Divider'
|
||||
|
||||
const feeRange: (txFee: TxFee, txFeeFactor: number) => [number, number] = (txFee, txFeeFactor) => {
|
||||
if (txFee.unit !== 'sats/kilo-vbyte') {
|
||||
throw new Error('This function can only be used with unit `sats/kilo-vbyte`')
|
||||
}
|
||||
const feeTargetInSatsPerVByte = txFee.value! / 1_000
|
||||
const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte)
|
||||
const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + txFeeFactor)
|
||||
return [minFeeSatsPerVByte, maxFeeSatsPerVByte]
|
||||
}
|
||||
|
||||
const useMiningFeeText = ({ tx_fees, tx_fees_factor }: Pick<FeeValues, 'tx_fees' | 'tx_fees_factor'>) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(() => {
|
||||
if (!isValidNumber(tx_fees?.value) || !isValidNumber(tx_fees_factor)) return null
|
||||
|
||||
if (!tx_fees?.unit) {
|
||||
return null
|
||||
} else if (tx_fees.unit === 'blocks') {
|
||||
return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: tx_fees.value })
|
||||
} else {
|
||||
const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange(tx_fees, tx_fees_factor!)
|
||||
const fractionDigits = 2
|
||||
|
||||
if (minFeeSatsPerVByte.toFixed(fractionDigits) === maxFeeSatsPerVByte.toFixed(fractionDigits)) {
|
||||
return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_exact', {
|
||||
value: minFeeSatsPerVByte.toLocaleString(undefined, {
|
||||
maximumFractionDigits: Math.log10(1_000),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_randomized', {
|
||||
min: minFeeSatsPerVByte.toLocaleString(undefined, {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}),
|
||||
max: maxFeeSatsPerVByte.toLocaleString(undefined, {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}, [t, tx_fees, tx_fees_factor])
|
||||
}
|
||||
|
||||
type ReviewUtxosProps = Required<Pick<PaymentDisplayInfo, 'isSweep' | 'availableUtxos'>> & {
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
const ReviewUtxos = ({ settings, availableUtxos, isSweep }: ReviewUtxosProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState<boolean>(availableUtxos.length === 1)
|
||||
|
||||
const allUtxosAreUsed = isSweep || availableUtxos.length === 1
|
||||
return (
|
||||
<rb.Row className="mt-2">
|
||||
<rb.Col xs={4} md={3} className="d-flex align-items-center justify-content-end text-end">
|
||||
<strong>
|
||||
{allUtxosAreUsed
|
||||
? t('send.confirm_send_modal.label_selected_utxos', { count: availableUtxos.length })
|
||||
: t('send.confirm_send_modal.label_eligible_utxos')}
|
||||
</strong>
|
||||
</rb.Col>
|
||||
<rb.Col xs={8} md={9}>
|
||||
<Divider toggled={isOpen} onToggle={() => setIsOpen((current) => !current)} />
|
||||
</rb.Col>
|
||||
<rb.Collapse in={isOpen}>
|
||||
<rb.Col xs={12}>
|
||||
<div className="my-2 text-start text-secondary">
|
||||
{allUtxosAreUsed
|
||||
? t('send.confirm_send_modal.description_selected_utxos', { count: availableUtxos.length })
|
||||
: t('send.confirm_send_modal.description_eligible_utxos')}
|
||||
</div>
|
||||
<UtxoListDisplay
|
||||
utxos={availableUtxos.map((it) => ({ ...it, checked: false, selectable: false }))}
|
||||
settings={settings}
|
||||
onToggle={() => {
|
||||
// No-op since these UTXOs are only for review and are not selectable
|
||||
}}
|
||||
/>
|
||||
</rb.Col>
|
||||
</rb.Collapse>
|
||||
</rb.Row>
|
||||
)
|
||||
}
|
||||
|
||||
interface PaymentDisplayInfo {
|
||||
sourceJarIndex?: JarIndex
|
||||
destination: BitcoinAddress | string
|
||||
amount: AmountSats
|
||||
isSweep: boolean
|
||||
isCoinjoin: boolean
|
||||
numCollaborators?: number
|
||||
feeConfigValues?: FeeValues
|
||||
showPrivacyInfo?: boolean
|
||||
availableUtxos?: Utxos
|
||||
}
|
||||
|
||||
interface PaymentConfirmModalProps extends ConfirmModalProps {
|
||||
data: PaymentDisplayInfo
|
||||
}
|
||||
|
||||
export function PaymentConfirmModal({
|
||||
data: {
|
||||
sourceJarIndex,
|
||||
destination,
|
||||
amount,
|
||||
isSweep,
|
||||
isCoinjoin,
|
||||
numCollaborators,
|
||||
feeConfigValues,
|
||||
showPrivacyInfo = true,
|
||||
availableUtxos = [],
|
||||
},
|
||||
children,
|
||||
...confirmModalProps
|
||||
}: PropsWithChildren<PaymentConfirmModalProps>) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettings()
|
||||
|
||||
const miningFeeText = useMiningFeeText({ ...feeConfigValues })
|
||||
const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({
|
||||
isCoinjoin,
|
||||
feeConfigValues,
|
||||
amount,
|
||||
numCollaborators: numCollaborators || null,
|
||||
})
|
||||
|
||||
return (
|
||||
<ConfirmModal {...confirmModalProps}>
|
||||
<rb.Container className="mt-2" fluid>
|
||||
{showPrivacyInfo && (
|
||||
<rb.Row className="mt-2 mb-3">
|
||||
<rb.Col xs={12} className="text-center">
|
||||
{isCoinjoin ? (
|
||||
<strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
|
||||
) : (
|
||||
<strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
|
||||
)}
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
{sourceJarIndex !== undefined && (
|
||||
<rb.Row>
|
||||
<rb.Col xs={4} md={3} className="text-end">
|
||||
<strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
|
||||
</rb.Col>
|
||||
<rb.Col xs={8} md={9} className="text-start">
|
||||
{t('send.confirm_send_modal.text_source_jar', { jarId: jarInitial(sourceJarIndex) })}
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
<rb.Row>
|
||||
<rb.Col xs={4} md={3} className="text-end">
|
||||
<strong>{t('send.confirm_send_modal.label_recipient')}</strong>
|
||||
</rb.Col>
|
||||
<rb.Col xs={8} md={9} className="text-start text-break slashed-zeroes">
|
||||
{destination}
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
<rb.Row>
|
||||
<rb.Col xs={4} md={3} className="text-end">
|
||||
<strong>{t('send.confirm_send_modal.label_amount')}</strong>
|
||||
</rb.Col>
|
||||
<rb.Col xs={8} md={9} className="text-start">
|
||||
{isSweep ? (
|
||||
<>
|
||||
<Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
|
||||
Sweep
|
||||
<Balance valueString={String(amount)} convertToUnit={settings.unit} showBalance={true} />
|
||||
</Trans>
|
||||
<rb.OverlayTrigger
|
||||
placement="right"
|
||||
overlay={
|
||||
<rb.Popover>
|
||||
<rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
|
||||
</rb.Popover>
|
||||
}
|
||||
>
|
||||
<div className="d-inline-flex align-items-center">
|
||||
<Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
|
||||
</div>
|
||||
</rb.OverlayTrigger>
|
||||
</>
|
||||
) : (
|
||||
<Balance valueString={String(amount)} convertToUnit={settings.unit} showBalance={true} />
|
||||
)}
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
{isCoinjoin && (
|
||||
<rb.Row>
|
||||
<rb.Col xs={4} md={3} className="text-end">
|
||||
<strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
|
||||
</rb.Col>
|
||||
<rb.Col xs={8} md={9} className="text-start">
|
||||
{numCollaborators}
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
{estimatedMaxCollaboratorFee && (
|
||||
<rb.Row>
|
||||
<rb.Col xs={4} md={3} className="text-end">
|
||||
<strong>{t('send.confirm_send_modal.label_estimated_max_collaborator_fee')}</strong>
|
||||
</rb.Col>
|
||||
<rb.Col xs={8} md={9} className="d-inline-flex align-items-center text-start">
|
||||
<div>
|
||||
≤
|
||||
<Balance
|
||||
valueString={`${estimatedMaxCollaboratorFee}`}
|
||||
convertToUnit={settings.unit}
|
||||
showBalance={true}
|
||||
/>
|
||||
<rb.OverlayTrigger
|
||||
placement="right"
|
||||
overlay={
|
||||
<rb.Popover>
|
||||
<rb.Popover.Body>
|
||||
{t('send.confirm_send_modal.text_estimated_max_collaborator_fee_info_popover')}
|
||||
</rb.Popover.Body>
|
||||
</rb.Popover>
|
||||
}
|
||||
>
|
||||
<div className="d-inline-flex align-items-center">
|
||||
<Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
|
||||
</div>
|
||||
</rb.OverlayTrigger>
|
||||
</div>
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
{miningFeeText && (
|
||||
<rb.Row>
|
||||
<rb.Col xs={4} md={3} className="text-end">
|
||||
<strong>{t('send.confirm_send_modal.label_miner_fee')}</strong>
|
||||
</rb.Col>
|
||||
<rb.Col xs={8} md={9} className="text-start">
|
||||
{miningFeeText}
|
||||
</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
{availableUtxos.length > 0 && (
|
||||
<ReviewUtxos settings={settings} availableUtxos={availableUtxos} isSweep={isSweep} />
|
||||
)}
|
||||
{children && (
|
||||
<rb.Row>
|
||||
<rb.Col xs={12}>{children}</rb.Col>
|
||||
</rb.Row>
|
||||
)}
|
||||
</rb.Container>
|
||||
</ConfirmModal>
|
||||
)
|
||||
}
|
||||