diff --git a/.env b/.env index 207d5a1c..d42a352f 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ VITE_JAM_DEFAULT_THEME=dark -VITE_JAM_REPO_URL=https://github.com/joinmarket-webui +VITE_JAM_REPO_URL=https://github.com/joinmarket-webui/jam VITE_JAM_DOCS_URL=https://jamdocs.org VITE_JAM_MATRIX_URL=https://matrix.to/#/%23jam:bitcoin.kyoto VITE_JAM_TELEGRAM_URL=https://t.me/JoinMarketWebUI @@ -23,3 +23,6 @@ VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=60000 VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT=10 VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT=90 + +VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS=true +VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1000 diff --git a/.env.development b/.env.development index 3a37e0c6..9b4b3df4 100644 --- a/.env.development +++ b/.env.development @@ -13,3 +13,5 @@ VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1 VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3 VITE_JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS=1 + +VITE_JAM_RUNNING_SCHEDULE_POLLING_INTERVAL=5000 diff --git a/.env.test b/.env.test index f379760f..214a58a6 100644 --- a/.env.test +++ b/.env.test @@ -3,3 +3,5 @@ VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3 VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=1 VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=1 + +VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 01fa7889..db7f7bd4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,14 +11,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: ['v26.3.0'] + node-version: ['v26.5.0'] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup (Node.js ${{ matrix.node-version }}) - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} @@ -30,7 +30,7 @@ jobs: run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').packages['node_modules/@playwright/test'].version)")" >> $GITHUB_ENV - name: Cache Playwright - uses: actions/cache@v5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: playwright-cache with: path: | @@ -59,4 +59,4 @@ jobs: # Set if: always() to also generate the report if tests are failing # Only works if you set `reportOnFailure: true` in vite config if: ${{ always() && hashFiles('coverage/coverage-summary.json') != '' }} - uses: davelosert/vitest-coverage-report-action@v2 + uses: davelosert/vitest-coverage-report-action@8b157684c6a6b259b97d45e72b44242865c0f6a5 # v2.12.2 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e89f19fa..ae329291 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v7 + with: + persist-credentials: false # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/storybook-build.yml b/.github/workflows/storybook-build.yml index 35594961..01d50de6 100644 --- a/.github/workflows/storybook-build.yml +++ b/.github/workflows/storybook-build.yml @@ -12,10 +12,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc diff --git a/.github/workflows/storybook-pages.yml b/.github/workflows/storybook-pages.yml index a0cdacb5..e23a3876 100644 --- a/.github/workflows/storybook-pages.yml +++ b/.github/workflows/storybook-pages.yml @@ -24,10 +24,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc @@ -38,13 +38,13 @@ jobs: run: npm run storybook:build-gh-pages - name: Configure Pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: storybook-static - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.nvmrc b/.nvmrc index 84a2f4a2..eacd4817 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v26.3.0 +v26.5.0 diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d4363bf0..b6009ab1 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,7 +2,8 @@ import { Suspense, useEffect } from 'react' import type { Preview } from '@storybook/react-vite' import { QueryClientProvider, QueryClient } from '@tanstack/react-query' -import { initialize, mswLoader } from 'msw-storybook-addon' +import { mswLoader } from 'msw-storybook-addon/csf3' +import { setupWorker } from 'msw/browser' import { ThemeProvider } from 'next-themes' import { I18nextProvider } from 'react-i18next' import { MemoryRouter } from 'react-router-dom' @@ -17,15 +18,6 @@ import mswHandlers from './msw-handlers' // needed if you want to use msw on a subpath (e.g. github pages /) const mswServiceWorkerUrl = import.meta.env.STORYBOOK_MSW_SERVICE_WORKER_URL ?? '/mockServiceWorker.js' -// Initialize MSW (https://github.com/mswjs/msw-storybook-addon) -initialize({ - onUnhandledRequest: 'bypass', - serviceWorker: { - url: mswServiceWorkerUrl, - }, - quiet: true, -}) - const locales = [ { value: 'en', title: 'en' }, { value: 'fr', title: 'fr' }, @@ -85,12 +77,28 @@ const preview: Preview = { viewport: { options: { ...MINIMAL_VIEWPORTS, ...INITIAL_VIEWPORTS }, }, + // this approach is deprecated as of v3.0.0 + // but it seems the new approach does not work as expected (404 response for our handlers) + // revisit after next update (last checked 2026-07-30) + // see: https://github.com/mswjs/msw-storybook-addon/blob/v3.0.0/MIGRATION.md#parametersmsw-is-deprecated-in-favor-of-beforeeach msw: { handlers: mswHandlers, }, }, // Provide the MSW addon loader globally - loaders: [mswLoader], + loaders: [ + mswLoader(async () => { + const worker = setupWorker() + await worker.start({ + onUnhandledRequest: 'bypass', + serviceWorker: { + url: mswServiceWorkerUrl, + }, + quiet: true, + }) + return worker + }), + ], } const withTheme = (Story: React.ComponentType, context: GlobalContext) => { diff --git a/docker/regtest/docker-compose-common.yml b/docker/regtest/docker-compose-common.yml index 1ed04619..0bd00c63 100644 --- a/docker/regtest/docker-compose-common.yml +++ b/docker/regtest/docker-compose-common.yml @@ -86,6 +86,8 @@ services: NETWORK_CONFIG__NETWORK: testnet NETWORK_CONFIG__BITCOIN_NETWORK: regtest NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file} + ORDERBOOK_WATCHER__HTTP_HOST: joinmarket_ng_orderbook_watcher + ORDERBOOK_WATCHER__HTTP_PORT: 8000 OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000 TOR__SOCKS_HOST: tor TOR__SOCKS_PORT: 9050 @@ -124,6 +126,8 @@ services: NETWORK_CONFIG__NETWORK: testnet NETWORK_CONFIG__BITCOIN_NETWORK: regtest NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file} + ORDERBOOK_WATCHER__HTTP_HOST: joinmarket_ng_orderbook_watcher + ORDERBOOK_WATCHER__HTTP_PORT: 8000 OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000 TOR__SOCKS_HOST: tor TOR__SOCKS_PORT: 9050 diff --git a/docker/regtest/docker-compose.yml b/docker/regtest/docker-compose.yml index 319892e0..9adaeeca 100644 --- a/docker/regtest/docker-compose.yml +++ b/docker/regtest/docker-compose.yml @@ -156,6 +156,8 @@ services: TOR__SOCKS_HOST: tor TOR__SOCKS_PORT: 9050 LOGGING__LEVEL: DEBUG + ORDERBOOK_WATCHER__HTTP_HOST: 0.0.0.0 + ORDERBOOK_WATCHER__HTTP_PORT: 8000 expose: - 8000 ports: diff --git a/docker/regtest/init-setup.sh b/docker/regtest/init-setup.sh index a5cd53e1..b1f7e03e 100755 --- a/docker/regtest/init-setup.sh +++ b/docker/regtest/init-setup.sh @@ -16,7 +16,9 @@ set -Eeuo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) # fund wallet in primary JoinMarket NG container -. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --unmatured --blocks 3 +. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 0 --unmatured --blocks 1 +. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 1 --unmatured --blocks 2 # outputs should be flagged as "reused" +. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 4 --unmatured --blocks 1 # fund wallet in ng standalone secondary container . "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket2 --unmatured --blocks 50 @@ -37,7 +39,7 @@ dummy_wallet_address2='bcrt1qt5yxk3xzrx66q9wd5sdyynklqynqcyf7uh74j3' # 8th addre dummy_wallet_address3='bcrt1qn8804dw5fahuc5cwqteuq5j4xlhk2cnkq7a8kw' # 21st change address of jar E (m/84'/1'/4'/1/21) dummy_wallet_address_fb0='bcrt1q7war3lusq3ez633rzzfkp75unfcgrqqcu80sgp0ellhrdvkzxh5swspygk' # 1st fb address (m/84'/1'/0'/2/0) dummy_wallet_address_fb959='bcrt1q6rlcknptmqhf4w20928tpz6px4y70960gyupd029c7vx7kdjwt0q46nk9c' # 960th fb address (m/84'/1'/0'/2/959) -# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106 +# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = at least 106 blocks . "$script_dir/mine-block.sh" 2 "$dummy_wallet_address1" &>/dev/null . "$script_dir/mine-block.sh" 2 "$dummy_wallet_address2" &>/dev/null . "$script_dir/mine-block.sh" 2 "$dummy_wallet_address3" &>/dev/null diff --git a/docs/developing.md b/docs/developing.md index e796c92c..1355b4c7 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -26,7 +26,7 @@ If your separately running joinmarket-ng services use different ports, you can o JAM_BACKEND=joinmarket-ng \ JMWALLETD_API_PORT=28183 \ JMWALLETD_WEBSOCKET_PORT=28283 \ -JMOBWATCH_PORT=8080 \ +JMOBWATCH_PORT=8000 \ npm run dev ``` diff --git a/package-lock.json b/package-lock.json index a093c158..e386f3e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,100 +1,100 @@ { "name": "jam", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jam", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "license": "MIT", "dependencies": { - "@hookform/resolvers": "5.4.0", + "@hookform/resolvers": "5.7.1", "@joinmarket-webui/joinmarket-ng-api-ts": "1.0.0", - "@noble/hashes": "2.2.0", - "@radix-ui/react-accordion": "1.2.16", - "@radix-ui/react-avatar": "1.2.2", - "@radix-ui/react-checkbox": "1.3.7", - "@radix-ui/react-dialog": "1.1.19", - "@radix-ui/react-dropdown-menu": "2.1.20", - "@radix-ui/react-label": "2.1.11", - "@radix-ui/react-popover": "1.1.19", - "@radix-ui/react-radio-group": "1.4.3", - "@radix-ui/react-select": "2.3.3", - "@radix-ui/react-separator": "1.1.11", - "@radix-ui/react-slider": "1.4.3", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-switch": "1.3.3", - "@radix-ui/react-tabs": "1.1.17", - "@radix-ui/react-tooltip": "1.2.12", + "@noble/hashes": "2.3.0", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-tooltip": "1.2.16", "@scure/bip32": "2.2.0", "@scure/bip39": "2.2.0", - "@tailwindcss/vite": "4.3.2", + "@tailwindcss/vite": "4.3.3", "@tanstack/match-sorter-utils": "8.19.4", - "@tanstack/react-query": "5.101.2", + "@tanstack/react-query": "5.101.4", "@tanstack/react-table": "8.21.3", "bitcoin-address-validation": "3.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "html5-qrcode": "2.3.8", "i18next-browser-languagedetector": "8.2.1", - "lucide-react": "1.24.0", + "lucide-react": "1.30.0", "next-themes": "0.4.6", "qrcode": "1.5.4", - "radix-ui": "1.6.2", - "react": "19.2.7", - "react-dom": "19.2.7", - "react-hook-form": "7.81.0", - "react-i18next": "17.0.9", - "react-router-dom": "7.18.1", + "radix-ui": "1.6.7", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-hook-form": "7.84.0", + "react-i18next": "17.0.11", + "react-router-dom": "7.18.2", "react-use-websocket": "4.13.0", "sonner": "2.0.7", "tailwind-merge": "3.6.0", - "tailwindcss": "4.3.2", + "tailwindcss": "4.3.3", "tw-animate-css": "1.4.0", "yup": "1.7.1", "zustand": "5.0.14" }, "devDependencies": { - "@chromatic-com/storybook": "5.2.1", - "@eslint/js": "9.39.4", - "@playwright/test": "1.61.1", - "@storybook/addon-a11y": "10.5.0", - "@storybook/addon-docs": "10.5.0", - "@storybook/addon-vitest": "10.5.0", - "@storybook/react-vite": "10.5.0", - "@tanstack/eslint-plugin-query": "5.101.2", - "@testing-library/jest-dom": "6.9.1", + "@chromatic-com/storybook": "5.3.0", + "@eslint/js": "9.39.5", + "@playwright/test": "1.62.0", + "@storybook/addon-a11y": "10.5.7", + "@storybook/addon-docs": "10.5.7", + "@storybook/addon-vitest": "10.5.7", + "@storybook/react-vite": "10.5.7", + "@tanstack/eslint-plugin-query": "5.101.4", + "@testing-library/jest-dom": "7.0.0", "@testing-library/react": "16.3.2", - "@testing-library/user-event": "14.6.1", + "@testing-library/user-event": "14.6.3", "@trivago/prettier-plugin-sort-imports": "6.0.2", - "@types/node": "26.1.1", + "@types/node": "26.1.2", "@types/qrcode": "1.5.6", - "@types/react": "19.2.17", - "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "6.0.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "@vitejs/plugin-react": "6.0.5", "@vitest/browser": "4.1.10", "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "4.1.10", - "conventional-changelog": "8.1.0", - "eslint": "9.39.4", + "conventional-changelog": "8.1.1", + "eslint": "9.39.5", "eslint-plugin-compat": "7.0.2", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.3", - "eslint-plugin-storybook": "10.5.0", + "eslint-plugin-storybook": "10.5.7", "eslint-plugin-unicorn": "65.0.1", - "globals": "17.7.0", + "globals": "17.9.0", "husky": "9.1.7", - "jsdom": "29.1.1", - "lint-staged": "17.0.8", + "jsdom": "30.0.1", + "lint-staged": "17.3.0", "msw": "2.15.0", - "msw-storybook-addon": "2.0.7", - "prettier": "3.9.5", - "prettier-plugin-tailwindcss": "0.8.0", - "storybook": "10.5.0", + "msw-storybook-addon": "3.0.0", + "prettier": "3.9.6", + "prettier-plugin-tailwindcss": "0.8.1", + "storybook": "10.5.7", "typescript": "6.0.3", - "typescript-eslint": "8.63.0", - "vite": "8.1.4", + "typescript-eslint": "8.66.0", + "vite": "8.2.1", "vitest": "4.1.10" }, "engines": { @@ -110,56 +110,58 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "20 || >=22" } }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -460,14 +462,14 @@ } }, "node_modules/@chromatic-com/storybook": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.2.1.tgz", - "integrity": "sha512-z6I7NJk/0VngA64y5TNYaB4Hc2X8+90n4op6lBt9PvWk5TmIlFLDqdX33rlrwbNRkkYijVgA/wO04rVYXi5Mlg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.3.0.tgz", + "integrity": "sha512-gMAfVYJnF/tjdY8C3Q54KYz/low2NFjPIZMpCqSFuMUiTJ2DNWCTb7mFhuuzZ1iduWFDhewlJyilFmvKcEDYRA==", "dev": true, "license": "MIT", "dependencies": { "@neoconfetti/react": "^1.0.0", - "chromatic": "16.10.0", + "chromatic": "^18.0.1", "jsonfile": "^6.1.0", "strip-ansi": "^7.1.0" }, @@ -537,9 +539,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -561,9 +563,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", - "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "dev": true, "funding": [ { @@ -578,7 +580,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.2.1" + "@csstools/css-calc": "^3.3.0" }, "engines": { "node": ">=20.19.0" @@ -612,9 +614,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", - "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { @@ -1148,9 +1150,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1221,10 +1223,27 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1245,6 +1264,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -1259,9 +1285,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1352,15 +1378,113 @@ "license": "MIT" }, "node_modules/@hookform/resolvers": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", - "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.7.1.tgz", + "integrity": "sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==", "license": "MIT", "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { - "react-hook-form": "^7.55.0" + "@sinclair/typebox": ">=0.25.24", + "@standard-schema/spec": "^1.0.0", + "@typeschema/main": ">=0.13.7", + "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", + "ajv": "^8.12.0", + "ajv-errors": "^3.0.0", + "ajv-formats": "^2.1.1", + "arktype": "^2.0.0", + "ata-validator": "^1.2.0", + "class-transformer": ">=0.4.0", + "class-validator": ">=0.12.0", + "computed-types": "^1.0.0", + "effect": "^3.10.3", + "fluentvalidation-ts": "^3.0.0", + "fp-ts": "^2.7.0", + "io-ts": "^2.0.0", + "joi": "^17.0.0", + "nope-validator": ">=0.12.0", + "react-hook-form": "^7.55.0", + "superstruct": ">=0.12.0", + "typanion": "^3.3.2", + "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", + "vest": ">=3.0.0", + "yup": "^1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@sinclair/typebox": { + "optional": true + }, + "@standard-schema/spec": { + "optional": true + }, + "@typeschema/main": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "ajv": { + "optional": true + }, + "ajv-errors": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "arktype": { + "optional": true + }, + "ata-validator": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "computed-types": { + "optional": true + }, + "effect": { + "optional": true + }, + "fluentvalidation-ts": { + "optional": true + }, + "fp-ts": { + "optional": true + }, + "io-ts": { + "optional": true + }, + "joi": { + "optional": true + }, + "nope-validator": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "typanion": { + "optional": true + }, + "valibot": { + "optional": true + }, + "vest": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } } }, "node_modules/@humanfs/core": { @@ -1685,7 +1809,7 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@noble/hashes": { + "node_modules/@noble/curves/node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", @@ -1697,6 +1821,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@open-draft/deferred-promise": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", @@ -2428,19 +2564,19 @@ ] }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@polka/url": { @@ -2451,24 +2587,24 @@ "license": "MIT" }, "node_modules/@radix-ui/number": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", - "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", - "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.11.tgz", - "integrity": "sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", "license": "MIT", "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.7" + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -2486,20 +2622,20 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.16.tgz", - "integrity": "sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ==", + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collapsible": "1.1.16", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2517,16 +2653,16 @@ } }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.19.tgz", - "integrity": "sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dialog": "1.1.19", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -2544,12 +2680,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz", - "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -2567,12 +2703,12 @@ } }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.11.tgz", - "integrity": "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -2590,16 +2726,17 @@ } }, "node_modules/@radix-ui/react-avatar": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.2.tgz", - "integrity": "sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2617,19 +2754,18 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.7.tgz", - "integrity": "sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ==", + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2647,19 +2783,19 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.16.tgz", - "integrity": "sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2677,15 +2813,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", - "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -2703,9 +2839,9 @@ } }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2718,9 +2854,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", - "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2733,16 +2869,16 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.3.tgz", - "integrity": "sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-menu": "2.1.20", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2760,23 +2896,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz", - "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.12", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -2796,9 +2933,9 @@ } }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2811,16 +2948,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", - "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-effect-event": "0.0.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", @@ -2838,18 +2975,18 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.20.tgz", - "integrity": "sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.20", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2867,9 +3004,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2882,14 +3019,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", - "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2907,17 +3044,17 @@ } }, "node_modules/@radix-ui/react-form": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.12.tgz", - "integrity": "sha512-JTX94E4LDL91rzLg7X0mHPdxr0A8JEdVwZEmeOwZJSMDHCGW5DFtSlTSJozUyUs807IQmnvbfzKZFVCK5DmkqQ==", + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.16.tgz", + "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-label": "2.1.11", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -2935,20 +3072,20 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.19.tgz", - "integrity": "sha512-2KTgMLQtKvicznQgbindEI2RZ3QbDIwU5gabjUPwFJsormjGDz+rUvO4NANmYwzEEpTcTONUt33vBHIfTIVSfw==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-popper": "1.3.3", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2966,12 +3103,12 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2984,12 +3121,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.11.tgz", - "integrity": "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==", + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -3007,27 +3144,27 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.20.tgz", - "integrity": "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.12", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.3", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.15", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -3047,21 +3184,21 @@ } }, "node_modules/@radix-ui/react-menubar": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.20.tgz", - "integrity": "sha512-gzFZvybgmwYsFBWDqanycIoEYnhyk8MMnuLamdFVHUZYGp4COM+sqXiwbnn0VMWqGLeeU7GV7jm+dXRa+Wufag==", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.24.tgz", + "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.20", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.15", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -3079,25 +3216,25 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.18.tgz", - "integrity": "sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew==", + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -3115,23 +3252,23 @@ } }, "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.12.tgz", - "integrity": "sha512-nQLu5OAcORDQp1EHAv6k3mJGV1hjMTw2NTGVAsGE1g/mWeNqAd1R5jyaAs3U+A8ZD/W8XNPY2yKT0ZdQnqo3NA==", + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.16.tgz", + "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.15", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3149,19 +3286,19 @@ } }, "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.7.tgz", - "integrity": "sha512-gB1Mr8vzdv1XzDjrtJTXmL0JORRs1B4g7ngUs0F+H2VvMOwXTZMTmLCl0wZZ3m7ylX8TssI7NCvgiSHmLuTm/A==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.11.tgz", + "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3179,24 +3316,24 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.19.tgz", - "integrity": "sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.12", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.3", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -3216,21 +3353,21 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.3.tgz", - "integrity": "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.11", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3248,13 +3385,13 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", - "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3272,12 +3409,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", - "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3295,12 +3432,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -3318,13 +3455,13 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.12.tgz", - "integrity": "sha512-ZPHyI0JyzoH/rP0tq2uRaIZTj/4s8+kAbqPz+e2N8+ejHvwPJ889dHhqn+vh7PNvNeq+boAoH9yzqeoShzwF2w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -3342,21 +3479,20 @@ } }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.3.tgz", - "integrity": "sha512-WwZFjWV4s3aC1QtR3k04R+oANHtX2q6fgKlc7MCEiDNlnTxCZ3H8k3mHtEgVlOejystwk1WQgarQhNOQZ2bK1g==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", + "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.15", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3374,22 +3510,22 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.15.tgz", - "integrity": "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3407,20 +3543,20 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.14.tgz", - "integrity": "sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3438,31 +3574,31 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.3.tgz", - "integrity": "sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.12", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.3", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.7", + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -3482,12 +3618,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.11.tgz", - "integrity": "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -3505,22 +3641,22 @@ } }, "node_modules/@radix-ui/react-slider": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.3.tgz", - "integrity": "sha512-CWVVj+XaTom0SKCqw1EUgb0NuiLwS+N3OFG73mVEezKEjgNIvZiu0EevMelSSU+CbX3owbqJweG2gPU31WGC5A==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3538,12 +3674,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" + "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -3556,18 +3692,17 @@ } }, "node_modules/@radix-ui/react-switch": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.3.tgz", - "integrity": "sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3585,19 +3720,19 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.17.tgz", - "integrity": "sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.15", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -3615,23 +3750,23 @@ } }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.19.tgz", - "integrity": "sha512-SxfVZfVOibWKWdkf0Xx1awW2d09fQu4V4PXDY1j5hi4MVf7MWdJZqTBJMa1KWtOr1S6GGtCk02nniZ0Iia+dHw==", + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -3649,14 +3784,14 @@ } }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.14.tgz", - "integrity": "sha512-QI/hB65XKWACA66P64A+aHxtLUgHJeJLkaQa+awUNXT6T3swndtY5DojeHA+vldrTspMTtFBd7HfZ9QGbM1Qrw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -3674,18 +3809,18 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.15.tgz", - "integrity": "sha512-gIC5Q+Xljg7lmUdzSuDoy0t97yZn1sZl00Ra37ZvKrYdWnQLU6sWLd09yG8cIB9jUAlQfHgJ2ACAG00MFwsqSQ==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.15", - "@radix-ui/react-toggle": "1.1.14", - "@radix-ui/react-use-controllable-state": "1.2.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -3703,18 +3838,18 @@ } }, "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.15.tgz", - "integrity": "sha512-t/iEuVjUnXXtrsGK40AA43uIx37sn3AqZ7oAVnPICK6lFJP6dzMzWR3U9b6eCfFjb6wtSEqkJ9Rn9xDjiOx20g==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", + "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.15", - "@radix-ui/react-separator": "1.1.11", - "@radix-ui/react-toggle-group": "1.1.15" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.19" }, "peerDependencies": { "@types/react": "*", @@ -3732,23 +3867,24 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.12.tgz", - "integrity": "sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.3", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-visually-hidden": "1.2.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -3766,9 +3902,9 @@ } }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3781,13 +3917,14 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3800,12 +3937,12 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3818,12 +3955,12 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", - "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3836,9 +3973,9 @@ } }, "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", - "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3851,9 +3988,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3866,9 +4003,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", - "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3881,12 +4018,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.2" + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3899,12 +4036,12 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -3917,12 +4054,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz", - "integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -3940,15 +4077,15 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", "cpu": [ "arm64" ], @@ -3962,9 +4099,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", "cpu": [ "arm64" ], @@ -3978,9 +4115,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", "cpu": [ "x64" ], @@ -3994,9 +4131,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", "cpu": [ "x64" ], @@ -4010,9 +4147,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", "cpu": [ "arm" ], @@ -4026,9 +4163,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", "cpu": [ "arm64" ], @@ -4045,9 +4182,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", "cpu": [ "arm64" ], @@ -4064,9 +4201,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", "cpu": [ "ppc64" ], @@ -4083,9 +4220,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", "cpu": [ "s390x" ], @@ -4102,9 +4239,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", "cpu": [ "x64" ], @@ -4121,9 +4258,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", "cpu": [ "x64" ], @@ -4140,9 +4277,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", "cpu": [ "arm64" ], @@ -4156,38 +4293,35 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", "license": "MIT", "optional": true, "dependencies": { @@ -4195,19 +4329,40 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", + "integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", "cpu": [ "arm64" ], @@ -4221,9 +4376,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", "cpu": [ "x64" ], @@ -4288,6 +4443,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@scure/bip39": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", @@ -4301,6 +4468,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", @@ -4364,7 +4543,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -4374,9 +4553,9 @@ "license": "MIT" }, "node_modules/@storybook/addon-a11y": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.0.tgz", - "integrity": "sha512-AguqTsYS2NX20AF1vWt6IonbkpMXur30/dAOOYltbEW7uKmPxBNtjuiBmYB2WI4aFN4r8jje4bbDGk/Yfq58hQ==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.7.tgz", + "integrity": "sha512-I30rsNz6aA3xg3811MEry40uJDHP3l5SOkfqtmNkp7y4NdqTDdKhmbhhDAZQX1WWEk6GeMBGr3AJ3TCz7r7JmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4388,20 +4567,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.5.0" + "storybook": "^10.5.7" } }, "node_modules/@storybook/addon-docs": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.0.tgz", - "integrity": "sha512-kbZGdX+mysiY4xezhpcFEwzQ1zSXcMhSS3u09Qt8+w5XetCAMMSv/yAxzpi6z+YoH+EdQ53e1q3MFtPUkzkfUA==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.7.tgz", + "integrity": "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.5.0", + "@storybook/csf-plugin": "10.5.7", "@storybook/icons": "^2.0.2", - "@storybook/react-dom-shim": "10.5.0", + "@storybook/react-dom-shim": "10.5.7", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -4412,7 +4591,7 @@ }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.0" + "storybook": "^10.5.7" }, "peerDependenciesMeta": { "@types/react": { @@ -4421,9 +4600,9 @@ } }, "node_modules/@storybook/addon-vitest": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.0.tgz", - "integrity": "sha512-o/H+ii8o7bu4r1M4pgyFt+DjVaIIccSeaAikpspNHKkKdiJ2GXfkgNwtib8Jru28HaBRfcP5u+m0QE3KBYIQkw==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.7.tgz", + "integrity": "sha512-7NK7Kzazc2vb2h8nGlH15QlUn5J6/LV0xF70VziAK0bxu3r1JupLCoBvckX39vHzFXiAZljXZtt1Wq/OidBxow==", "dev": true, "license": "MIT", "dependencies": { @@ -4438,7 +4617,7 @@ "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", - "storybook": "^10.5.0", + "storybook": "^10.5.7", "vitest": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { @@ -4457,13 +4636,13 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.0.tgz", - "integrity": "sha512-KXlifNIThDgS84KqVAJXyilool8OLTWp6DGoO9h5bHM2IPLe7UcdKfOzMUBkQ807mWBk4aW1yGEekj7kC2dvmg==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.7.tgz", + "integrity": "sha512-fShF/aQaITqcJuMCLr42BGNUAbhDi4IboqvlbZqXAwgrrTslnZEUnY8GcEcvpZmjl11VwlmazhMJdH50fIgBPg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.5.0", + "@storybook/csf-plugin": "10.5.7", "ts-dedent": "^2.0.0" }, "funding": { @@ -4471,14 +4650,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.5.0", + "storybook": "^10.5.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/csf-plugin": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.0.tgz", - "integrity": "sha512-R7VFw6FnDZTWct0ekOcFnTfDgdHnnAuQW+AbGG9A9IZPvyH9uLJivK7L86+r9MITRrO2+v81HaFDsT/OFk6ZkQ==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz", + "integrity": "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==", "dev": true, "license": "MIT", "dependencies": { @@ -4491,7 +4670,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.5.0", + "storybook": "^10.5.7", "vite": "*", "webpack": "*" }, @@ -4528,14 +4707,14 @@ } }, "node_modules/@storybook/react": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.0.tgz", - "integrity": "sha512-2IhddiREy7NqAqnFsEOfW6HBG7azIcLxhRQYploSsaemOncR29xhzvAZsG+xlWgrtvxv4h3fYQTTR4TNn8CgPQ==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.7.tgz", + "integrity": "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.5.0", + "@storybook/react-dom-shim": "10.5.7", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, @@ -4548,7 +4727,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.0", + "storybook": "^10.5.7", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { @@ -4564,9 +4743,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.0.tgz", - "integrity": "sha512-GwGA6zDj4Cfw6vGsdfPKfF39L0W6solNbbnjdjGa57m2ooASkidLhrXo2wgC9wh3o/jcfonmYPDRGY6sEhGDtg==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz", + "integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==", "dev": true, "license": "MIT", "funding": { @@ -4578,7 +4757,7 @@ "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.0" + "storybook": "^10.5.7" }, "peerDependenciesMeta": { "@types/react": { @@ -4590,16 +4769,16 @@ } }, "node_modules/@storybook/react-vite": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.0.tgz", - "integrity": "sha512-d9n/I3pViscXpJYT9JHxcpxVSam14HsHOr2wBqTQTiRtaiH4xSsT00HTEGm6CEZTyq/npTJyV0Qday9XhrtiDQ==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.7.tgz", + "integrity": "sha512-eEo3eVa2pvqrzQukKxAzx7YvswDAA1s6k/y+tdMxmRvWyHX6QEOsb9Tda6wcVaa7c8BeJM7Ggq+289cRMTH6Iw==", "dev": true, "license": "MIT", "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.5.0", - "@storybook/react": "10.5.0", + "@storybook/builder-vite": "10.5.7", + "@storybook/react": "10.5.7", "empathic": "^2.0.0", "magic-string": "^0.30.0", "react-docgen": "^8.0.2", @@ -4613,7 +4792,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.5.0", + "storybook": "^10.5.7", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, @@ -4624,47 +4803,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -4678,9 +4857,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -4694,9 +4873,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -4710,9 +4889,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -4726,9 +4905,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -4742,9 +4921,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -4761,9 +4940,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -4780,9 +4959,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -4799,9 +4978,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -4818,9 +4997,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -4847,9 +5026,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -4863,9 +5042,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -4879,23 +5058,23 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", - "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.2", - "@tailwindcss/oxide": "4.3.2", - "tailwindcss": "4.3.2" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.101.2.tgz", - "integrity": "sha512-cPE99s3XZwlObfn8lCezT4j4JLj2CVzpIEywx0H4hzfPsX/o9QhdwaOwcDXxrQAqx2ds7TbvTinxhB8B/ywb6w==", + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.101.4.tgz", + "integrity": "sha512-jqAZJWXGd5PthJYH9wmC2O5Ry5xAN2/7zxi9qcANQ+Xix8V5JkqNRjZ8Fh+rYzqzVYGiHqbrHekt+uh38OZVpw==", "dev": true, "license": "MIT", "dependencies": { @@ -4932,9 +5111,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", - "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", "license": "MIT", "funding": { "type": "github", @@ -4942,12 +5121,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", - "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.2" + "@tanstack/query-core": "5.101.4" }, "funding": { "type": "github", @@ -5011,9 +5190,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", "dev": true, "license": "MIT", "dependencies": { @@ -5025,9 +5204,12 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -5066,9 +5248,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", + "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", "dev": true, "license": "MIT", "engines": { @@ -5229,9 +5411,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5249,9 +5431,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5259,9 +5441,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "devOptional": true, "license": "MIT", "peerDependencies": { @@ -5293,17 +5475,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5316,7 +5498,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", + "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -5332,16 +5514,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3" }, "engines": { @@ -5357,14 +5539,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "engines": { @@ -5379,14 +5561,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5397,9 +5579,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", "dev": true, "license": "MIT", "engines": { @@ -5414,15 +5596,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5439,9 +5621,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", "dev": true, "license": "MIT", "engines": { @@ -5453,16 +5635,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5491,26 +5673,26 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -5520,16 +5702,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5544,13 +5726,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5575,9 +5757,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", "dev": true, "license": "MIT", "dependencies": { @@ -5881,39 +6063,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -6117,9 +6266,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -6281,9 +6430,9 @@ } }, "node_modules/chromatic": { - "version": "16.10.0", - "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-16.10.0.tgz", - "integrity": "sha512-nFsztmnu7rFiGafUJgXvLUNpqmRylz92eNvzBoJNTKKQj4EQUyxznwnfpf1dTs7hXtWD8JwcH92jADydaHA1sw==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-18.1.0.tgz", + "integrity": "sha512-I9av8lUc5CQRlYzwKpmOG9cwYvZ4AFmTvtHeNrzOMC1353F9xYw45CHpSNIsNKuOiqqmzci9esXQd5akl0fi6w==", "dev": true, "license": "MIT", "dependencies": { @@ -6294,6 +6443,9 @@ "chromatic": "dist/bin.cjs", "chromatic-cli": "dist/bin.cjs" }, + "engines": { + "node": ">=22.0.0" + }, "peerDependencies": { "@chromatic-com/cypress": "^0.*.* || ^1.0.0", "@chromatic-com/playwright": "^0.*.* || ^1.0.0", @@ -6339,39 +6491,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -6488,9 +6607,9 @@ "license": "MIT" }, "node_modules/conventional-changelog": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-8.1.0.tgz", - "integrity": "sha512-idt1JK+3+Nt7gqH/d/sEYWdDeR+5XPov/jssmFN6XxmYSRlonTWSDWhWUPkmm0zbwYjtVdt+4My5aiPkKyvocw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-8.1.1.tgz", + "integrity": "sha512-my0arqBpzr8mg+7RKDGnw8OiDvBr8/QVJIxazYJlIDeKx/2GCBYxZELaDYpbRhM5gNKfZB8h9SHuetrUFxd5HQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6500,7 +6619,7 @@ "argue-cli": "^3.1.0", "conventional-changelog-preset-loader": "^6.0.1", "conventional-changelog-writer": "^9.2.0", - "conventional-commits-parser": "^7.1.0", + "conventional-commits-parser": "^7.1.2", "fd-package-json": "^2.0.0" }, "bin": { @@ -6551,9 +6670,9 @@ } }, "node_modules/conventional-commits-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.0.tgz", - "integrity": "sha512-DPp6hkUjvwIivxbkrTiLXeRswNv1A/4GFA2X6scXma0AMa9632V3TwxmrlkUIEtUktiM3Ln+RrSH2xlP3/jUTw==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.2.tgz", + "integrity": "sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6840,9 +6959,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -6865,19 +6984,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -6961,9 +7067,9 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", "dependencies": { @@ -6972,8 +7078,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -7086,18 +7192,18 @@ } }, "node_modules/eslint-plugin-storybook": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.0.tgz", - "integrity": "sha512-UyhbK11baKAYqzyDUHlwDlm1aP/wxPMR0vFmsxA5984BGaPwarhoXUWng1Xa0cd9BdPbQ+zr/y8ADk6XxtwkYg==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.7.tgz", + "integrity": "sha512-mLpamG1Rsica2jYbUzIZOEuy7Fm1IMtVLMvvxGTpjTVKUMxTXJsANx3MBpH2VSbGQB8Yzlt5399WL/O07K97Ig==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "^8.48.0", - "@typescript-eslint/utils": "^8.48.0" + "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/utils": "^8.60.0" }, "peerDependencies": { "eslint": ">=8", - "storybook": "^10.5.0" + "storybook": "^10.5.7" } }, "node_modules/eslint-plugin-unicorn": { @@ -7163,10 +7269,27 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -7187,6 +7310,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -7298,13 +7428,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -7498,19 +7621,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -7562,16 +7672,16 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -7591,9 +7701,9 @@ } }, "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", + "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", "dev": true, "license": "MIT", "engines": { @@ -7691,12 +7801,12 @@ "license": "MIT" }, "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", "license": "MIT", - "dependencies": { - "void-elements": "3.1.0" + "funding": { + "url": "https://locize.com" } }, "node_modules/html5-qrcode": { @@ -7867,22 +7977,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -8015,9 +8109,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -8038,39 +8132,39 @@ } }, "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", + "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", + "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^3.2.3" }, "peerDependenciesMeta": { "canvas": { @@ -8088,6 +8182,21 @@ "node": "20 || >=22" } }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8108,13 +8217,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -8441,14 +8543,13 @@ } }, "node_modules/lint-staged": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", - "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.3.0.tgz", + "integrity": "sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==", "dev": true, "license": "MIT", "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, @@ -8465,23 +8566,6 @@ "yaml": "^2.9.0" } }, - "node_modules/listr2": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", - "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8519,99 +8603,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -8630,9 +8621,9 @@ } }, "node_modules/lucide-react": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", - "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.30.0.tgz", + "integrity": "sha512-tUIr2jXLbWpCkdtH8XP7P7YppM9ueWgTky99lpWDY6z5REs6B+O6ZQ3U5tHkUUY59ANyOv/PBcs8E4Fe3KO3eA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -8692,19 +8683,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -8814,16 +8792,17 @@ } }, "node_modules/msw-storybook-addon": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.7.tgz", - "integrity": "sha512-TGmlxXy2TsaB6QcClVKRxqvay5f93xoLguHOihRFQ+gIEIyiyvcoQjkEeuOe7Y9qvddzGB1LyFomzPo9/EpnuQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-3.0.0.tgz", + "integrity": "sha512-tyYmYAwSGcfFSHhrE5yzun/OXuVIkBGOt4YTsH5IR94N38F+hzZhvGqe5scu1ENJh/hOApI/8ZRQkWdFNFsKcw==", "dev": true, "license": "MIT", - "dependencies": { - "is-node-process": "^1.0.1" + "bin": { + "msw-storybook-migrate": "build/migrate.mjs" }, "peerDependencies": { - "msw": "^2.0.0" + "msw": ">=2", + "storybook": ">=9" } }, "node_modules/mute-stream": { @@ -8837,9 +8816,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "funding": [ { "type": "github", @@ -8895,22 +8874,6 @@ "node": ">=12.20.0" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -9204,35 +9167,35 @@ } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/pluralize": { @@ -9256,9 +9219,9 @@ } }, "node_modules/postcss": { - "version": "8.5.17", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", - "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -9275,7 +9238,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9294,9 +9257,9 @@ } }, "node_modules/prettier": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", - "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -9310,9 +9273,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.0.tgz", - "integrity": "sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.1.tgz", + "integrity": "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==", "dev": true, "license": "MIT", "engines": { @@ -9612,66 +9575,66 @@ } }, "node_modules/radix-ui": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.2.tgz", - "integrity": "sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", + "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-accessible-icon": "1.1.11", - "@radix-ui/react-accordion": "1.2.16", - "@radix-ui/react-alert-dialog": "1.1.19", - "@radix-ui/react-arrow": "1.1.11", - "@radix-ui/react-aspect-ratio": "1.1.11", - "@radix-ui/react-avatar": "1.2.2", - "@radix-ui/react-checkbox": "1.3.7", - "@radix-ui/react-collapsible": "1.1.16", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-context-menu": "2.3.3", - "@radix-ui/react-dialog": "1.1.19", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-dropdown-menu": "2.1.20", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.12", - "@radix-ui/react-form": "0.1.12", - "@radix-ui/react-hover-card": "1.1.19", - "@radix-ui/react-label": "2.1.11", - "@radix-ui/react-menu": "2.1.20", - "@radix-ui/react-menubar": "1.1.20", - "@radix-ui/react-navigation-menu": "1.2.18", - "@radix-ui/react-one-time-password-field": "0.1.12", - "@radix-ui/react-password-toggle-field": "0.1.7", - "@radix-ui/react-popover": "1.1.19", - "@radix-ui/react-popper": "1.3.3", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-progress": "1.1.12", - "@radix-ui/react-radio-group": "1.4.3", - "@radix-ui/react-roving-focus": "1.1.15", - "@radix-ui/react-scroll-area": "1.2.14", - "@radix-ui/react-select": "2.3.3", - "@radix-ui/react-separator": "1.1.11", - "@radix-ui/react-slider": "1.4.3", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-switch": "1.3.3", - "@radix-ui/react-tabs": "1.1.17", - "@radix-ui/react-toast": "1.2.19", - "@radix-ui/react-toggle": "1.1.14", - "@radix-ui/react-toggle-group": "1.1.15", - "@radix-ui/react-toolbar": "1.1.15", - "@radix-ui/react-tooltip": "1.2.12", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.3", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-escape-keydown": "1.1.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.15", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-alert-dialog": "1.1.23", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-aspect-ratio": "1.1.15", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.7", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-form": "0.1.16", + "@radix-ui/react-hover-card": "1.1.23", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-menubar": "1.1.24", + "@radix-ui/react-navigation-menu": "1.2.22", + "@radix-ui/react-one-time-password-field": "0.1.16", + "@radix-ui/react-password-toggle-field": "0.1.11", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-scroll-area": "1.2.18", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-toast": "1.2.23", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-toggle-group": "1.1.19", + "@radix-ui/react-toolbar": "1.1.19", + "@radix-ui/react-tooltip": "1.2.16", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -9689,9 +9652,9 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9730,21 +9693,21 @@ } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-hook-form": { - "version": "7.81.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", - "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", + "version": "7.84.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.84.0.tgz", + "integrity": "sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -9758,13 +9721,13 @@ } }, "node_modules/react-i18next": { - "version": "17.0.9", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz", - "integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==", + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.2", - "html-parse-stringify": "^3.0.1", + "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { @@ -9839,9 +9802,9 @@ } }, "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -9861,12 +9824,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "react-router": "7.18.1" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" @@ -9911,9 +9874,9 @@ "license": "MIT" }, "node_modules/recast": { - "version": "0.23.12", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz", - "integrity": "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==", + "version": "0.23.19", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.19.tgz", + "integrity": "sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==", "dev": true, "license": "MIT", "dependencies": { @@ -10040,23 +10003,6 @@ "node": ">=4" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/rettime": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", @@ -10064,20 +10010,13 @@ "dev": true, "license": "MIT" }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -10087,27 +10026,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -10235,36 +10174,6 @@ "node": ">=18" } }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -10319,16 +10228,16 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.0.tgz", - "integrity": "sha512-dRhM/kSSvHQR8DmZO41v5sJuz9U6zDjjR2gRBTgZN2RBSXbmF0Brvgszrvvxyx2VfxuYKzhB+xumKwWkwlBtig==", + "version": "10.5.7", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz", + "integrity": "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", @@ -10341,7 +10250,7 @@ "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", - "ws": "^8.18.0" + "ws": "^8.21.1" }, "bin": { "storybook": "dist/bin/dispatcher.js" @@ -10367,6 +10276,26 @@ } } }, + "node_modules/storybook/node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/storybook/node_modules/@vitest/spy": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", @@ -10380,6 +10309,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/storybook/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -10397,23 +10333,6 @@ "node": ">=0.6.19" } }, - "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -10536,9 +10455,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "license": "MIT" }, "node_modules/tapable": { @@ -10779,16 +10698,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", - "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.63.0", - "@typescript-eslint/parser": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0" + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10803,13 +10722,13 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { @@ -10949,15 +10868,15 @@ } }, "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", + "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { @@ -10974,7 +10893,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -11039,6 +10958,267 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", @@ -11157,15 +11337,6 @@ "node": ">=18" } }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -11280,41 +11451,10 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", - "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { @@ -11507,7 +11647,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index af04c0e5..5953be25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jam", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "private": true, "description": "Your sats. Your privacy. Your profit.", "repository": "git@github.com:joinmarket-webui/jam.git", @@ -71,92 +71,92 @@ ] }, "dependencies": { - "@hookform/resolvers": "5.4.0", + "@hookform/resolvers": "5.7.1", "@joinmarket-webui/joinmarket-ng-api-ts": "1.0.0", - "@noble/hashes": "2.2.0", - "@radix-ui/react-accordion": "1.2.16", - "@radix-ui/react-avatar": "1.2.2", - "@radix-ui/react-checkbox": "1.3.7", - "@radix-ui/react-dialog": "1.1.19", - "@radix-ui/react-dropdown-menu": "2.1.20", - "@radix-ui/react-label": "2.1.11", - "@radix-ui/react-popover": "1.1.19", - "@radix-ui/react-radio-group": "1.4.3", - "@radix-ui/react-select": "2.3.3", - "@radix-ui/react-separator": "1.1.11", - "@radix-ui/react-slider": "1.4.3", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-switch": "1.3.3", - "@radix-ui/react-tabs": "1.1.17", - "@radix-ui/react-tooltip": "1.2.12", + "@noble/hashes": "2.3.0", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-tooltip": "1.2.16", "@scure/bip32": "2.2.0", "@scure/bip39": "2.2.0", - "@tailwindcss/vite": "4.3.2", + "@tailwindcss/vite": "4.3.3", "@tanstack/match-sorter-utils": "8.19.4", - "@tanstack/react-query": "5.101.2", + "@tanstack/react-query": "5.101.4", "@tanstack/react-table": "8.21.3", "bitcoin-address-validation": "3.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "html5-qrcode": "2.3.8", "i18next-browser-languagedetector": "8.2.1", - "lucide-react": "1.24.0", + "lucide-react": "1.30.0", "next-themes": "0.4.6", "qrcode": "1.5.4", - "radix-ui": "1.6.2", - "react": "19.2.7", - "react-dom": "19.2.7", - "react-hook-form": "7.81.0", - "react-i18next": "17.0.9", - "react-router-dom": "7.18.1", + "radix-ui": "1.6.7", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-hook-form": "7.84.0", + "react-i18next": "17.0.11", + "react-router-dom": "7.18.2", "react-use-websocket": "4.13.0", "sonner": "2.0.7", "tailwind-merge": "3.6.0", - "tailwindcss": "4.3.2", + "tailwindcss": "4.3.3", "tw-animate-css": "1.4.0", "yup": "1.7.1", "zustand": "5.0.14" }, "devDependencies": { - "@chromatic-com/storybook": "5.2.1", - "@eslint/js": "9.39.4", - "@playwright/test": "1.61.1", - "@storybook/addon-a11y": "10.5.0", - "@storybook/addon-docs": "10.5.0", - "@storybook/addon-vitest": "10.5.0", - "@storybook/react-vite": "10.5.0", - "@tanstack/eslint-plugin-query": "5.101.2", - "@testing-library/jest-dom": "6.9.1", + "@chromatic-com/storybook": "5.3.0", + "@eslint/js": "9.39.5", + "@playwright/test": "1.62.0", + "@storybook/addon-a11y": "10.5.7", + "@storybook/addon-docs": "10.5.7", + "@storybook/addon-vitest": "10.5.7", + "@storybook/react-vite": "10.5.7", + "@tanstack/eslint-plugin-query": "5.101.4", + "@testing-library/jest-dom": "7.0.0", "@testing-library/react": "16.3.2", - "@testing-library/user-event": "14.6.1", + "@testing-library/user-event": "14.6.3", "@trivago/prettier-plugin-sort-imports": "6.0.2", - "@types/node": "26.1.1", + "@types/node": "26.1.2", "@types/qrcode": "1.5.6", - "@types/react": "19.2.17", - "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "6.0.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "@vitejs/plugin-react": "6.0.5", "@vitest/browser": "4.1.10", "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "4.1.10", - "conventional-changelog": "8.1.0", - "eslint": "9.39.4", + "conventional-changelog": "8.1.1", + "eslint": "9.39.5", "eslint-plugin-compat": "7.0.2", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.3", - "eslint-plugin-storybook": "10.5.0", + "eslint-plugin-storybook": "10.5.7", "eslint-plugin-unicorn": "65.0.1", - "globals": "17.7.0", + "globals": "17.9.0", "husky": "9.1.7", - "jsdom": "29.1.1", - "lint-staged": "17.0.8", + "jsdom": "30.0.1", + "lint-staged": "17.3.0", "msw": "2.15.0", - "msw-storybook-addon": "2.0.7", - "prettier": "3.9.5", - "prettier-plugin-tailwindcss": "0.8.0", - "storybook": "10.5.0", + "msw-storybook-addon": "3.0.0", + "prettier": "3.9.6", + "prettier-plugin-tailwindcss": "0.8.1", + "storybook": "10.5.7", "typescript": "6.0.3", - "typescript-eslint": "8.63.0", - "vite": "8.1.4", + "typescript-eslint": "8.66.0", + "vite": "8.2.1", "vitest": "4.1.10" }, "overrides": { diff --git a/src/App.tsx b/src/App.tsx index e1fb0c4e..d3887a78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,7 @@ import { EarnReportPage } from './components/earn/report/EarnReportPage' import { RootLayout } from './components/layout/RootLayout' import { LockWalletConfirmDialog } from './components/ui/jam/LockWalletConfirmDialog' import { Spinner } from './components/ui/spinner' +import { TxHistoryPage } from './components/wallet/TxHistoryPage' import { WalletJarsDetailsPage } from './components/wallet/WalletJarsDetailsPage' import { useJamSessionInfoContext } from './context/JamSessionInfoContext' import { JamSessionInfoContextProvider } from './context/JamSessionInfoContextProvider' @@ -240,6 +241,7 @@ function App() { path={routes.walletJarsDetails} element={} /> + } /> {isDeveloperMode && isDebugFeatureEnabled('devPage') && ( ({ useJars: () => ({ jars }), })) +vi.mock('@/store/jamSettingsStore', () => ({ + usePreviewFeatures: () => ({ + 'tx-history': true, + }), +})) + vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({ WalletJarsDetailsOverlay: ({ open, @@ -53,7 +59,11 @@ vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({ ), })) -vi.mock('./ui/jam/Balance', () => ({ +vi.mock('./wallet/TxHistoryContent', () => ({ + TxHistoryContent: () =>
, +})) + +vi.mock('@/components/ui/jam/Balance', () => ({ Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => ( {valueString} ), @@ -74,6 +84,7 @@ const makeJar = (jarIndex: number, name: string): Jar => calculatedTotalBalanceInSats: 100, calculatedAvailableBalanceInSats: 100, calculatedFrozenOrLockedBalanceInSats: 0, + calculatedAvailableFrozenBalanceInSats: 0, }, }) as unknown as Jar @@ -124,10 +135,14 @@ describe('MainWalletPage', () => { expect(refetch).toHaveBeenCalled() }) - it('shows a jars spinner while fetching and refreshes on click', () => { + it('keeps jars visible and clickable while fetching in the background', () => { walletInfo = { ...walletInfo, isFetching: true } render() - expect(screen.getByTestId('spinner')).toBeInTheDocument() + expect(screen.getByText(jars[0].name)).toBeInTheDocument() + + fireEvent.click(screen.getByText(jars[0].name)) + expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('open') + expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent(String(jars[0].jarIndex)) fireEvent.click(screen.getByText('global.refresh')) expect(refetch).toHaveBeenCalled() }) @@ -137,4 +152,17 @@ describe('MainWalletPage', () => { fireEvent.click(screen.getByText('5000')) expect(toggleDisplayMode).toHaveBeenCalled() }) + + it('expands and collapses transaction history via accordion toggle button', () => { + render() + expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument() + + const toggleButton = screen.getByTitle('tx_history.expand_history') + fireEvent.click(toggleButton) + expect(screen.getByTestId('TxHistoryContent')).toBeInTheDocument() + + const collapseButton = screen.getByTitle('tx_history.collapse_history') + fireEvent.click(collapseButton) + expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument() + }) }) diff --git a/src/components/MainWalletPage.tsx b/src/components/MainWalletPage.tsx index 3dbcdc4a..68b8aa9b 100644 --- a/src/components/MainWalletPage.tsx +++ b/src/components/MainWalletPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react' +import { ChevronDownIcon, DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { Alert, AlertDescription } from '@/components/ui/alert' @@ -17,8 +17,11 @@ import { import { getErrorReason } from '@/lib/errorReason' import type { WalletFileName } from '@/lib/utils' import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils' +import { usePreviewFeatures } from '@/store/jamSettingsStore' import { Balance } from './ui/jam/Balance' import { Spinner } from './ui/spinner' +import { TxHistoryContent } from './wallet/TxHistoryContent' +import { TxHistoryOverlay } from './wallet/TxHistoryOverlay' import { WalletJarsDetailsOverlay } from './wallet/WalletJarsDetailsOverlay' interface MainWalletPageProps { @@ -28,8 +31,11 @@ interface MainWalletPageProps { export default function MainWalletPage({ walletFileName }: MainWalletPageProps) { const { t } = useTranslation() const navigate = useNavigate() + const previewFeatures = usePreviewFeatures() const [selectedJar, setSelectedJar] = useState() const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false) + const [isTxHistoryOpen, setIsTxHistoryOpen] = useState(false) + const [isHistoryExpanded, setIsHistoryExpanded] = useState(false) const { toggleDisplayMode } = useJamDisplayContext() const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext() @@ -55,6 +61,10 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) walletFileName={walletFileName} selectedJarIndex={selectedJar?.jarIndex} /> + {previewFeatures?.['tx-history'] === true ? ( + + ) : null} +

@@ -87,8 +97,37 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) {t('current_wallet.button_withdraw')}

+ + {previewFeatures?.['tx-history'] === true ? ( +
+
+ +
+
+ ) : null}
+ {isHistoryExpanded && ( + setIsTxHistoryOpen(true)} + className="w-full max-w-6xl" + /> + )} + {error ? ( @@ -116,7 +155,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
- {isFetching ? ( + {isLoading ? (
{t('global.loading')} diff --git a/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.test.tsx b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.test.tsx index 4b5b62a0..fd0afdd3 100644 --- a/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.test.tsx +++ b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.test.tsx @@ -53,16 +53,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({ vi.mock('@/components/ui/jam/SelectableJar', () => ({ SelectableJar: ({ name, - onClick, + onSelect, disabled, isSelected, }: { name?: string - onClick?: () => void + onSelect?: () => void disabled?: boolean isSelected?: boolean }) => ( - ), diff --git a/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.test.ts b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.test.ts index 1c43be8c..1e7eb60d 100644 --- a/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.test.ts +++ b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.test.ts @@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Jar } from '@/context/JamWalletInfoContext' import type { Utxo } from '@/hooks/useQueryUtxos' +import { BALANCE_SUMMARY_EMPTY } from '@/lib/balanceSummary' import { generateLockdateOptions } from './types' import { useCreateFidelityBondWizard } from './useCreateFidelityBondWizard' @@ -90,7 +91,7 @@ const jar = (jarIndex: number, utxos: Utxo[]): Jar => jarIndex, name: `Jar ${jarIndex}`, color: '#808080', - balanceSummary: {}, + balanceSummary: BALANCE_SUMMARY_EMPTY, utxos, }) as unknown as Jar diff --git a/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts index feca2282..b19b0f49 100644 --- a/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts +++ b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.ts @@ -6,11 +6,15 @@ import { useQuery } from '@tanstack/react-query' import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { + JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS, + JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY, +} from '@/constants/jam' import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' import type { Utxo } from '@/hooks/useQueryUtxos' import * as fb from '@/lib/fidelityBondUtils' -import type { WalletFileName } from '@/lib/utils' +import { delayedPromise, type WalletFileName } from '@/lib/utils' import { useDeveloperMode } from '@/store/jamSettingsStore' import type { JarIndex } from '@/types/global' import { useFidelityBondMutations } from '../fidelity-bond/useFidelityBondMutations' @@ -106,10 +110,8 @@ export function useCreateFidelityBondWizard( const jarsWithUtxos = useMemo(() => { return walletInfo.jars.filter((jar) => { - // TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction - const availableUtxos = jar.utxos.filter( - (utxo) => - !utxo.frozen && !fb.utxo.isFidelityBond(utxo) && walletInfo.addressSummary[utxo.address]?.status === 'cj-out', + const availableUtxos = jar.utxos.filter((utxo) => + fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status), ) return availableUtxos.length > 0 }) @@ -117,17 +119,9 @@ export function useCreateFidelityBondWizard( const availableUtxos = useMemo(() => { if (selectedJar === undefined) return [] - return ( - selectedJar.utxos - // TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction - .filter( - (utxo) => - !utxo.frozen && - !fb.utxo.isFidelityBond(utxo) && - walletInfo.addressSummary[utxo.address]?.status === 'cj-out', - ) - .toSorted((a, b) => b.value - a.value) - ) + return selectedJar.utxos + .filter((utxo) => fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status)) + .toSorted((a, b) => b.value - a.value) }, [selectedJar, walletInfo.addressSummary]) const selectedUtxos = useMemo(() => { @@ -283,17 +277,15 @@ export function useCreateFidelityBondWizard( setStep('creating') try { + // TODO: refactor: use the sweep functionality from `useFidelityBondSweep` here const result = await directSend.mutateAsync({ path: { walletname: walletFileName }, body: { mixdepth: selectedJar.jarIndex, - amount_sats: 0, + amount_sats: 0, // 0 := sweep! destination: address, }, }) - setTxResult(result) - setStep('success') - toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text')) // Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure for (const utxo of frozenUtxos) { @@ -301,12 +293,33 @@ export function useCreateFidelityBondWizard( await unfreezeUtxo.mutateAsync({ path: { walletname: walletFileName }, body: { 'utxo-string': utxo.utxo, freeze: false }, + throwOnError: true, }) - } catch { - // logged via onError + } catch (_ignoredOnPurpose: unknown) { + // only debug output + console.debug('Error while unfreezing previously frozen UTXO.') } } + // freeze resulting fidelity bond output + if (JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS) { + try { + await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY) + const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output + await freezeUtxo.mutateAsync({ + path: { walletname: walletFileName }, + body: { 'utxo-string': fbUtxoId, freeze: true }, + throwOnError: true, + }) + } catch (_ignoredOnPurpose: unknown) { + console.warn('Error while freezing Fidelity Bond UTXO - continuing.') + } + } + + setTxResult(result) + setStep('success') + toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text')) + await walletInfo.refetch() } catch { // Best-effort rollback — unfreeze UTXOs that were frozen for this operation @@ -316,8 +329,9 @@ export function useCreateFidelityBondWizard( path: { walletname: walletFileName }, body: { 'utxo-string': utxo.utxo, freeze: false }, }) - } catch { - // logged via onError + } catch (_ignoredOnPurpose: unknown) { + // only debug output + console.debug('Error while unfreezing previously frozen UTXO in error handling.') } } setFrozenUtxos([]) diff --git a/src/components/earn/EarnForm.tsx b/src/components/earn/EarnForm.tsx index f45b166a..9a4d7243 100644 --- a/src/components/earn/EarnForm.tsx +++ b/src/components/earn/EarnForm.tsx @@ -3,7 +3,7 @@ import { yupResolver } from '@hookform/resolvers/yup' import type { TFunction } from 'i18next' import { HandshakeIcon, PercentIcon } from 'lucide-react' import { useForm, useWatch } from 'react-hook-form' -import type { Resolver, SubmitHandler } from 'react-hook-form' +import type { SubmitHandler } from 'react-hook-form' import { useTranslation } from 'react-i18next' import * as yup from 'yup' import { Button } from '@/components/ui/button' @@ -179,8 +179,7 @@ export function EarnForm({ } = useForm({ mode: 'onSubmit', defaultValues: FORM_INPUT_DEFAULT_VALUES, - // force type (see https://github.com/react-hook-form/resolvers/issues/807) - resolver: yupResolver(schema) as Resolver, + resolver: yupResolver(schema), }) const values = useWatch({ control }) diff --git a/src/components/earn/EarnPage.test.tsx b/src/components/earn/EarnPage.test.tsx index e767f53d..b3d781a7 100644 --- a/src/components/earn/EarnPage.test.tsx +++ b/src/components/earn/EarnPage.test.tsx @@ -2,7 +2,9 @@ import type React from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' +import * as JAM from '@/constants/jam' +import type { Jar } from '@/context/JamWalletInfoContext' +import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos' import { jmSessionStore } from '@/store/jmSessionStore' import type { EarnFormValues } from './EarnForm' import { EarnPage } from './EarnPage' @@ -10,6 +12,12 @@ import { EarnPage } from './EarnPage' const mocks = vi.hoisted(() => ({ developerMode: false, feeConfigMissing: false, + orderbookData: vi.fn<() => unknown>(), + orderbookQueryOptions: vi.fn(), + orderbookQueryState: { + isError: false, + isLoading: false, + }, scrollToTop: vi.fn(), startMaker: vi.fn(), startMutationState: { @@ -26,17 +34,12 @@ const mocks = vi.hoisted(() => ({ toastInfo: vi.fn(), toastSuccess: vi.fn(), walletInfo: { + addressSummary: {}, fidelityBondSummary: { fbOutputs: [] as FidelityBondUtxo[] }, + hasEligibleFidelityBondUtxo: true, isFetching: false, isLoading: false, - jars: [] as Array<{ - balanceSummary: { - calculatedAvailableBalanceInSats: number - calculatedConfirmedAvailableBalanceInSats: number - calculatedFrozenOrLockedBalanceInSats: number - calculatedTotalBalanceInSats: number - } - }>, + jars: [] as Jar[], maxJarAvailableBalance: 100_000_000, }, })) @@ -76,15 +79,26 @@ vi.mock('@tanstack/react-query', () => ({ reset: vi.fn(), } }), - useQuery: vi.fn(() => ({ - refetch: mocks.stopMakerRefetch, - })), + useQuery: vi.fn(() => { + return { refetch: mocks.stopMakerRefetch } + }), +})) + +vi.mock('@/hooks/useQueryOrderbook', () => ({ + useQueryOrderbook: (options: unknown) => { + mocks.orderbookQueryOptions(options) + return { + queryResult: { + data: mocks.orderbookData(), + ...mocks.orderbookQueryState, + }, + } + }, })) vi.mock('react-i18next', () => ({ - Trans: ({ children, i18nKey }: { children?: React.ReactNode; i18nKey?: string }) => ( - {children ?? i18nKey} - ), + Trans: ({ i18nKey, values }: { i18nKey: string; values?: unknown }) => + values ? `${i18nKey}:${JSON.stringify(values)}` : i18nKey, useTranslation: () => ({ t: (key: string, options?: Record) => (options ? `${key}:${JSON.stringify(options)}` : key), }), @@ -166,6 +180,7 @@ vi.mock('./EarnForm', () => ({
earn-form:{String(disabled)}:{String(debug)} @@ -354,14 +399,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
{walletInfo.fidelityBondSummary.fbOutputs.map((it) => { const isExpired = !fb.utxo.isLocked(it) - const actionsEnabled = - isExpired && - !jmSession.rescanning && - !jmSession.maker_running && - !jmSession.coinjoin_in_process && - !waitingForMakerUpdate && - !waitingForOfferUpdate && - !walletInfo.isFetching + const actionsEnabled = isExpired && isFidelityBondActionsEnabled return ( {isExpired && ( @@ -390,10 +428,14 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => { ) })}
- {/* Existing bonds hide the create button — expose it in developer mode to test multiple bonds */} - {isDeveloperMode && ( + + {showCreateAdditionalFidelityBond && (
- diff --git a/src/components/earn/EarnPreconditionAlerts.tsx b/src/components/earn/EarnPreconditionAlerts.tsx new file mode 100644 index 00000000..a95b1afe --- /dev/null +++ b/src/components/earn/EarnPreconditionAlerts.tsx @@ -0,0 +1,77 @@ +import { AlertTriangleIcon } from 'lucide-react' +import { Trans, useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { routes } from '@/constants/routes' + +export const UnconfirmedBalanceAlert = ({ numberOfMissingConfirmations }: { numberOfMissingConfirmations: number }) => { + const { t } = useTranslation() + + return ( + + + {t('earn.precondition.title')} + +
    +
  • + {t('earn.precondition.hint_missing_confirmations', { + count: numberOfMissingConfirmations, + })} +
  • +
+
+
+ ) +} + +export const NoSpendableBalanceAlert = () => { + const { t } = useTranslation() + + return ( + + + {t('earn.precondition.title')} + +
    +
  • + + + Fund your wallet + + to start earning. + +
  • +
+
+
+ ) +} + +export const NonFrozenFidelityBondsAlert = ({ + numberOfNonFrozenFidelityBondOutputs, +}: { + numberOfNonFrozenFidelityBondOutputs: number +}) => { + const { t } = useTranslation() + + return ( + + + {t('earn.precondition.title')} + + +
    +
  • + , + }} + /> +
  • +
+
+
+ ) +} diff --git a/src/components/earn/FidelityBondCard.test.tsx b/src/components/earn/FidelityBondCard.test.tsx index 60b17a50..b7cffbfc 100644 --- a/src/components/earn/FidelityBondCard.test.tsx +++ b/src/components/earn/FidelityBondCard.test.tsx @@ -34,7 +34,7 @@ vi.mock('../ui/jam/Address', () => ({ Address: ({ value }: { value?: string }) => {value}, })) -vi.mock('../ui/jam/Balance', () => ({ +vi.mock('@/components/ui/jam/Balance', () => ({ Balance: ({ valueString }: { valueString?: string }) => {valueString}, })) diff --git a/src/components/earn/FidelityBondCard.tsx b/src/components/earn/FidelityBondCard.tsx index 7cb649bf..ef254522 100644 --- a/src/components/earn/FidelityBondCard.tsx +++ b/src/components/earn/FidelityBondCard.tsx @@ -2,18 +2,19 @@ import { useMemo, type PropsWithChildren } from 'react' import { ClockIcon, CoinsIcon, CopyIcon } from 'lucide-react' import { Trans, useTranslation } from 'react-i18next' import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { Balance } from '@/components/ui/jam/Balance' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' import * as fb from '@/lib/fidelityBondUtils' -import { time } from '@/lib/utils' +import { cn, time } from '@/lib/utils' import { Address } from '../ui/jam/Address' -import { Balance } from '../ui/jam/Balance' interface FidelityBondCardProps { value: FidelityBondUtxo + className?: string } -export function FidelityBondCard({ value, children }: PropsWithChildren) { +export function FidelityBondCard({ value, className, children }: PropsWithChildren) { const { t, i18n } = useTranslation() const isExpired = !fb.utxo.isLocked(value) @@ -31,7 +32,7 @@ export function FidelityBondCard({ value, children }: PropsWithChildren + {isExpired ? ( diff --git a/src/components/earn/MoveToJarDialog.schema.test.ts b/src/components/earn/MoveToJarDialog.schema.test.ts new file mode 100644 index 00000000..efa65a6a --- /dev/null +++ b/src/components/earn/MoveToJarDialog.schema.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { createMoveToJarFormSchema } from './MoveToJarDialog.schema' + +describe('createMoveToJarFormSchema', () => { + const schema = createMoveToJarFormSchema([0, 2]) + + it('accepts an available destination jar', async () => { + await expect(schema.validate({ destinationJarIndex: 2 })).resolves.toEqual({ destinationJarIndex: 2 }) + }) + + it.each([{ destinationJarIndex: 1 }, { destinationJarIndex: undefined }])( + 'rejects an unavailable destination jar', + async (values) => { + await expect(schema.isValid(values)).resolves.toBe(false) + }, + ) +}) diff --git a/src/components/earn/MoveToJarDialog.schema.ts b/src/components/earn/MoveToJarDialog.schema.ts new file mode 100644 index 00000000..a14aa72a --- /dev/null +++ b/src/components/earn/MoveToJarDialog.schema.ts @@ -0,0 +1,13 @@ +import * as yup from 'yup' +import type { JarIndex } from '@/types/global' + +export type MoveToJarFormValues = { + destinationJarIndex: JarIndex +} + +export const createMoveToJarFormSchema = (availableJarIndexes: JarIndex[]): yup.ObjectSchema => + yup + .object({ + destinationJarIndex: yup.number().oneOf(availableJarIndexes).required(), + }) + .required() diff --git a/src/components/earn/MoveToJarDialog.test.tsx b/src/components/earn/MoveToJarDialog.test.tsx index 9215d192..d9a23d55 100644 --- a/src/components/earn/MoveToJarDialog.test.tsx +++ b/src/components/earn/MoveToJarDialog.test.tsx @@ -4,17 +4,20 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' import { MoveToJarDialog } from './MoveToJarDialog' +type MutationOptions = { mutationFn: (input: unknown) => Promise } + type ChildrenProps = { children: ReactNode } type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void } type ValueProps = { value: string } const h = vi.hoisted(() => ({ - queryReturn: { + fetchAddressQueryData: { data: undefined as { address?: string } | undefined, isLoading: false, isError: false, }, - mutateAsync: vi.fn<() => Promise>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }), + directSendAsync: vi.fn<() => Promise>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }), + freezeAsync: vi.fn<() => Promise>().mockResolvedValue({}), isPending: false, refetch: vi.fn(), extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>, @@ -27,13 +30,16 @@ vi.mock('react-i18next', () => ({ })) vi.mock('@tanstack/react-query', () => ({ - useQuery: () => h.queryReturn, - useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }), + useQuery: () => h.fetchAddressQueryData, + useMutation: vi.fn((options: MutationOptions) => ({ + isPending: false, + mutateAsync: async (input: unknown) => await options.mutationFn(input), + })), })) vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({ - directsendMutation: vi.fn(() => ({})), - freezeMutation: vi.fn(() => ({})), + directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })), + freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })), getaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })), })) @@ -72,16 +78,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({ vi.mock('@/components/ui/jam/SelectableJar', () => ({ SelectableJar: ({ name, - onClick, + onSelect, disabled, isSelected, }: { name?: string - onClick?: () => void + onSelect?: () => void disabled?: boolean isSelected?: boolean }) => ( - ), @@ -146,8 +152,9 @@ const goToConfirm = () => { describe('MoveToJarDialog', () => { beforeEach(() => { - h.queryReturn = { data: { address: 'bc111' }, isLoading: false, isError: false } - h.mutateAsync = vi.fn<() => Promise>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }) + h.fetchAddressQueryData = { data: { address: 'bc111' }, isLoading: false, isError: false } + h.directSendAsync = vi.fn<() => Promise>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }) + h.freezeAsync = vi.fn<() => Promise>().mockResolvedValue({}) h.isPending = false h.refetch = vi.fn() h.extraUtxos = [] @@ -169,14 +176,14 @@ describe('MoveToJarDialog', () => { }) it('shows a loading indicator while the destination address loads', () => { - h.queryReturn = { data: undefined, isLoading: true, isError: false } + h.fetchAddressQueryData = { data: undefined, isLoading: true, isError: false } renderDialog() goToConfirm() expect(screen.getByText('earn.fidelity_bond.move.text_loading')).toBeInTheDocument() }) it('shows an error alert when the address query fails', () => { - h.queryReturn = { data: undefined, isLoading: false, isError: true } + h.fetchAddressQueryData = { data: undefined, isLoading: false, isError: true } renderDialog() goToConfirm() expect(screen.getByText('global.errors.error_loading_address_failed')).toBeInTheDocument() @@ -200,12 +207,12 @@ describe('MoveToJarDialog', () => { fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit')) await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument()) - const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]> + const calls = h.freezeAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]> expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true) }) it('returns to the confirm step when sending fails', async () => { - h.mutateAsync = vi.fn<() => Promise>().mockRejectedValue(new Error('send failed')) + h.directSendAsync = vi.fn<() => Promise>().mockRejectedValue(new Error('send failed')) renderDialog() goToConfirm() fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit')) diff --git a/src/components/earn/MoveToJarDialog.tsx b/src/components/earn/MoveToJarDialog.tsx index 7b7234c0..e6d714d4 100644 --- a/src/components/earn/MoveToJarDialog.tsx +++ b/src/components/earn/MoveToJarDialog.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' +import { yupResolver } from '@hookform/resolvers/yup' import { getaddressOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { useQuery } from '@tanstack/react-query' import { AlertTriangleIcon, UnlockIcon } from 'lucide-react' +import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Alert, AlertTitle } from '@/components/ui/alert' @@ -11,7 +13,7 @@ import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' import type { WalletFileName } from '@/lib/utils' -import type { JarIndex } from '@/types/global' +import { createMoveToJarFormSchema, type MoveToJarFormValues } from './MoveToJarDialog.schema' import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout' import { AddressPreview, @@ -45,9 +47,25 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo const walletInfo = useJamWalletInfoContext() const [step, setStep] = useState('select_jar') - const [selectedJarIndex, setSelectedJarIndex] = useState() const [txResult, setTxResult] = useState() + const availableJarIndexes = useMemo(() => walletInfo.jars.map((jar) => jar.jarIndex), [walletInfo.jars]) + const formSchema = useMemo(() => createMoveToJarFormSchema(availableJarIndexes), [availableJarIndexes]) + const { + control, + handleSubmit, + reset, + setValue, + formState: { isSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues: { + destinationJarIndex: undefined, + }, + resolver: yupResolver(formSchema), + }) + const selectedJarIndex = useWatch({ control, name: 'destinationJarIndex' }) + const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({ walletFileName, utxo, @@ -77,7 +95,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo const handleReset = () => { setStep('select_jar') - setSelectedJarIndex(undefined) + reset() setTxResult(undefined) setError(undefined) } @@ -89,17 +107,24 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo onOpenChange(newOpen) } - const handleSubmit = async () => { - if (selectedJarIndex === undefined || !destinationAddress) return + const submitMove = handleSubmit(async () => { + if (!destinationAddress) return setStep('sending') - const swept = await sweep(destinationAddress, (result) => { - setTxResult(result) + const txResult = await sweep({ + destination: destinationAddress, + tryFreezeAfterBroadcast: false, + }) + + setTxResult(txResult) + + if (txResult) { setStep('success') toast.success(t('earn.fidelity_bond.move.success_text')) - }) - if (!swept) setStep('confirm') - } + } else { + setStep('confirm') + } + }) const renderFooter = () => { switch (step) { @@ -117,9 +142,9 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo setStep('select_jar')} onCancel={() => handleOpenChange(false)} - onPrimary={() => void handleSubmit()} + onPrimary={() => void submitMove()} primaryDisabled={!destinationAddress} - isLoading={isLoading} + isLoading={isLoading || isSubmitting} primaryLabel={t('earn.fidelity_bond.move.text_button_submit')} /> ) @@ -150,7 +175,13 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo + setValue('destinationJarIndex', jarIndex, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } isJarDisabled={() => false} />
diff --git a/src/components/earn/OfferCard.test.tsx b/src/components/earn/OfferCard.test.tsx index 62821467..150e93c9 100644 --- a/src/components/earn/OfferCard.test.tsx +++ b/src/components/earn/OfferCard.test.tsx @@ -1,6 +1,7 @@ import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { withRuntimeLocale } from '@/test/withRuntimeLocale' import { OfferCard } from './OfferCard' type Offer = NonNullable[number] @@ -18,7 +19,7 @@ vi.mock('@/lib/utils', () => ({ isRelativeOffer: (type: string) => type === 'sw0relo', })) -vi.mock('../ui/jam/Balance', () => ({ +vi.mock('@/components/ui/jam/Balance', () => ({ Balance: ({ valueString }: { valueString?: string }) => {valueString}, })) @@ -79,4 +80,41 @@ describe('OfferCard', () => { expect(screen.getByTestId('child')).toBeInTheDocument() }) + + it('shows whether the offer is visible in the local orderbook', () => { + const { rerender } = render() + + expect(screen.getByText('earn.current.text_orderbook_visible')).toBeInTheDocument() + + rerender() + expect(screen.getByText('earn.current.text_orderbook_missing')).toBeInTheDocument() + }) + + it('shows the advertised fidelity bond details', () => { + withRuntimeLocale('de-DE', () => { + render( + , + ) + + expect(screen.getByText('earn.current.text_fidelity_bond')).toBeInTheDocument() + expect(screen.getByText('earn.current.text_bond_value: 42.000')).toBeInTheDocument() + expect(screen.getByText('100000')).toBeInTheDocument() + expect(screen.getByText(/earn\.current\.text_bond_locktime/u)).toBeInTheDocument() + }) + }) }) diff --git a/src/components/earn/OfferCard.tsx b/src/components/earn/OfferCard.tsx index 33eaae1e..9880cdaa 100644 --- a/src/components/earn/OfferCard.tsx +++ b/src/components/earn/OfferCard.tsx @@ -1,11 +1,22 @@ import type { PropsWithChildren } from 'react' import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import type { TFunction } from 'i18next' -import { FingerprintIcon, HandCoinsIcon, Maximize2Icon, Minimize2Icon } from 'lucide-react' +import { + CircleAlertIcon, + CircleCheckIcon, + FingerprintIcon, + HandCoinsIcon, + Maximize2Icon, + Minimize2Icon, + PickaxeIcon, + SearchIcon, + ShieldCheckIcon, +} from 'lucide-react' import { useTranslation } from 'react-i18next' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { OrderbookFidelityBond, OrderbookOffer } from '@/lib/api/orderbook' import { cn, factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '@/lib/utils' import { Balance } from '../ui/jam/Balance' import { Label } from '../ui/label' @@ -36,16 +47,50 @@ interface OfferCardProps { className?: string value: Offer nickname: SessionResponse['nickname'] + orderbookStatus?: 'checking' | 'visible' | 'missing' | 'error' + orderbookOffer?: OrderbookOffer + fidelityBond?: OrderbookFidelityBond } -export function OfferCard({ className, value, nickname, children }: PropsWithChildren) { +const orderbookStatusEntry = (value: OfferCardProps['orderbookStatus'], t: TFunction) => { + return value === 'visible' + ? { icon: CircleCheckIcon, text: t('earn.current.text_orderbook_visible'), variant: 'success' as const } + : value === 'missing' + ? { icon: SearchIcon, text: t('earn.current.text_orderbook_missing'), variant: 'warning' as const } + : value === 'error' + ? { icon: CircleAlertIcon, text: t('earn.current.text_orderbook_error'), variant: 'destructive' as const } + : value === 'checking' + ? { icon: SearchIcon, text: t('earn.current.text_orderbook_checking'), variant: 'muted' as const } + : undefined +} + +export function OfferCard({ + className, + value, + nickname, + orderbookStatus, + orderbookOffer, + fidelityBond, + children, +}: PropsWithChildren) { const { t } = useTranslation() + const bondValue = Number(orderbookOffer?.fidelity_bond_value) || 0 + + const offerInOrderbookStatus = orderbookStatusEntry(orderbookStatus, t) + const bondWarning = bondValue === 0 || orderbookOffer?.fidelity_bond_verification_stale === true return ( - + {t('earn.current.text_offer')} - + + {offerInOrderbookStatus && ( + + + {offerInOrderbookStatus.text} + + )} + @@ -100,8 +145,9 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
{!!value?.txfee && ( -
-
+
+ +
@@ -109,6 +155,45 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
)} + {fidelityBond !== undefined && ( +
+ 0, + 'text-brand-warning': bondWarning, + })} + /> +
+ +
+
+ + {t('earn.current.text_bond_value')}: {Math.floor(bondValue).toLocaleString()} + +
+
+ + + {t('earn.current.text_bond_locktime', { + date: new Date(fidelityBond.locktime * 1_000).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }), + })} + +
+
+
+
+ )} {children} diff --git a/src/components/earn/RenewBondDialog.schema.test.ts b/src/components/earn/RenewBondDialog.schema.test.ts new file mode 100644 index 00000000..2053bb0a --- /dev/null +++ b/src/components/earn/RenewBondDialog.schema.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { renewBondFormSchema } from './RenewBondDialog.schema' + +describe('renewBondFormSchema', () => { + it('accepts a lockdate after confirmation', async () => { + const values = { lockdate: '2030-01' as const, confirmationAccepted: true } + await expect(renewBondFormSchema.validate(values)).resolves.toEqual(values) + }) + + it.each([ + { lockdate: undefined, confirmationAccepted: true }, + { lockdate: '2030-01' as const, confirmationAccepted: false }, + ])('rejects incomplete renewal values', async (values) => { + await expect(renewBondFormSchema.isValid(values)).resolves.toBe(false) + }) +}) diff --git a/src/components/earn/RenewBondDialog.schema.ts b/src/components/earn/RenewBondDialog.schema.ts new file mode 100644 index 00000000..a36649f9 --- /dev/null +++ b/src/components/earn/RenewBondDialog.schema.ts @@ -0,0 +1,19 @@ +import * as yup from 'yup' +import * as fb from '@/lib/fidelityBondUtils' + +export type RenewBondFormValues = { + lockdate?: fb.Lockdate + confirmationAccepted: boolean +} + +export const RENEW_BOND_FORM_DEFAULT_VALUES: RenewBondFormValues = { + lockdate: undefined, + confirmationAccepted: false, +} + +export const renewBondFormSchema: yup.ObjectSchema = yup + .object({ + lockdate: yup.string().required(), + confirmationAccepted: yup.boolean().oneOf([true]).required(), + }) + .required() diff --git a/src/components/earn/RenewBondDialog.test.tsx b/src/components/earn/RenewBondDialog.test.tsx index 4b9f944d..1b181058 100644 --- a/src/components/earn/RenewBondDialog.test.tsx +++ b/src/components/earn/RenewBondDialog.test.tsx @@ -4,13 +4,15 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' import { RenewBondDialog } from './RenewBondDialog' +type MutationOptions = { mutationFn: (input: unknown) => Promise } + type ChildrenProps = { children?: ReactNode } type ValueProps = { value: string } const h = vi.hoisted(() => ({ - queryReturn: { data: undefined as { address: string } | undefined, isLoading: false, isError: false }, - mutateAsync: vi.fn<() => Promise>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }), - isPending: false, + fetchAddressQueryData: { data: undefined as { address: string } | undefined, isLoading: false, isError: false }, + directSendAsync: vi.fn<() => Promise>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }), + freezeAsync: vi.fn<() => Promise>().mockResolvedValue({}), refetch: vi.fn(), utxoFrozen: false, extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>, @@ -24,13 +26,16 @@ vi.mock('react-i18next', () => ({ })) vi.mock('@tanstack/react-query', () => ({ - useQuery: () => h.queryReturn, - useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }), + useQuery: () => h.fetchAddressQueryData, + useMutation: vi.fn((options: MutationOptions) => ({ + isPending: false, + mutateAsync: async (input: unknown) => await options.mutationFn(input), + })), })) vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({ - directsendMutation: vi.fn(() => ({})), - freezeMutation: vi.fn(() => ({})), + directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })), + freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })), gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })), })) @@ -176,9 +181,9 @@ const goToConfirm = () => { describe('RenewBondDialog', () => { beforeEach(() => { vi.clearAllMocks() - h.queryReturn = { data: { address: 'bc1timelock' }, isLoading: false, isError: false } - h.mutateAsync = vi.fn<() => Promise>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }) - h.isPending = false + h.fetchAddressQueryData = { data: { address: 'bc1timelock' }, isLoading: false, isError: false } + h.directSendAsync = vi.fn<() => Promise>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }) + h.freezeAsync = vi.fn<() => Promise>().mockResolvedValue({}) h.utxoFrozen = false h.extraUtxos = [] h.selectHandlers = [] @@ -217,27 +222,33 @@ describe('RenewBondDialog', () => { }) it('shows a loading spinner while the timelock address loads', () => { - h.queryReturn = { data: undefined, isLoading: true, isError: false } + h.fetchAddressQueryData = { data: undefined, isLoading: true, isError: false } renderDialog() goToConfirm() expect(screen.getByText('earn.fidelity_bond.renew.text_loading')).toBeInTheDocument() }) it('shows an error alert when the address query fails', () => { - h.queryReturn = { data: undefined, isLoading: false, isError: true } + h.fetchAddressQueryData = { data: undefined, isLoading: false, isError: true } renderDialog() expect(screen.getByText('earn.fidelity_bond.error_loading_address')).toBeInTheDocument() }) it('completes the renew flow and reaches the success step', async () => { h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }] + renderDialog() + + expect(screen.queryByText('earn.fidelity_bond.renew.success_text')).not.toBeInTheDocument() + goToConfirm() + fireEvent.click(screen.getByTestId('confirm-switch')) fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit')) await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument()) expect(screen.getAllByText('abcd1234').length).toBeGreaterThan(0) + expect(h.refetch).toHaveBeenCalled() }) @@ -249,13 +260,40 @@ describe('RenewBondDialog', () => { fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit')) await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument()) - const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]> - expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true) + const calls = h.freezeAsync.mock.calls as unknown as Array< + [{ body?: { 'utxo-string'?: string; freeze?: boolean } }] + > + expect( + calls.some((call) => call[0]?.body?.['utxo-string'] === frozenUtxo.utxo && call[0]?.body?.freeze === false), + ).toBe(true) + }) + + it('freezes a fidelity bond after sending', async () => { + const frozenUtxo = { ...utxo, frozen: true } as unknown as FidelityBondUtxo + render() + goToConfirm() + fireEvent.click(screen.getByTestId('confirm-switch')) + fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit')) + + await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument()) + + const calls = h.freezeAsync.mock.calls as unknown as Array< + [{ body?: { 'utxo-string'?: string; freeze?: boolean } }] + > + // unfreezes the expired fb utxo + expect( + calls.some((call) => call[0]?.body?.['utxo-string'] === frozenUtxo.utxo && call[0]?.body?.freeze === false), + ).toBe(true) + + // freezes the renewed fb utxo + expect( + calls.some((call) => call[0]?.body?.['utxo-string'] === 'abcd1234:0' && call[0]?.body?.freeze === true), + ).toBe(true) }) it('returns to the confirm step when sending fails', async () => { h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }] - h.mutateAsync = vi.fn<() => Promise>().mockRejectedValue(new Error('send failed')) + h.directSendAsync = vi.fn<() => Promise>().mockRejectedValue(new Error('send failed')) renderDialog() goToConfirm() fireEvent.click(screen.getByTestId('confirm-switch')) diff --git a/src/components/earn/RenewBondDialog.tsx b/src/components/earn/RenewBondDialog.tsx index db02845b..6896c20d 100644 --- a/src/components/earn/RenewBondDialog.tsx +++ b/src/components/earn/RenewBondDialog.tsx @@ -1,16 +1,20 @@ import { useState } from 'react' +import { yupResolver } from '@hookform/resolvers/yup' import { gettimelockaddressOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { useQuery } from '@tanstack/react-query' import { AlertTriangleIcon, CalendarIcon, RefreshCwIcon } from 'lucide-react' +import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' +import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS } from '@/constants/jam' import { useApiClient } from '@/hooks/useApiClient' import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' import * as fb from '@/lib/fidelityBondUtils' import type { WalletFileName } from '@/lib/utils' +import { RENEW_BOND_FORM_DEFAULT_VALUES, renewBondFormSchema, type RenewBondFormValues } from './RenewBondDialog.schema' import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout' import { AddressPreview, @@ -44,10 +48,25 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re const client = useApiClient() const [step, setStep] = useState('select_date') - const [selectedLockdate, setSelectedLockdate] = useState('') - const [confirmationChecked, setConfirmationChecked] = useState(false) const [txResult, setTxResult] = useState() + const { + control, + handleSubmit, + reset, + setValue, + formState: { isSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues: RENEW_BOND_FORM_DEFAULT_VALUES, + resolver: yupResolver(renewBondFormSchema), + }) + const selectedLockdate = useWatch({ control, name: 'lockdate' }) + const confirmationChecked = useWatch({ + control, + name: 'confirmationAccepted', + }) + const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({ walletFileName, utxo, @@ -76,8 +95,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re const handleReset = () => { setStep('select_date') - setSelectedLockdate('') - setConfirmationChecked(false) + reset(RENEW_BOND_FORM_DEFAULT_VALUES) setTxResult(undefined) setError(undefined) } @@ -89,17 +107,24 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re onOpenChange(newOpen) } - const handleSubmit = async () => { + const submitRenewal = handleSubmit(async () => { if (!destinationAddress) return setStep('sending') - const swept = await sweep(destinationAddress, (result) => { - setTxResult(result) + + const txResult = await sweep({ + destination: destinationAddress, + tryFreezeAfterBroadcast: JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS, + }) + + setTxResult(txResult) + if (txResult) { setStep('success') toast.success(t('earn.fidelity_bond.renew.success_text')) - }) - if (!swept) setStep('confirm') - } + } else { + setStep('confirm') + } + }) const renderFooter = () => { switch (step) { @@ -117,9 +142,9 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re setStep('select_date')} onCancel={() => handleOpenChange(false)} - onPrimary={() => void handleSubmit()} + onPrimary={() => void submitRenewal()} primaryDisabled={!confirmationChecked || !destinationAddress} - isLoading={isLoading} + isLoading={isLoading || isSubmitting} primaryLabel={t('earn.fidelity_bond.renew.text_button_submit')} /> ) @@ -148,7 +173,17 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re - + + setValue('lockdate', lockdate || undefined, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } + />
)} @@ -193,7 +228,13 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re + setValue('confirmationAccepted', checked, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } />
)} diff --git a/src/components/earn/fidelity-bond/FidelityBondDialogParts.tsx b/src/components/earn/fidelity-bond/FidelityBondDialogParts.tsx index 9174958c..e50493ea 100644 --- a/src/components/earn/fidelity-bond/FidelityBondDialogParts.tsx +++ b/src/components/earn/fidelity-bond/FidelityBondDialogParts.tsx @@ -52,6 +52,7 @@ export function FidelityBondAmount({ value, className }: { value: AmountSats; cl return } +/* TODO: move component to different namespace */ export function JarBadge({ jarIndex, name }: { jarIndex: JarIndex | undefined; name?: string }) { const { t } = useTranslation() return ( @@ -61,6 +62,7 @@ export function JarBadge({ jarIndex, name }: { jarIndex: JarIndex | undefined; n {name} #{jarIndex} ) : ( + /* TODO: i18n - move key to different namespace */ t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: jarIndex }) )} diff --git a/src/components/earn/fidelity-bond/FidelityBondJarSelector.tsx b/src/components/earn/fidelity-bond/FidelityBondJarSelector.tsx index 68f6a4e4..db4db5f7 100644 --- a/src/components/earn/fidelity-bond/FidelityBondJarSelector.tsx +++ b/src/components/earn/fidelity-bond/FidelityBondJarSelector.tsx @@ -24,7 +24,7 @@ export function FidelityBondJarSelector({ selectedJarIndex, onSelect, isJarDisab frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats} totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats} isSelected={selectedJarIndex === jar.jarIndex} - onClick={() => onSelect(jar.jarIndex)} + onSelect={() => onSelect(jar.jarIndex)} disabled={isJarDisabled(jar)} /> ))} diff --git a/src/components/earn/fidelity-bond/useFidelityBondMutations.ts b/src/components/earn/fidelity-bond/useFidelityBondMutations.ts index 51249c08..c47f4d5a 100644 --- a/src/components/earn/fidelity-bond/useFidelityBondMutations.ts +++ b/src/components/earn/fidelity-bond/useFidelityBondMutations.ts @@ -26,16 +26,19 @@ export function useFidelityBondMutations({ const freezeUtxo = useMutation({ ...freezeMutation({ client }), + retry: false, onError: (error) => setErrorWithReason(error, t('global.errors.error_freezing_utxos')), }) const unfreezeUtxo = useMutation({ ...freezeMutation({ client }), + retry: false, onError: (error) => setErrorWithReason(error, t(unfreezeErrorKey)), }) const directSend = useMutation({ ...directsendMutation({ client }), + retry: false, onError: (error) => setErrorWithReason(error, t(sendErrorKey)), }) diff --git a/src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts b/src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts new file mode 100644 index 00000000..ca985da0 --- /dev/null +++ b/src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts @@ -0,0 +1,208 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Jar } from '@/context/JamWalletInfoContext' +import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos' +import { BALANCE_SUMMARY_EMPTY } from '@/lib/balanceSummary' +import { useFidelityBondSweep } from './useFidelityBondSweep' + +const mocks = vi.hoisted(() => ({ + freezeMutateAsync: vi.fn(), + unfreezeMutateAsync: vi.fn(), + directSendMutateAsync: vi.fn(), + setError: vi.fn(), + walletInfoRefetch: vi.fn(), + walletInfo: { + jars: [] as Jar[], + refetch: vi.fn(), + }, +})) + +// `useFidelityBondSweep` only talks to the network through these two hooks — +// mock at that boundary and let the hook's own orchestration logic run for real. +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise }) => ({ + mutateAsync: options.mutationFn, + isPending: false, + })), +})) + +vi.mock('./useFidelityBondMutations', () => ({ + useFidelityBondMutations: () => ({ + freezeUtxo: { mutateAsync: mocks.freezeMutateAsync }, + unfreezeUtxo: { mutateAsync: mocks.unfreezeMutateAsync }, + directSend: { mutateAsync: mocks.directSendMutateAsync }, + error: undefined, + setError: mocks.setError, + }), +})) + +vi.mock('@/context/JamWalletInfoContext', async (importOriginal) => ({ + ...(await importOriginal()), + useJamWalletInfoContext: () => mocks.walletInfo, +})) + +vi.mock('@/lib/utils', async (importOriginal) => ({ + ...(await importOriginal()), + delayedPromise: vi.fn(() => Promise.resolve()), +})) + +const utxo = (overrides: Partial): Utxo => ({ + utxo: 'tx:0', + address: 'bcrt1qsource', + path: "m/84'/1'/0'/0/0", + label: '', + value: 100_000, + tries: 0, + tries_remaining: 3, + external: false, + mixdepth: 0, + confirmations: 6, + frozen: false, + locktime: undefined, + ...overrides, +}) + +const bondUtxo = (overrides: Partial): FidelityBondUtxo => ({ + ...utxo({ utxo: 'bond:0', frozen: true, ...overrides }), + locktime: '2999-01-01 00:00:00', +}) + +const jar = (jarIndex: number, utxos: Utxo[]): Jar => + ({ + jarIndex, + name: `Jar ${jarIndex}`, + color: '#808080', + balanceSummary: BALANCE_SUMMARY_EMPTY, + utxos, + }) as unknown as Jar + +describe('useFidelityBondSweep', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.freezeMutateAsync.mockResolvedValue(undefined) + mocks.unfreezeMutateAsync.mockResolvedValue(undefined) + mocks.walletInfoRefetch.mockResolvedValue(undefined) + mocks.walletInfo.refetch = mocks.walletInfoRefetch + }) + + it('re-freezes the fidelity bond after a failed sweep that had unfrozen it', async () => { + const bond = bondUtxo({ mixdepth: 0 }) + const other = utxo({ utxo: 'other:0', mixdepth: 0, frozen: false }) + mocks.walletInfo.jars = [jar(0, [bond, other])] + mocks.directSendMutateAsync.mockRejectedValue(new Error('broadcast failed')) + + const { result } = renderHook(() => + useFidelityBondSweep({ + walletFileName: 'wallet.jmdat', + utxo: bond, + unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos', + sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond', + }), + ) + + const txResult = await result.current.sweep({ + destination: 'bcrt1qdestination', + tryFreezeAfterBroadcast: false, + }) + + expect(txResult).toBeUndefined() + + // the other jar utxo was frozen then correctly rolled back + expect(mocks.freezeMutateAsync).toHaveBeenCalledWith({ + path: { walletname: 'wallet.jmdat' }, + body: { 'utxo-string': 'other:0', freeze: true }, + }) + expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ body: { 'utxo-string': 'other:0', freeze: false } }), + ) + + // the bond itself was unfrozen before the failed send, and MUST be re-frozen in rollback + expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith({ + path: { walletname: 'wallet.jmdat' }, + body: { 'utxo-string': 'bond:0', freeze: false }, + }) + expect(mocks.freezeMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }), + ) + }) + + it('does not attempt to re-freeze the bond on failure when it was never unfrozen', async () => { + const bond = bondUtxo({ mixdepth: 0, frozen: false }) + mocks.walletInfo.jars = [jar(0, [bond])] + mocks.directSendMutateAsync.mockRejectedValue(new Error('broadcast failed')) + + const { result } = renderHook(() => + useFidelityBondSweep({ + walletFileName: 'wallet.jmdat', + utxo: bond, + unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos', + sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond', + }), + ) + + await result.current.sweep({ destination: 'bcrt1qdestination', tryFreezeAfterBroadcast: false }) + + expect(mocks.unfreezeMutateAsync).not.toHaveBeenCalled() + expect(mocks.freezeMutateAsync).not.toHaveBeenCalledWith( + expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }), + ) + }) + + it('sweeps successfully: freezes other utxos, unfreezes the bond, then restores the others', async () => { + const bond = bondUtxo({ mixdepth: 0 }) + const other = utxo({ utxo: 'other:0', mixdepth: 0, frozen: false }) + mocks.walletInfo.jars = [jar(0, [bond, other])] + mocks.directSendMutateAsync.mockResolvedValue({ txinfo: { txid: 'renewed-tx' } }) + + const { result } = renderHook(() => + useFidelityBondSweep({ + walletFileName: 'wallet.jmdat', + utxo: bond, + unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos', + sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond', + }), + ) + + const txResult = await result.current.sweep({ + destination: 'bcrt1qdestination', + tryFreezeAfterBroadcast: false, + }) + + expect(txResult).toEqual({ txinfo: { txid: 'renewed-tx' } }) + expect(mocks.directSendMutateAsync).toHaveBeenCalledWith({ + path: { walletname: 'wallet.jmdat' }, + body: { mixdepth: 0, amount_sats: 0, destination: 'bcrt1qdestination' }, + }) + // the other utxo ends up unfrozen again post-broadcast + expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ body: { 'utxo-string': 'other:0', freeze: false } }), + ) + expect(mocks.walletInfoRefetch).toHaveBeenCalled() + }) + + it('does not re-freeze the bond if the sweep broadcast but the refetch after it fails', async () => { + const bond = bondUtxo({ mixdepth: 0 }) + mocks.walletInfo.jars = [jar(0, [bond])] + mocks.directSendMutateAsync.mockResolvedValue({ txinfo: { txid: 'renewed-tx' } }) + mocks.walletInfoRefetch.mockRejectedValue(new Error('refetch failed')) + + const { result } = renderHook(() => + useFidelityBondSweep({ + walletFileName: 'wallet.jmdat', + utxo: bond, + unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos', + sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond', + }), + ) + + const txResult = await result.current.sweep({ + destination: 'bcrt1qdestination', + tryFreezeAfterBroadcast: false, + }) + + expect(txResult).toEqual({ txinfo: { txid: 'renewed-tx' } }) + expect(mocks.freezeMutateAsync).not.toHaveBeenCalledWith( + expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }), + ) + }) +}) diff --git a/src/components/earn/fidelity-bond/useFidelityBondSweep.ts b/src/components/earn/fidelity-bond/useFidelityBondSweep.ts index 0605e704..80c877e8 100644 --- a/src/components/earn/fidelity-bond/useFidelityBondSweep.ts +++ b/src/components/earn/fidelity-bond/useFidelityBondSweep.ts @@ -1,8 +1,10 @@ import { useMemo } from 'react' import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' +import { useMutation } from '@tanstack/react-query' +import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY } from '@/constants/jam' import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext' import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos' -import type { WalletFileName } from '@/lib/utils' +import { delayedPromise, type WalletFileName } from '@/lib/utils' import { useFidelityBondMutations } from './useFidelityBondMutations' /** @@ -22,13 +24,13 @@ export function useFidelityBondSweep({ unfreezeErrorKey: string sendErrorKey: string }) { - const walletInfo = useJamWalletInfoContext() + const { jars, refetch: walletInfoRefetch } = useJamWalletInfoContext() const { freezeUtxo, unfreezeUtxo, directSend, error, setError } = useFidelityBondMutations({ unfreezeErrorKey, sendErrorKey, }) - const sourceJar = walletInfo.jars.find((jar) => jar.jarIndex === utxo.mixdepth) + const sourceJar = jars.find((jar) => jar.jarIndex === utxo.mixdepth) // UTXOs in the source jar that are NOT this FB — they need to be frozen during sweep const utxosToFreeze = useMemo(() => { @@ -41,69 +43,130 @@ export function useFidelityBondSweep({ * before the frozen UTXOs are restored. Returns false when the sweep failed * (after a best-effort rollback). */ - const sweep = async (destination: string, onBroadcast: (result: DirectSendResponse) => void): Promise => { - setError(undefined) + const { isPending, mutateAsync: sweep } = useMutation({ + mutationFn: async ({ + destination, + tryFreezeAfterBroadcast, + onBroadcastSuccess, + }: { + destination: string + tryFreezeAfterBroadcast: boolean + onBroadcastSuccess?: (result: DirectSendResponse) => Promise + }): Promise => { + setError(undefined) - const frozen: Utxo[] = [] - try { - // Freeze other UTXOs in the source jar so only the FB gets swept - for (const u of utxosToFreeze) { - await freezeUtxo.mutateAsync({ - path: { walletname: walletFileName }, - body: { 'utxo-string': u.utxo, freeze: true }, - }) - frozen.push(u) - } + const frozen: Utxo[] = [] + let bondWasUnfrozen = false + let sweepBroadcasted = false - if (utxo.frozen) { - await unfreezeUtxo.mutateAsync({ - path: { walletname: walletFileName }, - body: { 'utxo-string': utxo.utxo, freeze: false }, - }) - } + try { + // Freeze other UTXOs in the source jar so only the FB gets swept + for (const u of utxosToFreeze) { + await freezeUtxo.mutateAsync({ + path: { walletname: walletFileName }, + body: { 'utxo-string': u.utxo, freeze: true }, + }) + frozen.push(u) + } - const result = await directSend.mutateAsync({ - path: { walletname: walletFileName }, - body: { - mixdepth: utxo.mixdepth, - amount_sats: 0, - destination, - }, - }) - - onBroadcast(result) - - // Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure - for (const u of frozen) { - try { + if (utxo.frozen) { await unfreezeUtxo.mutateAsync({ path: { walletname: walletFileName }, - body: { 'utxo-string': u.utxo, freeze: false }, + body: { 'utxo-string': utxo.utxo, freeze: false }, }) - } catch { - // logged via onError + bondWasUnfrozen = true + } + + const result = await directSend.mutateAsync({ + path: { walletname: walletFileName }, + body: { + mixdepth: utxo.mixdepth, + amount_sats: 0, // 0 := sweep! + destination, + }, + }) + sweepBroadcasted = true + + // Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure + for (const u of frozen) { + try { + await unfreezeUtxo.mutateAsync({ + path: { walletname: walletFileName }, + body: { 'utxo-string': u.utxo, freeze: false }, + throwOnError: true, + }) + } catch (_ignoredOnPurpose: unknown) { + // only debug output + console.debug('Error while unfreezing previously frozen UTXO.') + } + } + + if (tryFreezeAfterBroadcast) { + try { + await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY) + + const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output + await freezeUtxo.mutateAsync({ + path: { walletname: walletFileName }, + body: { 'utxo-string': fbUtxoId, freeze: true }, + throwOnError: true, + }) + } catch (_ignoredOnPurpose: unknown) { + console.warn('Error while freezing Fidelity Bond UTXO - continuing.') + } } - } - await walletInfo.refetch() - return true - } catch { - // Best-effort rollback — unfreeze UTXOs that were frozen before the error - for (const u of frozen) { try { - await unfreezeUtxo.mutateAsync({ - path: { walletname: walletFileName }, - body: { 'utxo-string': u.utxo, freeze: false }, - }) - } catch { - // logged via onError + await onBroadcastSuccess?.(result) + } catch (error: unknown) { + console.warn('onBroadcastSuccess failed', error) + } + + return result + } catch (_ignoredOnPurpose: unknown) { + // Best-effort rollback — unfreeze UTXOs that were frozen before the error + for (const u of frozen) { + try { + await unfreezeUtxo.mutateAsync({ + path: { walletname: walletFileName }, + body: { 'utxo-string': u.utxo, freeze: false }, + throwOnError: true, + }) + } catch (_ignoredOnPurpose: unknown) { + // only debug output + console.debug('Error while unfreezing previously frozen UTXO in error handling.') + } + } + + // re-freeze the bond if it was unfrozen before the error. skip once the + // sweep already broadcast, the bond utxo is spent by then + if (bondWasUnfrozen && !sweepBroadcasted) { + try { + await freezeUtxo.mutateAsync({ + path: { walletname: walletFileName }, + body: { 'utxo-string': utxo.utxo, freeze: true }, + throwOnError: true, + }) + } catch (_ignoredOnPurpose: unknown) { + console.debug('Error while re-freezing fidelity bond UTXO in error handling.') + } + } + + return undefined + } finally { + // refetch only if the sweep broadcast, and don't let a refetch error + // get treated as a sweep failure + if (sweepBroadcasted) { + try { + await walletInfoRefetch() + } catch (error: unknown) { + console.warn('Error while refetching wallet info after sweep.', error) + } } } - return false - } - } + }, + retry: false, + }) - const isLoading = freezeUtxo.isPending || unfreezeUtxo.isPending || directSend.isPending - - return { sweep, isLoading, error, setError, sourceJar } + return { sweep, isLoading: isPending, error, setError, sourceJar } } diff --git a/src/components/earn/report/EarnReportContent.test.tsx b/src/components/earn/report/EarnReportContent.test.tsx index 63a6139a..11955426 100644 --- a/src/components/earn/report/EarnReportContent.test.tsx +++ b/src/components/earn/report/EarnReportContent.test.tsx @@ -118,18 +118,42 @@ describe('EarnReportContent', () => { vi.restoreAllMocks() }) - it('renders loading and empty report states', () => { + it('renders loading and report states', () => { mocks.isLoading = true + mocks.isRefetching = false + mocks.entries = [] const { rerender } = render() expect(screen.getByText('spinner')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'global.refresh' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'global.download' })).not.toBeInTheDocument() mocks.isLoading = false + mocks.isRefetching = true mocks.entries = [] rerender() expect(screen.getByText('earn.alert_empty_report')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u })).toBeDisabled() + expect(screen.getByRole('button', { name: 'global.refresh' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'global.download' })).toBeDisabled() + + mocks.isLoading = false + mocks.isRefetching = false + mocks.entries = [] + rerender() + + expect(screen.queryByText('earn.alert_empty_report')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'global.refresh' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'global.download' })).toBeDisabled() + + mocks.isLoading = false + mocks.isRefetching = false + mocks.entries = [entry({ earnedAmount: 100, notes: 'first maker note' })] + rerender() + + expect(screen.queryByText('earn.alert_empty_report')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'global.refresh' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'global.download' })).toBeEnabled() }) it('summarizes, filters, refreshes, paginates, and exports report rows', async () => { @@ -152,37 +176,78 @@ describe('EarnReportContent', () => { expect(screen.getByText('chart:3')).toBeInTheDocument() expect(screen.getByText('balance:600')).toBeInTheDocument() expect(screen.getAllByText('balance:300').length).toBeGreaterThan(0) - expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":3\}/u)).toBeInTheDocument() + expect(screen.getByText('earn.report.text_report_summary:{"count":3}')).toBeInTheDocument() + expect(screen.queryByText(/earn\.report\.text_report_summary_filtered:/u)).not.toBeInTheDocument() expect(screen.getByText('first maker note')).toBeInTheDocument() fireEvent.change(screen.getByPlaceholderText('earn.report.placeholder_search'), { target: { value: 'second' }, }) - expect(screen.getByText(/earn\.report\.text_report_summary_filtered/u)).toBeInTheDocument() + + expect(screen.queryByText(/earn\.report\.text_report_summary:/u)).not.toBeInTheDocument() + expect(screen.getByText('earn.report.text_report_summary_filtered:{"count":1}')).toBeInTheDocument() expect(screen.queryByText('first maker note')).not.toBeInTheDocument() expect(screen.getByText('second maker note')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u })) + const downloadButton = screen.getByRole('button', { name: 'global.download' }) + expect(downloadButton).toBeEnabled() + + fireEvent.click(downloadButton) expect(mocks.createObjectURL).toHaveBeenCalled() expect(anchorClick).toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'first-page' })) fireEvent.click(screen.getByRole('button', { name: 'show-all' })) fireEvent.click(screen.getByRole('button', { name: 'show-page-size' })) - fireEvent.click(screen.getByRole('button', { name: '' })) + + const refreshButton = screen.getByRole('button', { name: 'global.refresh' }) + expect(refreshButton).toBeEnabled() + + fireEvent.click(refreshButton) await waitFor(() => expect(mocks.refetch).toHaveBeenCalled()) createElement.mockRestore() }) + it('exports all filtered rows regardless of pagination', async () => { + mocks.entries = Array.from({ length: 30 }, (_, index) => + entry({ + notes: `maker note ${index + 1}`, + timestamp: new Date(Date.UTC(2026, 0, index + 1, 12)), + }), + ) + + render() + + expect(screen.getByText('pagination:30')).toBeInTheDocument() + expect(screen.getAllByText(/maker note/u)).toHaveLength(25) + + fireEvent.click(screen.getByRole('button', { name: 'global.download' })) + + const blob = mocks.createObjectURL.mock.calls[0]?.[0] + expect(blob).toBeInstanceOf(Blob) + if (!blob) { + throw new Error('CSV blob was not created') + } + + const csv = await blob.text() + const lines = csv.trim().split('\n') + + expect(lines).toHaveLength(31) + expect(lines[1]).toContain('2026-01-30T12:00:00.000Z') + expect(lines.at(-1)).toContain('2026-01-01T12:00:00.000Z') + }) + it('adds demo rows in developer mode', () => { mocks.developerMode = true render() + expect(screen.getByText('dev-badge')).toBeInTheDocument() + expect(screen.getByText('earn.report.text_report_summary:{"count":3}')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_generate_demo_report/u })) - expect(screen.getByText('dev-badge')).toBeInTheDocument() - expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":4\}/u)).toBeInTheDocument() + expect(screen.getByText('earn.report.text_report_summary:{"count":4}')).toBeInTheDocument() }) }) diff --git a/src/components/earn/report/EarnReportContent.tsx b/src/components/earn/report/EarnReportContent.tsx index cc45759c..6e6cf12b 100644 --- a/src/components/earn/report/EarnReportContent.tsx +++ b/src/components/earn/report/EarnReportContent.tsx @@ -11,14 +11,14 @@ import { type SortingState, useReactTable, } from '@tanstack/react-table' -import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon } from 'lucide-react' +import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon, XIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { DevBadge } from '@/components/dev/DevBadge' import { useQueryYieldgenReport, type EarnReportEntry } from '@/components/earn/report/hooks/useQueryYieldgenReport' import { Alert, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' -import { Input } from '@/components/ui/input' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group' import { Balance } from '@/components/ui/jam/Balance' import { SortIcon } from '@/components/ui/jam/SortIcon' import { TablePagination } from '@/components/ui/jam/TablePagination' @@ -144,11 +144,11 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps }, [isShowAll, allEntries.length, table]) const visibleRows = table.getRowModel().rows + const exportRows = table.getSortedRowModel().rows - // Export visible entries as CSV const downloadCsv = useCallback(() => { const header = 'timestamp,cj_amount,input_count,input_amount,fee,earned,confirm_minutes,notes' - const rows = visibleRows.map((row) => + const rows = exportRows.map((row) => [ row.original.timestamp.toISOString(), row.original.cjTotalAmount ?? '', @@ -170,7 +170,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps setTimeout(() => { URL.revokeObjectURL(url) }, 0) - }, [visibleRows]) + }, [exportRows]) const earnedTotal = useMemo(() => sumEarned(allEntries, BITCOIN_GENESIS_DATE), [allEntries]) @@ -212,23 +212,46 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps {/* Toolbar: search + refresh */} -
-
- - table.setGlobalFilter(event.target.value)} - className="pl-8" - /> +
+
+ + table.setGlobalFilter(event.target.value)} + placeholder={t('earn.report.placeholder_search')} + /> + + + + + + + + + +
- - {globalFilter === '' @@ -236,7 +259,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps : t('earn.report.text_report_summary_filtered', { count: table.getFilteredRowModel().rows.length })} {isDeveloperMode ? ( - - )} -
+ + + -
{/* Search match count */} diff --git a/src/components/logging/useJmwalletdStdoutLog.ts b/src/components/logging/useJmwalletdStdoutLog.ts index 692cc041..ba49de08 100644 --- a/src/components/logging/useJmwalletdStdoutLog.ts +++ b/src/components/logging/useJmwalletdStdoutLog.ts @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { useStore } from 'zustand' import { Alert } from '@/components/ui/alert' +import * as JAM from '@/constants/jam' import { fetchLog } from '@/lib/api/jam' import { getErrorReason } from '@/lib/errorReason' import { authStore } from '@/store/authStore' @@ -38,7 +39,7 @@ export function useJmwalletdStdoutLog({ enabled = true }: UseJmwalletdStdoutLogP refetchInterval: (query) => { if (!enabled || token === undefined) return false if (query.state.error) return false - return 2_500 + return JAM.JMWALLETD_LOGS_POLLING_INTERVAL }, // Keep previous data during refetch to prevent content flicker on slower networks. placeholderData: (previousData) => previousData, diff --git a/src/components/login/LoginCard.tsx b/src/components/login/LoginCard.tsx index 934ddeca..410f8096 100644 --- a/src/components/login/LoginCard.tsx +++ b/src/components/login/LoginCard.tsx @@ -1,9 +1,10 @@ import { useState, type ComponentProps } from 'react' -import { RefreshCwIcon, WalletIcon } from 'lucide-react' +import { LanguagesIcon, RefreshCwIcon, WalletIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { LanguageSelectorDropdownMenu } from '@/components/ui/jam/LanguageSelector' import { WalletLoadErrorAlert } from '@/components/ui/jam/WalletLoadErrorAlert' import { Skeleton } from '@/components/ui/skeleton' import { Spinner } from '@/components/ui/spinner' @@ -71,22 +72,36 @@ export const LoginCard = ({ <> -
- {listWalletsFetching ? ( - - ) : ( +
+
+ + + + - )} +
{t('login.title')} {listWalletsLoading ? ( @@ -103,7 +118,7 @@ export const LoginCard = ({ ) : null} - + {listWalletsError ? ( <> diff --git a/src/components/orderbook/OrderbookContent.tsx b/src/components/orderbook/OrderbookContent.tsx index e6c9893b..cd36028d 100644 --- a/src/components/orderbook/OrderbookContent.tsx +++ b/src/components/orderbook/OrderbookContent.tsx @@ -18,6 +18,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { Balance } from '@/components/ui/jam/Balance' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -36,7 +37,6 @@ import { } from '@/lib/utils' import { useDeveloperMode } from '@/store/jamSettingsStore' import { jmSessionStore } from '@/store/jmSessionStore' -import { Balance } from '../ui/jam/Balance' import { Spinner } from '../ui/spinner' import { OrderbookChart } from './OrderbookChart' import { OrderbookTable, type OrderTableEntry } from './OrderbookTable' @@ -87,7 +87,7 @@ const offerToTableEntry = ( maximumSize: String(offer.maxsize || 0), bondValue: { value: offer.fidelity_bond_value || 0, - displayValue: String((offer.fidelity_bond_value || 0).toFixed(0)), + displayValue: Math.floor(offer.fidelity_bond_value || 0).toLocaleString(), locktime: fidelityBond?.locktime, displayLocktime: fidelityBond?.locktime ? new Date(fidelityBond.locktime * 1_000).toDateString() : undefined, displayExpiresIn: fidelityBond?.locktime diff --git a/src/components/orderbook/OrderbookTable.tsx b/src/components/orderbook/OrderbookTable.tsx index f4e1289c..41203c41 100644 --- a/src/components/orderbook/OrderbookTable.tsx +++ b/src/components/orderbook/OrderbookTable.tsx @@ -116,16 +116,19 @@ export const OrderbookTable = ({ columnHelper.accessor<'type', OrderTableEntry['type']>('type', { header: () =>
{t('orderbook.table.heading_type')}
, sortingFn: (a, b) => a.original.type.displayValue.localeCompare(b.original.type.displayValue), - cell: (info) => ( - - - {info.getValue().displayValue} - - -

{info.getValue().tooltip}

-
-
- ), + cell: (info) => { + const value = info.getValue() + return ( + + + {value.displayValue} + + +

{value.tooltip}

+
+
+ ) + }, meta: { align: 'center', alphabetic: true, @@ -140,12 +143,18 @@ export const OrderbookTable = ({ } return a.original.fee.value - b.original.fee.value }, - cell: (info) => { - const entry = info.row.original - return entry.fee.displayValue.includes('%') ? ( - {entry.fee.displayValue} + cell: ({ + row: { + original: { + type: { isAbsolute }, + fee, + }, + }, + }) => { + return isAbsolute ? ( + {fee.displayValue} ) : ( - + ) }, meta: { @@ -184,26 +193,29 @@ export const OrderbookTable = ({ columnHelper.accessor('bondValue', { header: () =>
{t('orderbook.table.heading_bond_value')}
, sortingFn: (a, b) => a.original.bondValue.value - b.original.bondValue.value, - cell: (info) => { - const entry = info.row.original - return entry.bondValue.value > 0 ? ( + cell: ({ + row: { + original: { bondValue }, + }, + }) => { + return bondValue.value > 0 ? ( - {entry.bondValue.displayValue} + {bondValue.displayValue}
- - {entry.bondValue.displayLocktime && ( + + {bondValue.displayLocktime && (
- {entry.bondValue.displayLocktime} ({entry.bondValue.displayExpiresIn}) + {bondValue.displayLocktime} ({bondValue.displayExpiresIn})
)}
) : ( - <>{entry.bondValue.displayValue} + <>{bondValue.displayValue} ) }, meta: { diff --git a/src/components/receive/ReceiveForm.test.tsx b/src/components/receive/ReceiveForm.test.tsx index 5eba191d..9e04c219 100644 --- a/src/components/receive/ReceiveForm.test.tsx +++ b/src/components/receive/ReceiveForm.test.tsx @@ -19,8 +19,8 @@ vi.mock('@/context/JamWalletInfoContext', () => ({ })) vi.mock('@/components/ui/jam/SelectableJar', () => ({ - SelectableJar: ({ name, isSelected, onClick }: { name: string; isSelected: boolean; onClick: () => void }) => ( - ), diff --git a/src/components/receive/ReceiveForm.tsx b/src/components/receive/ReceiveForm.tsx index f3be268f..2ed14ade 100644 --- a/src/components/receive/ReceiveForm.tsx +++ b/src/components/receive/ReceiveForm.tsx @@ -91,7 +91,7 @@ export const ReceiveForm = ({ className, defaultValues, onSubmit, jars, disabled > {t('receive.label_source_jar')} -
+
{jars.map((jar, index) => ( { + onSelect={() => { setValue('source.fromJar', jar.jarIndex, { shouldValidate: true }) }} disabled={disabled || isSubmitting} diff --git a/src/components/receive/ReceivePage.test.tsx b/src/components/receive/ReceivePage.test.tsx index b355513e..1d637b4d 100644 --- a/src/components/receive/ReceivePage.test.tsx +++ b/src/components/receive/ReceivePage.test.tsx @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => { return { developerMode: false, + rescanning: false, getAddress: vi.fn(), defaultJars, jars: defaultJars, @@ -107,6 +108,12 @@ vi.mock('@/context/JamWalletInfoContext', () => ({ }), })) +vi.mock('@/context/JamSessionInfoContext', () => ({ + useRescanStatus: () => ({ + rescanInfo: { rescanning: mocks.rescanning }, + }), +})) + vi.mock('@/hooks/useApiClient', () => ({ useApiClient: () => ({}), })) @@ -148,6 +155,7 @@ vi.mock('./ReceiveForm', () => ({ describe('ReceivePage', () => { beforeEach(() => { mocks.developerMode = false + mocks.rescanning = false mocks.jars = mocks.defaultJars mocks.getAddress.mockReset() mocks.getAddress.mockResolvedValue({ data: { address: 'bc1qexample' } }) @@ -175,6 +183,17 @@ describe('ReceivePage', () => { await flushActUpdates() }) + it('prevents loading addresses while rescanning', () => { + mocks.rescanning = true + + render() + + expect(screen.getByRole('button', { name: 'receive.button_reveal_address' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'receive.button_new_address' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'receive.button_new_address' })) + expect(mocks.getAddress).not.toHaveBeenCalled() + }) + it('uses a secondary jar badge when no jar is available', () => { mocks.jars = [] diff --git a/src/components/receive/ReceivePage.tsx b/src/components/receive/ReceivePage.tsx index 9975902e..9b378a58 100644 --- a/src/components/receive/ReceivePage.tsx +++ b/src/components/receive/ReceivePage.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import PageTitle from '@/components/ui/jam/PageTitle' import { Skeleton } from '@/components/ui/skeleton' +import { useRescanStatus } from '@/context/JamSessionInfoContext' import { useJars } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' import { withMutationDelay } from '@/lib/queryClient' @@ -34,6 +35,7 @@ interface ReceivePageProps { export const ReceivePage = ({ walletFileName }: ReceivePageProps) => { const { t } = useTranslation() const { jars } = useJars() + const { rescanInfo } = useRescanStatus() const [selectedSourceJarIndex, setSelectedSourceJarIndex] = useState(jars.length > 0 ? jars[0].jarIndex : undefined) const [amount, setAmount] = useState() @@ -67,17 +69,18 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => { mutationKey: ['receive', ...getaddressQueryKey(getAddressOptions)], mutationFn: withMutationDelay( async () => { + const sourceJarIndex = Number.parseInt(getAddressOptions.path.mixdepth, 10) const { data } = await getaddress({ ...getAddressOptions, throwOnError: true, }) return { address: data.address, - sourceJarIndex: Number.parseInt(getAddressOptions.path.mixdepth, 10), + sourceJarIndex, } }, { - delayAfter: 21, + throttle: 210, }, ), retry: false, @@ -107,6 +110,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => { } const fetchNewAddress = async () => { + if (rescanInfo.rescanning) return await getAddressMutation.mutateAsync() } @@ -132,7 +136,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => { variant={jarButtonVariant(selectedSourceJarIndex)} size="lg" onClick={() => void fetchNewAddress()} - disabled={getAddressMutation.isPending} + disabled={getAddressMutation.isPending || rescanInfo.rescanning} > {t('receive.button_reveal_address')} @@ -189,7 +193,11 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
- + + + + + ) +} diff --git a/src/components/send/JarSelectorDialog.schema.test.ts b/src/components/send/JarSelectorDialog.schema.test.ts new file mode 100644 index 00000000..245a483c --- /dev/null +++ b/src/components/send/JarSelectorDialog.schema.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { createJarSelectorDialogFormSchema } from './JarSelectorDialog.schema' + +describe('createJarSelectorDialogFormSchema', () => { + const schema = createJarSelectorDialogFormSchema([0, 2]) + + it('accepts a selectable jar index', async () => { + await expect(schema.validate({ jarIndex: 2 })).resolves.toEqual({ jarIndex: 2 }) + }) + + it.each([{ jarIndex: 1 }, { jarIndex: undefined }])('rejects an unavailable jar index', async (values) => { + await expect(schema.isValid(values)).resolves.toBe(false) + }) +}) diff --git a/src/components/send/JarSelectorDialog.schema.ts b/src/components/send/JarSelectorDialog.schema.ts new file mode 100644 index 00000000..65acf39c --- /dev/null +++ b/src/components/send/JarSelectorDialog.schema.ts @@ -0,0 +1,15 @@ +import * as yup from 'yup' +import type { JarIndex } from '@/types/global' + +export type JarSelectorDialogFormValues = { + jarIndex: JarIndex +} + +export const createJarSelectorDialogFormSchema = ( + selectableJarIndexes: JarIndex[], +): yup.ObjectSchema => + yup + .object({ + jarIndex: yup.number().oneOf(selectableJarIndexes).required(), + }) + .required() diff --git a/src/components/send/JarSelectorDialog.test.tsx b/src/components/send/JarSelectorDialog.test.tsx index d0527787..6d86673a 100644 --- a/src/components/send/JarSelectorDialog.test.tsx +++ b/src/components/send/JarSelectorDialog.test.tsx @@ -11,16 +11,16 @@ vi.mock('react-i18next', () => ({ vi.mock('../ui/jam/SelectableJar', () => ({ SelectableJar: ({ name, - onClick, + onSelect, disabled, isSelected, }: { name?: string - onClick?: () => void + onSelect?: () => void disabled?: boolean isSelected?: boolean }) => ( - ), @@ -94,20 +94,23 @@ describe('JarSelectorDialog', () => { expect(screen.queryByText('Pick a jar')).not.toBeInTheDocument() }) - it('disables the confirm button until a jar is selected', () => { + it('disables the confirm button until a jar is selected', async () => { renderDialog() - const confirm = screen.getByText('modal.confirm_button_accept').closest('button') + const confirm = screen.getByRole('button', { name: 'modal.confirm_button_accept' }) expect(confirm).toBeDisabled() fireEvent.click(screen.getByText('Jar 0')) - expect(confirm).not.toBeDisabled() + await waitFor(() => expect(confirm).not.toBeDisabled()) }) it('confirms the selected jar', async () => { const onConfirm = vi.fn().mockResolvedValue(undefined) renderDialog({ onConfirm }) fireEvent.click(screen.getByText('Jar 0')) - fireEvent.click(screen.getByText('modal.confirm_button_accept')) + const confirm = screen.getByRole('button', { name: 'modal.confirm_button_accept' }) + await waitFor(() => expect(confirm).not.toBeDisabled()) + fireEvent.click(confirm) await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(0)) + await waitFor(() => expect(confirm).toBeDisabled()) }) it('closes and resets via the reject button', () => { diff --git a/src/components/send/JarSelectorDialog.tsx b/src/components/send/JarSelectorDialog.tsx index 1ae72b40..fd8e8206 100644 --- a/src/components/send/JarSelectorDialog.tsx +++ b/src/components/send/JarSelectorDialog.tsx @@ -1,4 +1,6 @@ -import { useState, type ComponentProps } from 'react' +import { useMemo, type ComponentProps } from 'react' +import { yupResolver } from '@hookform/resolvers/yup' +import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import type { Jar } from '@/context/JamWalletInfoContext' import type { BalanceSummary } from '@/lib/balanceSummary' @@ -7,6 +9,7 @@ import { Button } from '../ui/button' import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter, DialogHeader } from '../ui/dialog' import { SelectableJar } from '../ui/jam/SelectableJar' import { Spinner } from '../ui/spinner' +import { createJarSelectorDialogFormSchema, type JarSelectorDialogFormValues } from './JarSelectorDialog.schema' type JarSelectorDialogProps = WithRequiredProperty< Omit, 'children'>, @@ -33,73 +36,100 @@ export default function JarSelectorDialog({ }: JarSelectorDialogProps) { const { t } = useTranslation() - const [isConfirming, setIsConfirming] = useState(false) - const [selectedJar, setSelectedJar] = useState() + const selectableJarIndexes = useMemo( + () => jars.filter((jar) => !disabledJars.includes(jar)).map((jar) => jar.jarIndex), + [disabledJars, jars], + ) + const schema = useMemo(() => createJarSelectorDialogFormSchema(selectableJarIndexes), [selectableJarIndexes]) + const { + control, + handleSubmit, + reset, + setValue, + formState: { isSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues: { + jarIndex: undefined, + }, + resolver: yupResolver(schema), + }) + const selectedJarIndex = useWatch({ control, name: 'jarIndex' }) + const selectedJar = jars.find((jar) => jar.jarIndex === selectedJarIndex) + const canConfirm = + selectedJarIndex !== undefined && selectedJar !== undefined && selectableJarIndexes.includes(selectedJarIndex) const handleClose = () => { - setSelectedJar(undefined) + reset() onOpenChange(false) } - const confirm = async () => { - if (selectedJar === undefined) return - - setIsConfirming(true) - try { - await onConfirm(selectedJar.jarIndex) - setSelectedJar(undefined) - } finally { - setIsConfirming(false) - } - } + const doOnSubmit = handleSubmit(async ({ jarIndex }) => { + await onConfirm(jarIndex) + reset() + }) return ( - - {title} - {subtitle && {subtitle}} - +
void doOnSubmit(event)} className="grid gap-4" noValidate> + + {title} + {subtitle && {subtitle}} + -
-
- {jars.map((jar, index) => ( - setSelectedJar(jar)} - disabled={disabledJars.includes(jar)} - /> - ))} +
+
+ {jars.map((jar) => ( + + setValue('jarIndex', jar.jarIndex, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } + disabled={disabledJars.includes(jar)} + /> + ))} +
-
- - - - + + + + +
) diff --git a/src/components/send/PaymentConfirmDialog.test.tsx b/src/components/send/PaymentConfirmDialog.test.tsx index f3b2962f..cf6c04ce 100644 --- a/src/components/send/PaymentConfirmDialog.test.tsx +++ b/src/components/send/PaymentConfirmDialog.test.tsx @@ -27,7 +27,7 @@ vi.mock('../ui/jam/Address', () => ({ Address: ({ value }: { value: string }) => address:{value}, })) -vi.mock('../ui/jam/Balance', () => ({ +vi.mock('@/components/ui/jam/Balance', () => ({ Balance: ({ valueString }: { valueString: string }) => balance:{valueString}, })) @@ -50,6 +50,7 @@ const sourceJar: Jar = { calculatedAvailableBalanceInSats: 50_000, calculatedTotalBalanceInSats: 50_000, calculatedConfirmedAvailableBalanceInSats: 50_000, + calculatedAvailableFrozenBalanceInSats: 0, calculatedFrozenOrLockedBalanceInSats: 0, }, color: '#e2b86a', diff --git a/src/components/send/PaymentConfirmDialog.tsx b/src/components/send/PaymentConfirmDialog.tsx index 5e455a8d..462beaba 100644 --- a/src/components/send/PaymentConfirmDialog.tsx +++ b/src/components/send/PaymentConfirmDialog.tsx @@ -3,6 +3,7 @@ import { InfoIcon } from 'lucide-react' import { Trans, useTranslation } from 'react-i18next' import { Badge } from '@/components/ui/badge' import { jarBadgeVariant } from '@/components/ui/badge-variants' +import { Balance } from '@/components/ui/jam/Balance' import type { Jar } from '@/context/JamWalletInfoContext' import type { Utxo } from '@/hooks/useQueryUtxos' import type { JamFeeConfigValues } from '@/lib/feeConfig' @@ -13,7 +14,6 @@ import { Button } from '../ui/button' import { Card, CardContent, CardHeader } from '../ui/card' import { Dialog, DialogTitle, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '../ui/dialog' import { Address } from '../ui/jam/Address' -import { Balance } from '../ui/jam/Balance' import { Spinner } from '../ui/spinner' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { estimateMaxCollaboratorFee, useMiningFeeText, type EstimateMaxCollaboratorFeeResult } from './feeEstimate' diff --git a/src/components/send/SendCoinjoinPreconditionAlert.tsx b/src/components/send/SendCoinjoinPreconditionAlert.tsx index b73670e1..3a470de9 100644 --- a/src/components/send/SendCoinjoinPreconditionAlert.tsx +++ b/src/components/send/SendCoinjoinPreconditionAlert.tsx @@ -1,84 +1,10 @@ -import { AlertTriangleIcon } from 'lucide-react' -import { Trans, useTranslation } from 'react-i18next' import type { SweepPreconditionSummary } from '@/components/sweep/preconditions' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam' -import { formatSats, shortenStringMiddle } from '@/lib/utils' +import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert' interface SendCoinjoinPreconditionAlertProps { summary: SweepPreconditionSummary } -const RetryLockedUtxoList = ({ summary }: SendCoinjoinPreconditionAlertProps) => { - const { t } = useTranslation() - - if (summary.retryLockedUtxos.length === 0) { - return null - } - - return ( - <> - , - , - ]} - /> -
-
{t('global.utxos')}
-
    - {summary.retryLockedUtxos.map((utxo) => ( -
  • - jar {utxo.mixdepth} - {formatSats(utxo.value)} sats - {shortenStringMiddle(utxo.utxo, 26)} -
  • - ))} -
-
- - ) -} - export const SendCoinjoinPreconditionAlert = ({ summary }: SendCoinjoinPreconditionAlertProps) => { - const { t } = useTranslation() - - if (summary.isFulfilled) { - return null - } - - if (summary.numberOfMissingUtxos > 0) { - return ( - - - - {t('send.coinjoin_precondition.hint_missing_utxos', { minConfirmations: summary.options.minConfirmations })} - - - ) - } - - if (summary.numberOfMissingConfirmations > 0) { - return ( - - - - {t('send.coinjoin_precondition.hint_missing_confirmations', { - minConfirmations: summary.options.minConfirmations, - amountOfMissingConfirmations: summary.numberOfMissingConfirmations, - })} - - - ) - } - - return ( - - - - - - - ) + return } diff --git a/src/components/send/SendForm.schema.test.ts b/src/components/send/SendForm.schema.test.ts index b748c523..4a457e62 100644 --- a/src/components/send/SendForm.schema.test.ts +++ b/src/components/send/SendForm.schema.test.ts @@ -1,7 +1,10 @@ import { Network } from 'bitcoin-address-validation' import type { TFunction } from 'i18next' import { describe, expect, it, vi } from 'vitest' +import { JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS, JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS } from '@/constants/jam' import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext' +import type { Utxo } from '@/hooks/useQueryUtxos' +import { toBalanceSummary } from '@/lib/balanceSummary' import { TX_FEE_UNITS, toJamFeeConfigValues } from '@/lib/feeConfig' import { createSendFormSchema, @@ -10,32 +13,58 @@ import { toSendFormDefaultValues, } from './SendForm.schema' -const t = vi.fn((key: string) => key) as unknown as TFunction<'translation', undefined> +const t = vi.fn((key: string, options?: Record) => + options ? `${key}:${JSON.stringify(options)}` : key, +) as unknown as TFunction<'translation', undefined> -const validMainnetAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT' -const sourceJarAddress = '1CounterpartyXXXXXXXXXXXXXXXUWLpVr' +const validMainnetAddress = '1BitcoinEaterAddressDontSend8MUo1T' +const validRegtestAddress = 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' +const sourceJarAddress = '1BitcoinEaterAddressDontSendDHyNcX' + +const utxo = (overrides: Partial): Utxo => ({ + utxo: 'tx:0', + address: 'bcrt1qsource', + path: "m/84'/1'/0'/0/0", + label: '', + value: 100_000, + tries: 0, + tries_remaining: 3, + external: false, + mixdepth: 0, + confirmations: 6, + frozen: false, + locktime: undefined, + ...overrides, +}) + +const fbUtxo = (overrides: Partial) => + utxo({ + locktime: '2999-01-01 00:00:00', + path: "m/84'/1'/0'/2/0:32503680000", + ...overrides, + }) + +const jar = (jarIndex: number, utxos: Utxo[]): Jar => + ({ + jarIndex, + name: `Jar ${jarIndex}`, + color: '#808080', + balanceSummary: toBalanceSummary(utxos), + utxos, + }) as unknown as Jar const jars = [ - { - jarIndex: 0, - balanceSummary: { - calculatedAvailableBalanceInSats: 10_000, - }, - }, - { - jarIndex: 1, - balanceSummary: { - calculatedAvailableBalanceInSats: 0, - }, - }, + jar(0, [utxo({ value: 10_000 })]), + jar(1, []), + jar(2, [utxo({ value: 21_000, frozen: true })]), + jar(3, [ + utxo({ value: 42_000 }), + fbUtxo({ utxo: 'non-frozen-fb:0', value: 21_000, frozen: false }), + fbUtxo({ utxo: 'non-frozen-fb:1', value: 29_000, frozen: false }), + ]), ] as Jar[] const addressSummary = { - [validMainnetAddress]: { - address: validMainnetAddress, - jarIndex: 2, - used: false, - }, [sourceJarAddress]: { address: sourceJarAddress, jarIndex: 0, @@ -81,22 +110,48 @@ describe('toSendFormDefaultValues', () => { }) describe('createSendFormSchema', () => { - const schema = createSendFormSchema(jars, addressSummary, 3, Network.mainnet, t) + const schema = createSendFormSchema( + jars, + addressSummary, + JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS, + Network.mainnet, + t, + ) it('accepts a valid coinjoin send', async () => { await expect(schema.validate(validFormValues)).resolves.toMatchObject(validFormValues) }) - it('rejects unavailable source jars', async () => { + it('rejects source jars with zero balance', async () => { await expect( schema.validate({ ...validFormValues, - source: { fromJar: 1 }, + source: { fromJar: jars[1].jarIndex }, amount: { isSweep: true, sweepAmount: MIN_SEND_AMOUNT }, }), ).rejects.toThrow('send.feedback_invalid_source_jar') }) + it('rejects source jars without unfrozen balance', async () => { + await expect( + schema.validate({ + ...validFormValues, + source: { fromJar: jars[2].jarIndex }, + amount: { isSweep: true, sweepAmount: MIN_SEND_AMOUNT }, + }), + ).rejects.toThrow('send.feedback_invalid_source_jar_must_unfreeze_utxos') + }) + + it('rejects source jars without unfrozen fidelity bonds', async () => { + await expect( + schema.validate({ + ...validFormValues, + source: { fromJar: jars[3].jarIndex }, + amount: { isSweep: true, sweepAmount: MIN_SEND_AMOUNT }, + }), + ).rejects.toThrow('send.feedback_invalid_source_jar_must_freeze_fidelity_bond:{"count":2}') + }) + it('rejects reused or source-jar destination addresses', async () => { await expect( schema.validate({ @@ -131,13 +186,30 @@ describe('createSendFormSchema', () => { destination: { address: sourceJarAddress }, }), ).rejects.toThrow('send.feedback_address_from_source_jar') + + await expect( + schema.validate({ + ...validFormValues, + destination: { address: validRegtestAddress }, + }), + ).rejects.toThrow('send.feedback_destination_network_mismatch') + + await expect( + schema.validate({ + ...validFormValues, + destination: { address: 'invalid' }, + }), + ).rejects.toThrow('send.feedback_invalid_destination_address') }) it('rejects invalid amounts and clears collaborators for direct sends', async () => { await expect( schema.validate({ ...validFormValues, - amount: { isSweep: false, amount: 11_000 }, + amount: { + isSweep: false, + amount: jars[validFormValues.source.fromJar].balanceSummary.calculatedTotalBalanceInSats + 1, + }, }), ).rejects.toThrow('send.feedback_amount_exceeds_balance') @@ -145,7 +217,7 @@ describe('createSendFormSchema', () => { schema.validate({ ...validFormValues, isCoinJoin: false, - numCollaborators: 4, + numCollaborators: 10, }), ).resolves.toMatchObject({ isCoinJoin: false, @@ -153,6 +225,29 @@ describe('createSendFormSchema', () => { }) }) + it('rejects invalid number of collaborators', async () => { + await expect( + schema.validate({ + ...validFormValues, + numCollaborators: JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS - 1, + }), + ).rejects.toThrow('send.error_invalid_num_collaborators:{"minNumCollaborators":4,"maxNumCollaborators":20}') + + await expect( + schema.validate({ + ...validFormValues, + numCollaborators: JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS + 1, + }), + ).rejects.toThrow('send.error_invalid_num_collaborators:{"minNumCollaborators":4,"maxNumCollaborators":20}') + + await expect( + schema.validate({ + ...validFormValues, + numCollaborators: null, + }), + ).rejects.toThrow('send.error_invalid_num_collaborators:{"minNumCollaborators":4,"maxNumCollaborators":20}') + }) + it('supports sweep sends with a sweep amount', async () => { await expect( schema.validate({ diff --git a/src/components/send/SendForm.schema.ts b/src/components/send/SendForm.schema.ts index 9b353799..e3972151 100644 --- a/src/components/send/SendForm.schema.ts +++ b/src/components/send/SendForm.schema.ts @@ -5,6 +5,7 @@ import { MAX_NUM_COLLABORATORS, TOTAL_COIN_SUPPLY } from '@/constants/jam' import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext' import { TX_FEE_UNITS } from '@/lib/feeConfig' import type { JamFeeConfigValues } from '@/lib/feeConfig' +import * as fb from '@/lib/fidelityBondUtils' import { destinationAddressField, sourceJarField } from '@/lib/formValidation' import { isValidInteger, pseudoRandomInteger } from '@/lib/utils' import type { AmountSats } from '@/types/global' @@ -21,6 +22,8 @@ export const initialNumberOfCollaborators = (minValue: number): number => { export const MIN_SEND_AMOUNT: AmountSats = 292 export const MAX_SEND_AMOUNT: AmountSats = TOTAL_COIN_SUPPLY +export const ERROR_SOURCE_JAR_NEEDS_MANUAL_COIN_CONTROL = 'valid-source-jar-needs-manual-coin-control-test' + const FORM_INPUT_DEFAULT_VALUES: Partial = { source: undefined, destination: undefined, @@ -64,11 +67,28 @@ export const createSendFormSchema = ( .object({ source: yup .object({ - fromJar: sourceJarField( - t('send.feedback_invalid_source_jar'), - (jarIndex) => - (jars.find((it) => it.jarIndex === jarIndex)?.balanceSummary.calculatedAvailableBalanceInSats || 0) > 0, - ), + fromJar: sourceJarField(t('send.feedback_invalid_source_jar'), (jarIndex) => + jars.some((it) => it.jarIndex === jarIndex), + ) + .test( + ERROR_SOURCE_JAR_NEEDS_MANUAL_COIN_CONTROL, + t('send.feedback_invalid_source_jar_must_unfreeze_utxos'), + (jarIndex) => { + const jar = jars.find((it) => it.jarIndex === jarIndex) + if (!jar) return true + if (jar.balanceSummary.calculatedAvailableBalanceInSats > 0) return true + return jar.balanceSummary.calculatedAvailableFrozenBalanceInSats <= 0 + }, + ) + .test( + 'valid-source-jar-has-funds-test', + t('send.feedback_invalid_source_jar_no_available_utxos'), + (jarIndex) => { + const jar = jars.find((it) => it.jarIndex === jarIndex) + if (!jar) return true + return jar.balanceSummary.calculatedAvailableBalanceInSats > 0 + }, + ), }) .required(), destination: yup @@ -137,7 +157,26 @@ export const createSendFormSchema = ( // eslint-disable-next-line unicorn/prefer-spread -- false positive .concat(createTxFeeFormSchema({ t })) .required() - .test('address-not-from-source-jar-test', function (root) { + .test((root) => { + const jar = jars.find((it) => it.jarIndex === root.source.fromJar) + const unfrozenFidelityBondOutputs = (jar?.utxos ?? []) + .filter((it) => fb.utxo.isFidelityBond(it)) + .filter((it) => it.frozen !== true) + + if (unfrozenFidelityBondOutputs.length === 0) return true + + const errorMessage = t('send.feedback_invalid_source_jar_must_freeze_fidelity_bond', { + count: unfrozenFidelityBondOutputs.length, + }) + return new yup.ValidationError( + errorMessage, + root.destination.address, + 'source.fromJar', + ERROR_SOURCE_JAR_NEEDS_MANUAL_COIN_CONTROL, + true, + ) + }) + .test((root) => { // Note: `fromJar` might still be `undefined` at this point if (root.source.fromJar === undefined) return true const addressIsFromSourceJar = addressSummary[root.destination.address]?.jarIndex === root.source.fromJar @@ -145,9 +184,15 @@ export const createSendFormSchema = ( const errorMessage = t('send.feedback_address_from_source_jar') - return new yup.ValidationError(errorMessage, root.destination.address, 'destination.address', undefined, true) + return new yup.ValidationError( + errorMessage, + root.destination.address, + 'destination.address', + 'address-not-from-source-jar-test', + true, + ) }) - .test('amount-exceeds-balance-test', function (root) { + .test((root) => { if (root.amount.isSweep) return true if (root.amount.amount === undefined || root.amount.amount === null) return true @@ -161,7 +206,7 @@ export const createSendFormSchema = ( t('send.feedback_amount_exceeds_balance'), root.amount.amount, 'amount.amount', - undefined, + 'amount-exceeds-balance-test', true, ) }) diff --git a/src/components/send/SendForm.test.tsx b/src/components/send/SendForm.test.tsx index bda8c888..aedc0406 100644 --- a/src/components/send/SendForm.test.tsx +++ b/src/components/send/SendForm.test.tsx @@ -18,9 +18,22 @@ const h = vi.hoisted(() => { }, toastSuccess: vi.fn<(message: string) => void>(), toastError: vi.fn<(message: string) => void>(), + hasOrders: true, + orderbookIsLoading: false, + orderbookError: false, } }) +vi.mock('@/hooks/useQueryOrderbook', () => ({ + useQueryOrderbook: () => ({ + hasOrders: h.hasOrders, + queryResult: { + isLoading: h.orderbookIsLoading, + isError: h.orderbookError, + }, + }), +})) + vi.mock('sonner', () => ({ toast: { success: (message: string) => { @@ -33,11 +46,21 @@ vi.mock('sonner', () => ({ })) vi.mock('react-i18next', () => ({ + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, useTranslation: () => ({ t: (key: string, options?: Record) => key + (options ? ' ' + JSON.stringify(options) : ''), }), })) +vi.stubGlobal( + 'ResizeObserver', + class ResizeObserver { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + }, +) + vi.mock('@/context/JamWalletInfoContext', () => ({ useDetectNetwork: () => ({ network: 'mainnet' }), })) @@ -55,13 +78,13 @@ vi.mock('../ui/jam/Address', () => ({ Address: () =>
, })) -vi.mock('../ui/jam/Balance', () => ({ +vi.mock('@/components/ui/jam/Balance', () => ({ Balance: ({ valueString }: { valueString?: string }) =>
{valueString}
, })) vi.mock('../ui/jam/SelectableJar', () => ({ - SelectableJar: ({ name, onClick, disabled }: { name?: string; onClick?: () => void; disabled?: boolean }) => ( - ), @@ -156,6 +179,9 @@ describe('SendForm', () => { h.getaddressResult = { data: { address: h.DEFAULT_NEW_DUMMY_ADDRESS_0 }, error: undefined } h.toastSuccess = vi.fn<(message: string) => void>() h.toastError = vi.fn<(message: string) => void>() + h.hasOrders = true + h.orderbookIsLoading = false + h.orderbookError = false }) it('renders the core fields', () => { @@ -286,7 +312,7 @@ describe('SendForm', () => { await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('global.errors.error_loading_address_failed')) }) - it('applies a pasted bip21 uri', async () => { + it('applies a pasted bip21 uri and shows success toast', async () => { renderForm() const inputBefore = document.querySelector('#send-destination') as HTMLInputElement @@ -303,7 +329,7 @@ describe('SendForm', () => { await flushActUpdates() }) - it('ignores a non-bitcoin paste', () => { + it('ignores a non-bitcoin-uri paste', () => { renderForm() const input = document.querySelector('#send-destination') as HTMLInputElement @@ -312,6 +338,18 @@ describe('SendForm', () => { expect(h.toastSuccess).not.toHaveBeenCalled() }) + it('applies pasted address and does show success toast', () => { + renderForm() + const inputBefore = document.querySelector('#send-destination') as HTMLInputElement + + fireEvent.paste(inputBefore, { clipboardData: { getData: () => h.DEFAULT_SCAN_DUMMY_ADDRESS_1 } }) + + const inputAfter = document.querySelector('#send-destination') as HTMLInputElement + expect(inputAfter).toHaveValue(h.DEFAULT_SCAN_DUMMY_ADDRESS_1) + + expect(h.toastSuccess).not.toHaveBeenCalled() + }) + it('renders the debug panel when debug is enabled', () => { renderForm({ debug: true }) expect(screen.getByText('isValid:')).toBeInTheDocument() @@ -356,4 +394,49 @@ describe('SendForm', () => { await flushActUpdates() }) + + it('shows empty orderbook warning when collaborative send is selected and orderbook is empty', () => { + h.hasOrders = false + renderForm() + + expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument() + }) + + it('does not show empty orderbook warning when orderbook is loading or has error', () => { + h.hasOrders = false + h.orderbookIsLoading = true + + const { rerender } = renderForm() + expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument() + + h.orderbookIsLoading = false + h.orderbookError = true + rerender( + , + ) + expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument() + }) + + it('does not show empty orderbook warning when non-collaborative send is selected', () => { + h.hasOrders = false + renderForm() + + expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument() + + const optionsAccordion = screen.getByRole('button', { name: /send.sending_options/i }) + fireEvent.click(optionsAccordion) + + const collaborativeSwitch = document.querySelector('#switch-is-collaborative-transaction') + expect(collaborativeSwitch).toBeInTheDocument() + fireEvent.click(collaborativeSwitch!) + + expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument() + }) }) diff --git a/src/components/send/SendForm.tsx b/src/components/send/SendForm.tsx index ba69dbc7..d102b020 100644 --- a/src/components/send/SendForm.tsx +++ b/src/components/send/SendForm.tsx @@ -5,15 +5,16 @@ import { getAddressInfo, Network } from 'bitcoin-address-validation' import type { AddressInfo } from 'bitcoin-address-validation' import { AlertTriangleIcon, BrushCleaningIcon, MilkIcon, ScanQrCodeIcon, XIcon } from 'lucide-react' import { FormProvider, useForm, useWatch } from 'react-hook-form' -import type { Resolver, SubmitHandler } from 'react-hook-form' +import type { FieldErrors, Resolver, SubmitHandler } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import QrScannerDialog from '@/components/ui/QrScannerDialog' -import { isDevMode } from '@/constants/debugFeatures' +import { Balance } from '@/components/ui/jam/Balance' import { MAX_NUM_COLLABORATORS } from '@/constants/jam' import { JM_MINIMUM_MAKERS_DEFAULT, JM_TAKER_UTXO_AGE } from '@/constants/jm' import { useDetectNetwork, type AddressSummary, type Jar } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' +import { useQueryOrderbook } from '@/hooks/useQueryOrderbook' import type { BalanceSummary } from '@/lib/balanceSummary' import { parseBip21Uri, type Bip21ParseResult } from '@/lib/bip21' import type { JamFeeConfigValues } from '@/lib/feeConfig' @@ -31,8 +32,8 @@ import { Field, FieldDescription, FieldError, FieldLabel } from '../ui/field' import { Input } from '../ui/input' import { inputVariants } from '../ui/input-variants' import { Address } from '../ui/jam/Address' -import { Balance } from '../ui/jam/Balance' import { SatSymbol } from '../ui/jam/CurrencySymbol' +import { OrderbookEmptyAlert } from '../ui/jam/OrderbookEmptyAlert' import { SelectableJar } from '../ui/jam/SelectableJar' import { Slider } from '../ui/slider' import { Spinner } from '../ui/spinner' @@ -94,12 +95,6 @@ const AddressFromJarSelectorDialog = ({ ) } -// set the default to one collaborator in dev mode -const DEV_INITIAL_NUM_COLLABORATORS_INPUT = 1 - -// TODO: this value should be dynamic via jm backend settings -const MIN_NUM_COLLABORATORS = isDevMode() ? DEV_INITIAL_NUM_COLLABORATORS_INPUT : JM_MINIMUM_MAKERS_DEFAULT - const FieldPrefixSatSymbol = ( onSourceJarChange?: (jarIndex: JarIndex | undefined) => void - sourceJarLabelButton?: React.ReactElement + onSourceJarClicked?: (jarIndex: JarIndex | undefined) => void + sourceJarLabelButton?: (errors?: FieldErrors<{ fromJar: number }>) => React.ReactElement minNumberOfCollaborators?: number feeConfigValues: JamFeeConfigValues walletFileName: WalletFileName @@ -129,11 +125,12 @@ export function SendForm({ className, onSubmit, onSourceJarChange, + onSourceJarClicked, sourceJarLabelButton, disabled, feeConfigValues, walletFileName, - minNumberOfCollaborators = MIN_NUM_COLLABORATORS, + minNumberOfCollaborators = JM_MINIMUM_MAKERS_DEFAULT, jars, walletBalanceSummary, addressSummary, @@ -187,6 +184,16 @@ export function SendForm({ const isCoinJoin = useWatch({ control, name: 'isCoinJoin' }) const collaboratorCount = useWatch({ control, name: 'numCollaborators' }) + const { + hasOrders, + queryResult: { + isLoading: orderbookCheckIsLoading, + isFetching: orderbookCheckIsFetching, + isError: orderbookCheckIsError, + refetch: orderbookRefetch, + }, + } = useQueryOrderbook() + const destinationAddressInfo = useMemo(() => { try { return destinationAddress !== undefined ? getAddressInfo(destinationAddress) : undefined @@ -204,6 +211,15 @@ export function SendForm({ onSourceJarChange?.(sourceJarIndex) }, [onSourceJarChange, sourceJarIndex]) + useEffect(() => { + if (formState.dirtyFields.source?.fromJar) { + console.debug( + 'Validating `source.fromJar` as wallet balance summary changed (e.g. frozen/unfrozen utxos, new transaction, etc.)', + ) + void trigger('source.fromJar') + } + }, [walletBalanceSummary, trigger, formState.dirtyFields.source?.fromJar]) + const destinationJar = useMemo(() => { if (destinationJarIndex === undefined) return return jars.find((it) => it.jarIndex === destinationJarIndex) @@ -259,7 +275,9 @@ export function SendForm({ event.preventDefault() applyBip21Result(parsed) - toast.success(t('send.qr_scan_bip21_applied')) + if (parsed.fromUri) { + toast.success(t('send.qr_scan_bip21_applied')) + } }, [applyBip21Result, t], ) @@ -298,47 +316,67 @@ export function SendForm({ {...sendFormMethods} >
void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate> -
- +
+
{t('send.label_source_jar')} - {sourceJarLabelButton && <>{sourceJarLabelButton}} + {sourceJarLabelButton?.(errors.source)}
-
- {jars.map((jar, index) => ( - { - setValue('source.fromJar', jar.jarIndex, { shouldValidate: true }) +
+ {jars.map((jar, index) => { + const noFunds = + jar.balanceSummary.calculatedAvailableBalanceInSats <= 0 && + jar.balanceSummary.calculatedAvailableFrozenBalanceInSats <= 0 - if (isSweep === true) { - setValue('amount.isSweep', false, { shouldValidate: true }) - setValue('amount.sweepAmount', undefined, { shouldValidate: true }) - setValue('amount.amount', undefined, { shouldValidate: true }) - } - if (destinationJarIndex === jar.jarIndex) { - setValue('destination.address', '', { shouldValidate: true }) - setValue('destination.fromJar', undefined, { shouldValidate: true }) - } else if (destinationAddress !== undefined) { - void trigger('destination.address') - } - }} - disabled={disabled || jar.balanceSummary.calculatedAvailableBalanceInSats <= 0} - /> - ))} + const isSelected = sourceJarIndex === jar.jarIndex + + return ( + { + if (isSelected) { + onSourceJarClicked?.(sourceJarIndex) + } + }} + onSelect={() => { + setValue('source.fromJar', jar.jarIndex, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + + if (isSweep === true) { + setValue('amount.isSweep', false, { shouldValidate: true }) + setValue('amount.sweepAmount', undefined, { shouldValidate: true }) + setValue('amount.amount', undefined, { shouldValidate: true }) + } + if (destinationJarIndex === jar.jarIndex) { + setValue('destination.address', '', { shouldValidate: true }) + setValue('destination.fromJar', undefined, { shouldValidate: true }) + } else if (destinationAddress !== undefined) { + void trigger('destination.address') + } + }} + disabled={disabled || noFunds} + /> + ) + })}
{errors.source?.fromJar?.message ? {errors.source?.fromJar.message} : null} {hasCoinjoinPreconditionWarning && coinjoinPreconditionSummary && ( )} + {isCoinJoin && !orderbookCheckIsLoading && !orderbookCheckIsError && !hasOrders && ( + + )}
diff --git a/src/components/send/SendPage.test.tsx b/src/components/send/SendPage.test.tsx index 5aa3bd87..10cc9b8e 100644 --- a/src/components/send/SendPage.test.tsx +++ b/src/components/send/SendPage.test.tsx @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ stopCoinjoinRefetch: vi.fn(), takerRunning: false, schedulerRunning: false, + makerRunning: false, toastError: vi.fn(), toastInfo: vi.fn(), toastSuccess: vi.fn(), @@ -33,6 +34,9 @@ const mocks = vi.hoisted(() => ({ walletInfoIsFetching: false, walletInfoIsLoading: false, waitForUtxosToBeSpent: [] as string[], + hasOrders: true, + orderbookIsLoading: false, + orderbookError: null as Error | null, })) vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({ @@ -72,11 +76,23 @@ vi.mock('@tanstack/react-query', () => ({ })) vi.mock('react-i18next', () => ({ + Trans: ({ i18nKey, values }: { i18nKey: string; values?: unknown }) => + values ? `${i18nKey}:${JSON.stringify(values)}` : i18nKey, useTranslation: () => ({ t: (key: string, options?: Record) => (options ? `${key}:${JSON.stringify(options)}` : key), }), })) +vi.mock('@/hooks/useQueryOrderbook', () => ({ + useQueryOrderbook: () => ({ + hasOrders: mocks.hasOrders, + queryResult: { + isLoading: mocks.orderbookIsLoading, + error: mocks.orderbookError, + }, + }), +})) + vi.mock('sonner', () => ({ toast: { error: mocks.toastError, @@ -145,6 +161,10 @@ vi.mock('@/store/jamSettingsStore', () => ({ useDeveloperMode: () => ({ enabled: true }), })) +vi.mock('@/context/JamDisplayContext', () => ({ + useJamDisplayContext: () => ({ addressChunkingEnabled: false }), +})) + vi.mock('@/context/JamSessionInfoContext', () => ({ useJamSessionInfoContext: () => ({ clearCurrentPaymentAttempt: mocks.clearCurrentPaymentAttempt, @@ -157,6 +177,9 @@ vi.mock('@/context/JamSessionInfoContext', () => ({ running: mocks.schedulerRunning, }, }, + makerInfo: { + running: mocks.makerRunning, + }, }), })) @@ -209,11 +232,11 @@ vi.mock('./SendForm', () => ({ disabled?: boolean onSourceJarChange?: (jarIndex: number | undefined) => void onSubmit: (values: SendFormValues) => void - sourceJarLabelButton?: React.ReactElement + sourceJarLabelButton?: () => React.ReactElement }) => (
send-form:{String(disabled)} -
{sourceJarLabelButton}
+
{sourceJarLabelButton?.()}
+
+ - - ) : ( - <> - {currentPaymentAttempt.utxosHashHex === utxosHashHex ? ( - - - {t('send.alert_collaborative_ended_title')} - -
{t('send.alert_collaborative_ended_description')}
-
- -
-
-
- ) : ( - - - {t('send.alert_collaborative_completed_title')} - -
{t('send.alert_collaborative_completed_description')}
-
- -
-
-
- )} - - )} - - ) + ) : ( + + + {t('send.alert_collaborative_completed_title')} + +
{t('send.alert_collaborative_completed_description')}
+
+ +
+
+
+ )} + + )} + + ) } - {takerRunning && !isWaitingCoinjoinStop && ( + + {schedulerRunning && ( {t('send.text_coinjoin_already_running')} - - {currentPaymentAttempt && ( -
-                  {JSON.stringify(
-                    {
-                      sourceJar: currentPaymentAttempt.data.source.fromJar,
-                      destinationJar: currentPaymentAttempt.data.destination.fromJar,
-                      destinationAddress: currentPaymentAttempt.data.destination.address,
-                      isSweep: currentPaymentAttempt.data.amount.isSweep === true,
-                      amount:
-                        currentPaymentAttempt.data.amount.isSweep === true
-                          ? currentPaymentAttempt.data.amount.sweepAmount
-                          : currentPaymentAttempt.data.amount.amount,
-                      numCollaborators: currentPaymentAttempt.data.numCollaborators,
-                    },
-                    null,
-                    2,
-                  )}
-                
- )} - {takerRunning && !schedulerRunning ? ( -
- -
- ) : null} -
)} + {takerRunning && !schedulerRunning && ( + setShowAbortCoinjoinDialog(true)} + /> + )} + {triggerNonCollaborativeTransaction.error ? ( @@ -579,7 +564,11 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { )} - + { addressSummary={addressSummary} walletBalanceSummary={walletBalanceSummary} disabled={ - jmSession?.maker_running === true || + makerRunning || takerRunning || rescanInfo.rescanning || isWaitingCoinjoinStart || @@ -604,17 +593,23 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { } debug={isDeveloperMode} onSourceJarChange={setSourceJarIndex} - sourceJarLabelButton={ - - } + onSourceJarClicked={onOpenUtxoSelector} + sourceJarLabelButton={(errors) => { + const errorSolvedByUtxoSelection = errors?.fromJar?.type === ERROR_SOURCE_JAR_NEEDS_MANUAL_COIN_CONTROL + const animate = utxoSelectionDialog.dialogProps.open === false && errorSolvedByUtxoSelection + return ( + + ) + }} /> diff --git a/src/components/send/UtxoSelectionDialog.schema.test.ts b/src/components/send/UtxoSelectionDialog.schema.test.ts new file mode 100644 index 00000000..e8b680ce --- /dev/null +++ b/src/components/send/UtxoSelectionDialog.schema.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { createUtxoSelectionFormSchema } from './UtxoSelectionDialog.schema' + +describe('createUtxoSelectionFormSchema', () => { + const schema = createUtxoSelectionFormSchema(['tx-a:0', 'tx-b:1']) + + it('accepts selectable UTXO rows and a filter', async () => { + const values = { + filter: 'confirmed', + rowSelection: { 'tx-a:0': true, 'tx-b:1': false }, + } + await expect(schema.validate(values)).resolves.toEqual(values) + }) + + it('allows clearing the selection', async () => { + await expect(schema.isValid({ filter: '', rowSelection: {} })).resolves.toBe(true) + }) + + it('rejects selected rows that are not available', async () => { + await expect(schema.isValid({ filter: '', rowSelection: { 'unknown:0': true } })).resolves.toBe(false) + }) +}) diff --git a/src/components/send/UtxoSelectionDialog.schema.ts b/src/components/send/UtxoSelectionDialog.schema.ts new file mode 100644 index 00000000..6ed25f30 --- /dev/null +++ b/src/components/send/UtxoSelectionDialog.schema.ts @@ -0,0 +1,32 @@ +import type { RowSelectionState } from '@tanstack/react-table' +import * as yup from 'yup' + +export type UtxoSelectionFormValues = { + filter: string + rowSelection: RowSelectionState +} + +export const UTXO_SELECTION_FORM_DEFAULT_VALUES: UtxoSelectionFormValues = { + filter: '', + rowSelection: {}, +} + +export const createUtxoSelectionFormSchema = ( + selectableUtxoIds: string[], +): yup.ObjectSchema => { + const selectableUtxoIdSet = new Set(selectableUtxoIds) + + return yup + .object({ + filter: yup.string().defined(), + rowSelection: yup + .mixed() + .defined() + .test('selectable-utxos', (value) => + Object.entries(value).every( + ([utxoId, selected]) => typeof selected === 'boolean' && (!selected || selectableUtxoIdSet.has(utxoId)), + ), + ), + }) + .required() +} diff --git a/src/components/send/UtxoSelectionDialog.tsx b/src/components/send/UtxoSelectionDialog.tsx index 4b3e2684..b28f1027 100644 --- a/src/components/send/UtxoSelectionDialog.tsx +++ b/src/components/send/UtxoSelectionDialog.tsx @@ -37,40 +37,49 @@ export const UtxoSelectionDialog = ({ return ( - - {t('show_utxos.title')} - - {t('show_utxos.subtitle', { count: selectedCount })} {t('show_utxos.text_subtitle_addon')} - - + { + event.preventDefault() + void onSubmit() + }} + noValidate + > + + {t('show_utxos.title')} + + {t('show_utxos.subtitle', { count: selectedCount })} {t('show_utxos.text_subtitle_addon')} + + - onFilterChange(event.target.value)} - placeholder={t('jar_details.utxo_list.placeholder_search')} - disabled={isSubmitting} - /> -
- onFilterChange(event.target.value)} + placeholder={t('jar_details.utxo_list.placeholder_search')} + disabled={isSubmitting} /> -
+
+ +
- - - - + + + + +
) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 699da46a..668ff9cc 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -26,6 +26,7 @@ import { withQueryDelay } from '@/lib/queryClient' import { cn, type WalletFileName } from '@/lib/utils' import { convertExtendedPublicKey } from '@/lib/xpubs' import type { JarIndex, Milliseconds, MnemonicPhrase, WithRequiredProperty } from '@/types/global' +import { JarBadge } from '../earn/fidelity-bond/FidelityBondDialogParts' import { Alert, AlertDescription, AlertTitle } from '../ui/alert' import { Badge } from '../ui/badge' import { buttonVariants } from '../ui/button-variants' @@ -173,9 +174,9 @@ export const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) => - {account.accountName} + {accountLabel} - {accountLabel} +
diff --git a/src/components/settings/SettingsItem.test.tsx b/src/components/settings/SettingsItem.test.tsx new file mode 100644 index 00000000..f2edd7e3 --- /dev/null +++ b/src/components/settings/SettingsItem.test.tsx @@ -0,0 +1,183 @@ +import { render, screen } from '@testing-library/react' +import user from '@testing-library/user-event' +import { KeyRoundIcon } from 'lucide-react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem' + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + open: vi.fn(), +})) + +vi.mock('react-router-dom', () => ({ + useNavigate: () => mocks.navigate, +})) + +vi.mock('@/components/ui/switch', () => ({ + Switch: ({ + checked, + disabled, + onCheckedChange, + }: { + checked?: boolean + disabled?: boolean + onCheckedChange?: (checked: boolean) => void + }) => ( + + ), +})) + +describe('SettingsItem', () => { + beforeEach(() => { + mocks.navigate.mockReset() + mocks.open.mockReset() + }) + + it('renders title and icon', () => { + render() + + expect(screen.getByText('settings.show_seed')).toBeInTheDocument() + }) + + it('exposes an actionable row as a button', () => { + render() + + expect(screen.getByRole('button', { name: 'settings.show_seed' })).toBeInTheDocument() + }) + + it('does not render a button without an action', () => { + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('is reachable by keyboard', async () => { + render() + + await user.tab() + + expect(screen.getByRole('button', { name: 'settings.show_seed' })).toHaveFocus() + }) + + it('triggers the action on Enter', async () => { + const action = vi.fn() + render() + + await user.tab() + await user.keyboard('{Enter}') + + expect(action).toHaveBeenCalledOnce() + }) + + it('triggers the action on Space', async () => { + const action = vi.fn() + render() + + await user.tab() + await user.keyboard(' ') + + expect(action).toHaveBeenCalledOnce() + }) + + it('triggers the action on click', async () => { + const action = vi.fn() + render() + + await user.click(screen.getByRole('button', { name: 'settings.show_seed' })) + + expect(action).toHaveBeenCalledOnce() + }) + + it('does not trigger the action while disabled', async () => { + const action = vi.fn() + render() + + const item = screen.getByRole('button', { name: 'settings.show_seed' }) + expect(item).toBeDisabled() + + await user.click(item) + await user.tab() + await user.keyboard('{Enter}') + + expect(action).not.toHaveBeenCalled() + }) + + it('stays a wrapper when children provide their own control', () => { + render( + + + , + ) + + // no button is nested inside another button + expect(screen.queryByRole('button', { name: 'settings.show_seed' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'child-control' })).toBeInTheDocument() + }) +}) + +describe('SettingsLink', () => { + beforeEach(() => { + mocks.navigate.mockReset() + }) + + it('navigates internally on Enter', async () => { + render() + + await user.tab() + await user.keyboard('{Enter}') + + expect(mocks.navigate).toHaveBeenCalledWith('/rescan') + }) + + it('opens external links in a new tab on Enter', async () => { + vi.stubGlobal('open', mocks.open) + render() + + await user.tab() + await user.keyboard('{Enter}') + + expect(mocks.open).toHaveBeenCalledWith('https://example.org', '_blank', 'noreferrer,noopener') + expect(mocks.navigate).not.toHaveBeenCalled() + }) +}) + +describe('SettingSwitch', () => { + it('is keyboard operable via the row when no toggle is displayed', async () => { + const onCheckedChange = vi.fn() + render( + , + ) + + await user.tab() + await user.keyboard('{Enter}') + + expect(onCheckedChange).toHaveBeenCalledWith(true) + }) + + it('is keyboard operable via the toggle when one is displayed', async () => { + const onCheckedChange = vi.fn() + render( + , + ) + + await user.tab() + expect(screen.getByRole('button', { name: 'switch:false' })).toHaveFocus() + + await user.keyboard('{Enter}') + expect(onCheckedChange).toHaveBeenCalledWith(true) + }) +}) diff --git a/src/components/settings/SettingsItem.tsx b/src/components/settings/SettingsItem.tsx index e92e70be..7c4ba1df 100644 --- a/src/components/settings/SettingsItem.tsx +++ b/src/components/settings/SettingsItem.tsx @@ -1,9 +1,9 @@ import type { PropsWithChildren, ReactNode } from 'react' -import { cx } from 'class-variance-authority' import { ExternalLinkIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { Switch } from '@/components/ui/switch' +import { cn } from '@/lib/utils' type SettingsItemProps = PropsWithChildren<{ icon?: LucideIcon @@ -11,24 +11,37 @@ type SettingsItemProps = PropsWithChildren<{ title: string disabled?: boolean action?: () => void | Promise + /** + * Set when {@link children} already renders its own focusable control (e.g. a `Switch`). + * The row then stays a plain wrapper instead of becoming a button, so a button is never + * nested inside a button - keyboard users operate the child control directly. + */ + hasInteractiveChild?: boolean }> -export const SettingItem = ({ +export const SettingsItem = ({ icon: Icon, renderIcon, title, action, disabled = false, + hasInteractiveChild = false, children, }: SettingsItemProps) => { + const rowClassName = cn('flex min-w-0 items-center justify-between gap-2 py-2', { + 'hover:bg-muted/50 cursor-pointer': !disabled && action, + 'rounded-md px-2': !disabled, + 'cursor-not-allowed opacity-60': disabled, + }) + // The row bleeds into the card padding so the hover background spans the full width. + // A block `div` with `width: auto` gets this from the negative margin alone, but a + // `button` does not stretch to its parent, and once `w-full` pins the width the + // negative margin no longer widens it. Keeping the bleed on a wrapper makes `w-full` + // resolve against the widened box, so both variants end up the same size. + const bleedClassName = !disabled ? '-mx-2' : undefined + const content = ( -
void action() : undefined} - > + <>
{Icon && } @@ -39,13 +52,34 @@ export const SettingItem = ({
{children !== undefined &&
{children}
} -
+ ) - return content + // A row that is itself the control must be a real button: the browser then provides + // focus, Enter/Space activation and the correct role for free. + if (action && !hasInteractiveChild) { + return ( +
+ +
+ ) + } + + return ( +
void action() : undefined}> + {content} +
+ ) } -type SettingsLinkProps = Omit & { +type SettingsLinkProps = Omit & { to: string external?: boolean } @@ -54,7 +88,7 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro const navigate = useNavigate() return ( - { if (external) { @@ -65,19 +99,22 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro }} > {external && } - +
) } -type SettingsSwitchProps = Omit & { +type SettingsSwitchProps = Omit & { checked?: boolean onCheckedChange?: (checked: boolean) => void displayToggle?: boolean } -export const SettingSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => { +export const SettingsSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => { return ( - onCheckedChange?.(!checked)}> + // With a visible toggle the `Switch` is the focusable control and the row stays a + // wrapper. Without one the row itself has to be the button, or the setting cannot be + // reached by keyboard at all. + onCheckedChange?.(!checked)} hasInteractiveChild={displayToggle}> {displayToggle && } - +
) } diff --git a/src/components/settings/SettingsPage.test.tsx b/src/components/settings/SettingsPage.test.tsx index 91c4684a..20e58cb3 100644 --- a/src/components/settings/SettingsPage.test.tsx +++ b/src/components/settings/SettingsPage.test.tsx @@ -59,6 +59,9 @@ vi.mock('next-themes', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ + i18n: { + resolvedLanguage: 'en', + }, t: (key: string) => key, }), })) @@ -123,7 +126,7 @@ vi.mock('@/components/settings/AccountXpubsDialog', () => ({ ) : null, })) -vi.mock('@/components/settings/LanguageSelector', () => ({ +vi.mock('@/components/ui/jam/LanguageSelector', () => ({ LanguageSelector: () =>
language-selector
, })) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 93190ee7..3fba6fd4 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -16,6 +16,9 @@ import { UnfoldHorizontalIcon, KeyRoundIcon, HandCoinsIcon, + SparklesIcon, + HistoryIcon, + LanguagesIcon, } from 'lucide-react' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' @@ -23,8 +26,10 @@ import { useNavigate, type NavigateFunction } from 'react-router-dom' import { useStore } from 'zustand' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol' +import { LanguageSelector } from '@/components/ui/jam/LanguageSelector' import PageTitle from '@/components/ui/jam/PageTitle' import { Separator } from '@/components/ui/separator' +import { Spinner } from '@/components/ui/spinner' import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures' import { JAM_DOCS_URL, JAM_MATRIX_URL, JAM_REPO_URL, JAM_SEED_MODAL_TIMEOUT, JAM_TELEGRAM_URL } from '@/constants/jam' import { routes } from '@/constants/routes' @@ -34,11 +39,9 @@ import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation' import { cn, type WalletFileName } from '@/lib/utils' import { authStore } from '@/store/authStore' import { jamSettingsStore } from '@/store/jamSettingsStore' -import { Spinner } from '../ui/spinner' import { AccountXpubsDialog } from './AccountXpubsDialog' -import { LanguageSelector } from './LanguageSelector' import { SeedPhraseDialog } from './SeedPhraseDialog' -import { SettingItem, SettingsLink, SettingSwitch } from './SettingsItem' +import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem' import { FeeConfigDialog } from './fees/FeeConfigDialog' interface SettingPageProps { @@ -79,7 +82,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) {t('settings.section_title_display')} - - } title={t(currency === 'btc' ? 'settings.use_btc' : 'settings.use_sats')} checked={currency === 'btc'} @@ -95,7 +98,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) displayToggle={false} /> - - - + + + @@ -125,7 +130,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) {t('settings.section_title_market')} - setShowFeeConfigDialog(true)} @@ -139,21 +144,21 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) {t('settings.section_title_wallet')} - setShowSeedDialog(true)} disabled={hashedPassword === undefined} /> - setShowXpubsDialog(true)} disabled={hashedPassword === undefined} /> - lockWalletMutation.isPending ? ( @@ -214,10 +219,39 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) to={JAM_REPO_URL} external={true} /> + + { + jamSettings.update({ previewFeatures: checked ? {} : undefined }) + }} + /> + {!!jamSettings.state.previewFeatures && ( + <> + + { + jamSettings.update({ + previewFeatures: { + ...jamSettings.state.previewFeatures, + 'tx-history': checked, + }, + }) + }} + /> + + )} {isDevMode() && ( <> - ({ - Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, + Trans: ({ i18nKey, values }: { i18nKey: string; values?: unknown }) => + values ? `${i18nKey}:${JSON.stringify(values)}` : i18nKey, useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, options?: Record) => (options ? `${key}:${JSON.stringify(options)}` : key), }), })) -const retryLockedUtxo = { +vi.mock('@/components/ui/jam/Balance', () => ({ + Balance: ({ valueString }: { valueString?: string }) => {valueString}sats, +})) + +const retryLockedUtxo: Utxo = { address: 'bc1qretry', confirmations: 12, frozen: false, @@ -20,18 +25,22 @@ const retryLockedUtxo = { locktime: undefined, mixdepth: 0, path: '', + tries: 3, + external: false, tries_remaining: 0, utxo: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:0', value: 123_456, -} as unknown as Utxo +} const makeSummary = (overrides: Partial = {}): SweepPreconditionSummary => ({ isFulfilled: false, numberOfMissingConfirmations: 0, numberOfMissingUtxos: 0, + numberOfNonFrozenFidelityBondOutputs: 0, options: { minConfirmations: 5, minNumberOfUtxos: 1, + maxNumberOfNonFrozenFidelityBondOutputs: 0, }, retryLockedUtxos: [], ...overrides, @@ -57,8 +66,8 @@ describe('precondition alerts', () => { , ) - expect(screen.getByText('send.coinjoin_precondition.hint_missing_utxos')).toBeInTheDocument() - expect(screen.getByText('scheduler.precondition.hint_missing_utxos')).toBeInTheDocument() + expect(screen.getByText('send.coinjoin_precondition.hint_missing_utxos:{"minConfirmations":5}')).toBeInTheDocument() + expect(screen.getByText('scheduler.precondition.hint_missing_utxos:{"minConfirmations":5}')).toBeInTheDocument() }) it('shows missing confirmation warnings for send and sweep flows', () => { @@ -71,8 +80,30 @@ describe('precondition alerts', () => { , ) - expect(screen.getByText('send.coinjoin_precondition.hint_missing_confirmations')).toBeInTheDocument() - expect(screen.getByText('scheduler.precondition.hint_missing_confirmations')).toBeInTheDocument() + expect( + screen.getByText( + 'send.coinjoin_precondition.hint_missing_confirmations:{"minConfirmations":5,"amountOfMissingConfirmations":3}', + ), + ).toBeInTheDocument() + expect( + screen.getByText( + 'scheduler.precondition.hint_missing_confirmations:{"minConfirmations":5,"amountOfMissingConfirmations":3}', + ), + ).toBeInTheDocument() + }) + + it('shows non-frozen fidelity bond warnings for send and sweep flows', () => { + const summary = makeSummary({ numberOfNonFrozenFidelityBondOutputs: 3 }) + + render( + <> + + + , + ) + + expect(screen.getByText('send.coinjoin_precondition.hint_non_frozen_fidelity_bond:{"count":3}')).toBeInTheDocument() + expect(screen.getByText('scheduler.precondition.hint_non_frozen_fidelity_bond:{"count":3}')).toBeInTheDocument() }) it('lists retry-locked utxos when retries are the remaining blocker', () => { @@ -88,7 +119,6 @@ describe('precondition alerts', () => { expect(screen.getByText('send.coinjoin_precondition.hint_missing_retries')).toBeInTheDocument() expect(screen.getByText('scheduler.precondition.hint_missing_retries')).toBeInTheDocument() expect(screen.getAllByText('global.utxos')).toHaveLength(2) - expect(screen.getAllByText('jar 0')).toHaveLength(2) - expect(screen.getAllByText('123,456 sats')).toHaveLength(2) + expect(screen.getAllByText('123456sats')).toHaveLength(2) }) }) diff --git a/src/components/sweep/SweepDestinationInputs.test.tsx b/src/components/sweep/SweepDestinationInputs.test.tsx index cfe7d400..f621e149 100644 --- a/src/components/sweep/SweepDestinationInputs.test.tsx +++ b/src/components/sweep/SweepDestinationInputs.test.tsx @@ -1,19 +1,35 @@ import { act, fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { SweepDestinationInputs } from './SweepDestinationInputs' import type { SweepFormValues } from './SweepFormSchema' type SweepForm = UseFormReturn type SweepFields = Array> +const h = vi.hoisted(() => { + const DEFAULT_NEW_DUMMY_ADDRESS_0 = 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' + return { + DEFAULT_NEW_DUMMY_ADDRESS_0, + toastSuccess: vi.fn<(message: string) => void>(), + } +}) + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: Record) => (options ? `${key} ${JSON.stringify(options)}` : key), }), })) +vi.mock('sonner', () => ({ + toast: { + success: (message: string) => { + h.toastSuccess(message) + }, + }, +})) + describe('SweepDestinationInputs', () => { const defaultFormMock = { formState: { @@ -30,6 +46,11 @@ describe('SweepDestinationInputs', () => { { id: '2', address: '' }, ] as unknown as SweepFields + beforeEach(() => { + vi.clearAllMocks() + h.toastSuccess = vi.fn<(message: string) => void>() + }) + it('renders input fields correctly', async () => { render( { ) const inputs = screen.getAllByRole('textbox') + expect(inputs).toHaveLength(fields.length) await act(async () => { await userEvent.click(inputs[0]) await userEvent.paste('anything') @@ -128,19 +150,33 @@ describe('SweepDestinationInputs', () => { await userEvent.click(inputs[0]) await userEvent.paste('bitcoin:') }) + expect(defaultFormMock.setValue).not.toHaveBeenCalled() + await act(async () => { + await userEvent.click(inputs[0]) + await userEvent.paste(h.DEFAULT_NEW_DUMMY_ADDRESS_0) + }) + + expect(defaultFormMock.setValue).toHaveBeenCalledWith(`destinations.0.address`, h.DEFAULT_NEW_DUMMY_ADDRESS_0, { + shouldValidate: true, + }) + + expect(h.toastSuccess).not.toHaveBeenCalled() + await act(async () => { await userEvent.click(inputs[1]) await userEvent.paste( 'bitcoin:bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk?amount=0.0021&label=order%20123&message=regtest', ) }) + expect(defaultFormMock.setValue).toHaveBeenCalledWith( `destinations.1.address`, 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk', { shouldValidate: true }, ) + expect(h.toastSuccess).toHaveBeenCalledWith('send.qr_scan_bip21_applied') }) it('adds additional destination inputs', () => { diff --git a/src/components/sweep/SweepDestinationInputs.tsx b/src/components/sweep/SweepDestinationInputs.tsx index 821e08c4..d0b2afbe 100644 --- a/src/components/sweep/SweepDestinationInputs.tsx +++ b/src/components/sweep/SweepDestinationInputs.tsx @@ -55,7 +55,9 @@ export const SweepDestinationInputs = ({ event.preventDefault() applyBip21Result(parsed, index) - toast.success(t('send.qr_scan_bip21_applied')) + if (parsed.fromUri) { + toast.success(t('send.qr_scan_bip21_applied')) + } }, [applyBip21Result, t], ) diff --git a/src/components/sweep/SweepForm.tsx b/src/components/sweep/SweepForm.tsx index 6a763fff..3a86088b 100644 --- a/src/components/sweep/SweepForm.tsx +++ b/src/components/sweep/SweepForm.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' import { yupResolver } from '@hookform/resolvers/yup' +import type { Network } from 'bitcoin-address-validation' import { AlertTriangleIcon } from 'lucide-react' import { useFieldArray, useForm, useWatch, type SubmitHandler } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -53,6 +54,7 @@ interface SweepFormProps { initialValues?: Partial jars: Jar[] addressSummary: AddressSummary + network: Network disabled?: boolean debug?: boolean } @@ -62,6 +64,7 @@ export const SweepForm = ({ onSubmit, jars, addressSummary, + network, initialValues, disabled, debug, @@ -82,10 +85,11 @@ export const SweepForm = ({ minNumberOfDestinations, maxNumberOfDestinations, addressSummary, + network, }, t, ), - [minNumberOfDestinations, maxNumberOfDestinations, addressSummary, t], + [minNumberOfDestinations, maxNumberOfDestinations, addressSummary, network, t], ) const { formState, reset, register, control, setValue, handleSubmit, trigger } = useForm< SweepFormValues, diff --git a/src/components/sweep/SweepFormSchema.test.ts b/src/components/sweep/SweepFormSchema.test.ts index a3094edc..9b365fcf 100644 --- a/src/components/sweep/SweepFormSchema.test.ts +++ b/src/components/sweep/SweepFormSchema.test.ts @@ -1,3 +1,4 @@ +import { Network } from 'bitcoin-address-validation' import type { TFunction } from 'i18next' import { describe, expect, it } from 'vitest' import type { AddressSummary } from '@/context/JamWalletInfoContext' @@ -12,13 +13,19 @@ import { const t = ((key: string) => key) as unknown as TFunction<'translation', undefined> const validRegtestAddress = 'bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx' +const validMainnetAddress = '1BitcoinEaterAddressDontSend8MUo1T' -const validate = async (values: SweepFormValues, addressSummary = {} as AddressSummary) => { +const validate = async ( + values: SweepFormValues, + addressSummary = {} as AddressSummary, + network: Network = Network.regtest, +) => { return await sweepFormSchema( { minNumberOfDestinations: 1, maxNumberOfDestinations: 5, addressSummary, + network, }, t, ).validate(values, { abortEarly: false }) @@ -49,6 +56,22 @@ describe('sweepFormSchema', () => { }) }) + it('rejects an address from the wrong network', async () => { + await expect( + validate({ + ...buildSweepFormValuesDefaultValues(), + destinations: [{ address: validMainnetAddress }], + }), + ).rejects.toMatchObject({ + inner: [ + expect.objectContaining({ + path: 'destinations[0].address', + message: 'scheduler.feedback_destination_network_mismatch', + }), + ], + }) + }) + it('rejects duplicate destination addresses', async () => { await expect( validate({ diff --git a/src/components/sweep/SweepFormSchema.ts b/src/components/sweep/SweepFormSchema.ts index e2ff18e0..380ccdb4 100644 --- a/src/components/sweep/SweepFormSchema.ts +++ b/src/components/sweep/SweepFormSchema.ts @@ -1,3 +1,4 @@ +import type { Network } from 'bitcoin-address-validation' import type { TFunction } from 'i18next' import * as yup from 'yup' import { @@ -15,7 +16,7 @@ import { } from '@/constants/jam' import { JM_NG_DEFAULT_TUMBLER_PARAMS, type TumblerParameters } from '@/constants/jm' import type { AddressSummary } from '@/context/JamWalletInfoContext' -import { isValidAddress } from '@/lib/formValidation' +import { isAddressOnNetwork, isValidAddress } from '@/lib/formValidation' import { factorToPercentage, isValidNumber, percentageToFactor, pseudoRandomInteger } from '@/lib/utils' import type { Seconds } from '@/types/global' import { buildDestinationErrors, normalizeDestinationAddresses } from './destinationValidation' @@ -115,14 +116,17 @@ export const sweepFormSchema = ( minNumberOfDestinations, maxNumberOfDestinations, addressSummary, + network, }: { minNumberOfDestinations: number maxNumberOfDestinations: number addressSummary: AddressSummary + network: Network }, t: TFunction<'translation', undefined>, ): yup.ObjectSchema => { const invalidDestinationAddressMessage = t('scheduler.feedback_invalid_destination_address') + const networkMismatchDestinationAddressMessage = t('scheduler.feedback_destination_network_mismatch') const invalidNumberOfDestinationsMessage = t('send.feedback_invalid_number_of_destination_addresses', { // TODO: i18n defaultValue: 'Please provide between {{ min }} and {{ max }} destination addresses.', @@ -137,20 +141,20 @@ export const sweepFormSchema = ( .of( yup .object({ - // TODO: use formValidation#destinationAddressField ? address: yup .string() .transform((_, originalValue: unknown) => typeof originalValue === 'string' ? normalizeDestinationAddresses([originalValue])[0] : '', ) .defined() - .test('valid-sweep-destination', invalidDestinationAddressMessage, function (value) { - if (!isValidAddress(value)) { - return false - } - - return true - }), + .test('valid-sweep-destination', invalidDestinationAddressMessage, (value) => isValidAddress(value)) + // Only run once the address itself is valid, so an invalid address surfaces a + // single, correct error instead of also reporting a network mismatch. + .test( + 'sweep-destination-network-mismatch', + networkMismatchDestinationAddressMessage, + (value) => !isValidAddress(value) || isAddressOnNetwork(value, network), + ), }) .required(), ) diff --git a/src/components/sweep/SweepPage.test.tsx b/src/components/sweep/SweepPage.test.tsx index 7383cedf..16700047 100644 --- a/src/components/sweep/SweepPage.test.tsx +++ b/src/components/sweep/SweepPage.test.tsx @@ -107,6 +107,19 @@ const mocks = vi.hoisted(() => ({ stopState: { isPending: false, isSuccess: false }, toastError: vi.fn(), walletInfo: undefined as WalletInfo | undefined, + hasOrders: true, + orderbookIsLoading: false, + orderbookError: false, +})) + +vi.mock('@/hooks/useQueryOrderbook', () => ({ + useQueryOrderbook: () => ({ + hasOrders: mocks.hasOrders, + queryResult: { + isLoading: mocks.orderbookIsLoading, + isError: mocks.orderbookError, + }, + }), })) vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({ @@ -264,6 +277,7 @@ vi.mock('@/components/ui/jam/PageLoading', () => ({ vi.mock('@/context/JamWalletInfoContext', () => ({ useJamWalletInfoContext: () => mocks.walletInfo, + useDetectNetwork: () => ({ network: 'regtest' }), })) vi.mock('@/hooks/useApiClient', () => ({ @@ -302,6 +316,7 @@ const makeWalletInfo = (overrides: Partial = {}): WalletInfo => { calculatedAvailableBalanceInSats: 100_000, calculatedTotalBalanceInSats: 100_000, calculatedConfirmedAvailableBalanceInSats: 100_000, + calculatedAvailableFrozenBalanceInSats: 0, calculatedFrozenOrLockedBalanceInSats: 0, }, color: '#e2b86a', @@ -314,6 +329,7 @@ const makeWalletInfo = (overrides: Partial = {}): WalletInfo => { calculatedAvailableBalanceInSats: 0, calculatedTotalBalanceInSats: 0, calculatedConfirmedAvailableBalanceInSats: 0, + calculatedAvailableFrozenBalanceInSats: 0, calculatedFrozenOrLockedBalanceInSats: 0, }, color: '#ccc', @@ -328,6 +344,7 @@ const makeWalletInfo = (overrides: Partial = {}): WalletInfo => { detectedNetwork: null, error: null, fidelityBondSummary: { fbOutputs: [] }, + hasEligibleFidelityBondUtxo: false, isFetching: false, isLoading: false, jars: [jar, emptyJar(1), emptyJar(2)], @@ -340,6 +357,7 @@ const makeWalletInfo = (overrides: Partial = {}): WalletInfo => { calculatedAvailableBalanceInSats: 100_000, calculatedTotalBalanceInSats: 100_000, calculatedConfirmedAvailableBalanceInSats: 100_000, + calculatedAvailableFrozenBalanceInSats: 0, calculatedFrozenOrLockedBalanceInSats: 0, }, walletName: 'wallet.jmdat', @@ -399,6 +417,9 @@ describe('SweepPage', async () => { mocks.stopState = { isPending: false, isSuccess: false } mocks.toastError.mockReset() mocks.walletInfo = makeWalletInfo() + mocks.hasOrders = true + mocks.orderbookIsLoading = false + mocks.orderbookError = false setSession() vi.clearAllMocks() @@ -717,4 +738,27 @@ describe('SweepPage', async () => { await flushActUpdates() }) }) + + describe('Orderbook Precheck', () => { + it('shows empty orderbook warning when orderbook has no offers', () => { + mocks.hasOrders = false + + render() + + expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument() + }) + + it('does not show empty orderbook warning when orderbook is loading or has error', () => { + mocks.hasOrders = false + mocks.orderbookIsLoading = true + + const { rerender } = render() + expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument() + + mocks.orderbookIsLoading = false + mocks.orderbookError = true + rerender() + expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/sweep/SweepPage.tsx b/src/components/sweep/SweepPage.tsx index 3e8ba2a1..bbc95e4c 100644 --- a/src/components/sweep/SweepPage.tsx +++ b/src/components/sweep/SweepPage.tsx @@ -23,20 +23,21 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Balance } from '@/components/ui/jam/Balance' import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert' +import { OrderbookEmptyAlert } from '@/components/ui/jam/OrderbookEmptyAlert' import { PageLoading } from '@/components/ui/jam/PageLoading' import PageTitle from '@/components/ui/jam/PageTitle' -import { isDevMode } from '@/constants/debugFeatures' +import * as JAM from '@/constants/jam' import type { TumblerParameters } from '@/constants/jm' import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext' -import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext' +import { useDetectNetwork, useJamWalletInfoContext } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation' +import { useQueryOrderbook } from '@/hooks/useQueryOrderbook' import { useRefreshSession } from '@/hooks/useRefreshSession' import { getErrorReason } from '@/lib/errorReason' -import { scrollToTop, type WalletFileName } from '@/lib/utils' +import { cn, scrollToTop, type WalletFileName } from '@/lib/utils' import { useDeveloperMode } from '@/store/jamSettingsStore' import { jmSessionStore } from '@/store/jmSessionStore' -import type { Milliseconds } from '@/types/global' import { Button } from '../ui/button' import { Spinner } from '../ui/spinner' import { SweepForm } from './SweepForm' @@ -46,11 +47,6 @@ interface SweepPageProps { walletFileName: WalletFileName } -const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = 3_000 -const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = 1_000 - -const RUNNING_SCHEDULE_POLLING_INTERVAL: Milliseconds = isDevMode() ? 5_000 : 10_000 - const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial = { time_lambda_seconds: 10, stage1_wait_multiplier: 1.5, @@ -62,14 +58,31 @@ const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial = { export const SweepPage = ({ walletFileName }: SweepPageProps) => { const { t } = useTranslation() const client = useApiClient() - const { rescanInfo, takerInfo, makerInfo } = useJamSessionInfoContext() + const { + rescanInfo, + makerInfo: { running: makerRunning }, + takerInfo: { + running: takerRunning, + scheduler: { running: schedulerRunning }, + }, + } = useJamSessionInfoContext() const jmSession = useStore(jmSessionStore, (state) => state.state) const walletInfo = useJamWalletInfoContext() + const { network } = useDetectNetwork() const { enabled: isDeveloperMode } = useDeveloperMode() const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false) const [showScheduleConfirmDialog, setShowScheduleConfirmDialog] = useState() const [alertMessage, setAlertMessage] = useState() + const { + hasOrders, + queryResult: { + isLoading: orderbookCheckIsLoading, + isError: orderbookCheckIsError, + isFetching: orderbookCheckIsFetching, + refetch: orderbookRefetch, + }, + } = useQueryOrderbook() const feeConfigValidation = useFeeConfigValidation({ walletFileName }) @@ -84,9 +97,8 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { path: { walletname: walletFileName }, }), refetchInterval: (query) => { - const schedulerRunning = takerInfo.running && takerInfo.scheduler.running const currentScheduleStillActive = query.state.data && !isPlanTerminated(query.state.data) - return schedulerRunning || currentScheduleStillActive ? RUNNING_SCHEDULE_POLLING_INTERVAL : false + return schedulerRunning || currentScheduleStillActive ? JAM.RUNNING_SCHEDULE_POLLING_INTERVAL : false }, refetchIntervalInBackground: true, retry: false, @@ -194,21 +206,18 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { return toSchedule(getScheduleQuery.data, walletInfo.jars) }, [getScheduleQuery.error, getScheduleQuery.data, walletInfo.jars]) - const schedulerRunning = takerInfo.scheduler.running const isWaitingSchedulerStart = startScheduleMutationIsPending || (startScheduleMutationIsSuccess && !schedulerRunning) - const waitingForTumblerStatus = takerInfo.running && getScheduleQuery.isPending + const waitingForTumblerStatus = takerRunning && getScheduleQuery.isPending const singleCoinJoinRunning = - takerInfo.running && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus - const makerRunning = makerInfo.running === true - const collaborativeOperationRunning = makerRunning || takerInfo.running + takerRunning && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus const isWaitingSchedulerStop = stopScheduleMutationIsPending || (stopScheduleMutationIsSuccess && schedulerRunning) useRefreshSession({ enabled: isWaitingSchedulerStart || isWaitingSchedulerStop, - refetchInterval: WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL, - refetchDelay: WAIT_FOR_UPDATE_SESSION_POLLING_DELAY, + refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL, + refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY, }) useEffect(() => { @@ -225,8 +234,10 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { const isOperationDisabled = feeConfigValidation.maxFeesConfigMissing || - collaborativeOperationRunning || + makerRunning || + takerRunning || rescanInfo.rescanning || + makerRunning || !preconditionSummary.isFulfilled const isStartDisabled = @@ -284,6 +295,10 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { setShowFeeConfigDialog(true)} className="mb-4" /> )} + {!orderbookCheckIsLoading && !orderbookCheckIsError && !hasOrders && ( + + )} + {alertMessage && ( {t('global.error')} @@ -294,14 +309,14 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { {singleCoinJoinRunning && ( - {t('send.text_coinjoin_already_running')} + {t('send.text_coinjoin_already_running')} )} {makerRunning && ( - {t('send.text_maker_running')} + {t('send.text_maker_running')} )} @@ -435,7 +450,11 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { {!currentSchedule && !schedulerRunning && ( <> - +
@@ -452,6 +471,7 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { { diff --git a/src/components/sweep/SweepPreconditionAlert.tsx b/src/components/sweep/SweepPreconditionAlert.tsx index e59005d3..fc5c6eb5 100644 --- a/src/components/sweep/SweepPreconditionAlert.tsx +++ b/src/components/sweep/SweepPreconditionAlert.tsx @@ -1,84 +1,10 @@ -import { AlertTriangleIcon } from 'lucide-react' -import { Trans, useTranslation } from 'react-i18next' import type { SweepPreconditionSummary } from '@/components/sweep/preconditions' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam' -import { formatSats, shortenStringMiddle } from '@/lib/utils' +import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert' interface SweepPreconditionAlertProps { summary: SweepPreconditionSummary } -const RetryLockedUtxoList = ({ summary }: SweepPreconditionAlertProps) => { - const { t } = useTranslation() - - if (summary.retryLockedUtxos.length === 0) { - return null - } - - return ( - <> - , - , - ]} - /> -
-
{t('global.utxos')}
-
    - {summary.retryLockedUtxos.map((utxo) => ( -
  • - jar {utxo.mixdepth} - {formatSats(utxo.value)} sats - {shortenStringMiddle(utxo.utxo, 26)} -
  • - ))} -
-
- - ) -} - export const SweepPreconditionAlert = ({ summary }: SweepPreconditionAlertProps) => { - const { t } = useTranslation() - - if (summary.isFulfilled) { - return null - } - - if (summary.numberOfMissingUtxos > 0) { - return ( - - - - {t('scheduler.precondition.hint_missing_utxos', { minConfirmations: summary.options.minConfirmations })} - - - ) - } - - if (summary.numberOfMissingConfirmations > 0) { - return ( - - - - {t('scheduler.precondition.hint_missing_confirmations', { - minConfirmations: summary.options.minConfirmations, - amountOfMissingConfirmations: summary.numberOfMissingConfirmations, - })} - - - ) - } - - return ( - - - - - - - ) + return } diff --git a/src/components/sweep/destinationValidation.test.ts b/src/components/sweep/destinationValidation.test.ts index 9de4ff4b..ae86d3dd 100644 --- a/src/components/sweep/destinationValidation.test.ts +++ b/src/components/sweep/destinationValidation.test.ts @@ -13,7 +13,7 @@ describe('buildDestinationErrors', () => { }) it('reports duplicate addresses', () => { - const validAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT' + const validAddress = '1BitcoinEaterAddressDontSend8MUo1T' const errors = buildDestinationErrors([validAddress, validAddress, validAddress], {}, t) expect(errors).toEqual([ @@ -24,8 +24,8 @@ describe('buildDestinationErrors', () => { }) it('reports reused addresses from wallet history', () => { - const usedAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT' - const freshAddress = '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy' + const usedAddress = '1BitcoinEaterAddressDontSend8MUo1T' + const freshAddress = '1BitcoinEaterAddressDontSendDHyNcX' const addressSummary = { [usedAddress]: { used: true, diff --git a/src/components/sweep/preconditions.test.ts b/src/components/sweep/preconditions.test.ts index bd0acec9..61ac4f07 100644 --- a/src/components/sweep/preconditions.test.ts +++ b/src/components/sweep/preconditions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { Utxo } from '@/hooks/useQueryUtxos' import { buildSweepPreconditionSummary } from './preconditions' -const utxo = (overrides: Partial): Utxo => { +const makeUtxo = (overrides: Partial): Utxo => { return { utxo: 'txid:0', address: 'bc1qexampleaddress000000000000000000000000000', @@ -22,45 +22,72 @@ const utxo = (overrides: Partial): Utxo => { describe('buildSweepPreconditionSummary', () => { it('returns fulfilled when at least one eligible utxo is available', () => { - const summary = buildSweepPreconditionSummary([utxo({})]) + const summary = buildSweepPreconditionSummary([makeUtxo({})]) expect(summary.isFulfilled).toBe(true) expect(summary.numberOfMissingUtxos).toBe(0) + expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(0) expect(summary.numberOfMissingConfirmations).toBe(0) expect(summary.retryLockedUtxos).toHaveLength(0) }) it('reports missing confirmations when all eligible utxos are too new', () => { - const summary = buildSweepPreconditionSummary([utxo({ confirmations: 2 })], { minConfirmations: 5 }) + const summary = buildSweepPreconditionSummary([makeUtxo({ confirmations: 2 })], { minConfirmations: 5 }) expect(summary.isFulfilled).toBe(false) + expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(0) expect(summary.numberOfMissingUtxos).toBe(0) expect(summary.numberOfMissingConfirmations).toBe(3) + expect(summary.retryLockedUtxos).toHaveLength(0) }) it('reports retry-locked utxos when one jar has no retries left', () => { const summary = buildSweepPreconditionSummary([ - utxo({ utxo: 'a:0', mixdepth: 0, tries_remaining: 0 }), - utxo({ utxo: 'b:0', mixdepth: 0, tries_remaining: 0 }), - utxo({ utxo: 'c:0', mixdepth: 1, tries_remaining: 1 }), + makeUtxo({ utxo: 'a:0', mixdepth: 0, tries_remaining: 0 }), + makeUtxo({ utxo: 'b:0', mixdepth: 0, tries_remaining: 0 }), + makeUtxo({ utxo: 'c:0', mixdepth: 1, tries_remaining: 1 }), ]) expect(summary.isFulfilled).toBe(false) + expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(0) + expect(summary.numberOfMissingUtxos).toBe(0) + expect(summary.numberOfMissingConfirmations).toBe(0) expect(summary.retryLockedUtxos.map((it) => it.utxo)).toEqual(['a:0', 'b:0']) }) - it('ignores frozen or timelocked utxos when counting eligibility', () => { + it('ignores frozen utxos when counting eligibility', () => { const summary = buildSweepPreconditionSummary([ - utxo({ utxo: 'a:0', frozen: true }), - utxo({ + makeUtxo({ utxo: 'a:0', frozen: true }), + makeUtxo({ utxo: 'b:0', locktime: '2999-01-01 00:00:00', path: "m/84'/0'/0'/0/0:32503680000", + frozen: true, }), ]) expect(summary.isFulfilled).toBe(false) + expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(0) expect(summary.numberOfMissingUtxos).toBe(1) expect(summary.numberOfMissingConfirmations).toBe(0) + expect(summary.retryLockedUtxos).toHaveLength(0) + }) + + it('report non-frozen fidelity bonds', () => { + const summary = buildSweepPreconditionSummary([ + makeUtxo({ utxo: 'a:0', frozen: true }), + makeUtxo({ + utxo: 'b:0', + locktime: '2999-01-01 00:00:00', + path: "m/84'/0'/0'/0/0:32503680000", + frozen: false, + }), + ]) + + expect(summary.isFulfilled).toBe(false) + expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(1) + expect(summary.numberOfMissingUtxos).toBe(0) + expect(summary.numberOfMissingConfirmations).toBe(0) + expect(summary.retryLockedUtxos).toHaveLength(0) }) }) diff --git a/src/components/sweep/preconditions.ts b/src/components/sweep/preconditions.ts index 8ffecd35..c781c371 100644 --- a/src/components/sweep/preconditions.ts +++ b/src/components/sweep/preconditions.ts @@ -5,6 +5,7 @@ import * as fb from '@/lib/fidelityBondUtils' export interface SweepPreconditionOptions { minNumberOfUtxos: number minConfirmations: number + maxNumberOfNonFrozenFidelityBondOutputs: number } export interface SweepPreconditionSummary { @@ -12,17 +13,15 @@ export interface SweepPreconditionSummary { options: SweepPreconditionOptions numberOfMissingUtxos: number numberOfMissingConfirmations: number + numberOfNonFrozenFidelityBondOutputs: number retryLockedUtxos: Utxo[] } -const DEFAULT_OPTIONS: SweepPreconditionOptions = { +export const SWEEP_PRECONDITION_DEFAULT_OPTIONS: SweepPreconditionOptions = Object.freeze({ minNumberOfUtxos: 1, minConfirmations: JM_TAKER_UTXO_AGE, -} - -const isUtxoEligible = (utxo: Utxo): boolean => { - return !utxo.frozen && !fb.utxo.isFidelityBond(utxo) -} + maxNumberOfNonFrozenFidelityBondOutputs: 0, +}) const groupByJarIndex = (utxos: Utxo[]): Map => { return utxos.reduce((acc, utxo) => { @@ -65,11 +64,35 @@ export const buildSweepPreconditionSummary = ( options: Partial = {}, ): SweepPreconditionSummary => { const mergedOptions: SweepPreconditionOptions = { - ...DEFAULT_OPTIONS, + ...SWEEP_PRECONDITION_DEFAULT_OPTIONS, ...options, } - const eligibleUtxos = utxos.filter((utxo) => isUtxoEligible(utxo)) + const { nonFrozenFbUtxos, eligibleUtxos } = utxos + .filter((it) => it.frozen !== true) + .reduce( + (context, utxo) => { + if (fb.utxo.isFidelityBond(utxo)) { + context.nonFrozenFbUtxos.push(utxo) + } else { + context.eligibleUtxos.push(utxo) + } + return context + }, + { nonFrozenFbUtxos: [] as Utxo[], eligibleUtxos: [] as Utxo[] }, + ) + + if (nonFrozenFbUtxos.length > mergedOptions.maxNumberOfNonFrozenFidelityBondOutputs) { + return { + isFulfilled: false, + options: mergedOptions, + numberOfNonFrozenFidelityBondOutputs: nonFrozenFbUtxos.length, + numberOfMissingUtxos: 0, + numberOfMissingConfirmations: 0, + retryLockedUtxos: [], + } + } + const numberOfMissingUtxos = Math.max(0, mergedOptions.minNumberOfUtxos - eligibleUtxos.length) const numberOfMissingConfirmations = numberOfMissingUtxos > 0 ? 0 : calcMissingConfirmations(eligibleUtxos, mergedOptions.minConfirmations) @@ -79,6 +102,7 @@ export const buildSweepPreconditionSummary = ( return { isFulfilled: numberOfMissingUtxos === 0 && numberOfMissingConfirmations === 0 && retryLockedUtxos.length === 0, options: mergedOptions, + numberOfNonFrozenFidelityBondOutputs: 0, numberOfMissingUtxos, numberOfMissingConfirmations, retryLockedUtxos, diff --git a/src/components/ui/button-variants.ts b/src/components/ui/button-variants.ts index ee782c92..ec705830 100644 --- a/src/components/ui/button-variants.ts +++ b/src/components/ui/button-variants.ts @@ -40,6 +40,7 @@ const buttonVariants = cva( "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', 'icon-lg': 'size-9', + 'icon-xxl': 'size-12', }, }, defaultVariants: { diff --git a/src/components/ui/jam/Balance.test.tsx b/src/components/ui/jam/Balance.test.tsx index 25489f7e..49d0355a 100644 --- a/src/components/ui/jam/Balance.test.tsx +++ b/src/components/ui/jam/Balance.test.tsx @@ -3,8 +3,10 @@ import '@testing-library/jest-dom/vitest' import { render as reactRender, screen, type RenderOptions } from '@testing-library/react' import user from '@testing-library/user-event' import { describe, it, expect, vi } from 'vitest' +import { Balance } from '@/components/ui/jam/Balance' import { JamDisplayContextProvider } from '@/context/JamDisplayContextProvider' -import { Balance } from './Balance' +import '@/i18n/config' +import { withRuntimeLocale } from '@/test/withRuntimeLocale' const render = (ui: React.ReactNode, options?: Omit) => { const providers = ({ children }: { children: React.ReactNode }) => { @@ -35,11 +37,13 @@ describe('', () => { }) it('should render balance in SATS', () => { - render() - 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() + withRuntimeLocale('en-US', () => { + render() + 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 render a string BTC value correctly as BTC', () => { @@ -49,9 +53,11 @@ describe('', () => { }) it('should render a string BTC value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`) - expect(screen.getByTestId('sats-symbol')).toBeVisible() + withRuntimeLocale('en-US', () => { + render() + 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', () => { @@ -65,23 +71,31 @@ describe('', () => { }) it('should render a large string BTC value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + }) }) it('should render a large string BTC value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + }) }) it('should render a max string BTC value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + }) }) it('should render a max string BTC value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + }) }) it('should render a string SATS value correctly as SATS', () => { @@ -105,23 +119,31 @@ describe('', () => { }) it('should render a large string SATS value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + }) }) it('should render a large string SATS value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + }) }) it('should render a max string SATS value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + }) }) it('should render a max string SATS value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + }) }) it('should render frozen balance in BTC', () => { @@ -132,10 +154,12 @@ describe('', () => { }) it('should render frozen balance in SATS', () => { - render() - expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`) - expect(screen.getByTestId('sats-symbol')).toBeVisible() - expect(screen.getByTestId('frozen-symbol')).toBeVisible() + withRuntimeLocale('en-US', () => { + render() + 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', () => { @@ -198,4 +222,51 @@ describe('', () => { expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument() expect(screen.queryByText(`*****`)).not.toBeInTheDocument() }) + + it('should render the visibility toggle as a keyboard-focusable button with an accessible name', () => { + render() + + const toggleButton = screen.getByRole('button', { name: 'Show balance' }) + expect(toggleButton).toBeInTheDocument() + expect(toggleButton).toHaveAttribute('aria-pressed', 'true') + }) + + it('should update the accessible name and aria-pressed after toggling', async () => { + render() + + await user.click(screen.getByRole('button', { name: 'Show balance' })) + + const toggleButton = screen.getByRole('button', { name: 'Hide balance' }) + expect(toggleButton).toBeInTheDocument() + expect(toggleButton).toHaveAttribute('aria-pressed', 'false') + }) + + it('should toggle visibility via keyboard (Enter)', async () => { + render() + + await user.tab() + expect(screen.getByRole('button', { name: 'Show balance' })).toHaveFocus() + + await user.keyboard('{Enter}') + + expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument() + expect(screen.queryByText(`*****`)).not.toBeInTheDocument() + }) + + it('should toggle visibility via keyboard (Space)', async () => { + render() + + await user.tab() + expect(screen.getByRole('button', { name: 'Show balance' })).toHaveFocus() + + await user.keyboard(' ') + + expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument() + expect(screen.queryByText(`*****`)).not.toBeInTheDocument() + }) + + it('should not render a focusable button when the visibility toggle is disabled', () => { + render() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) }) diff --git a/src/components/ui/jam/Balance.tsx b/src/components/ui/jam/Balance.tsx index 11740b9a..c61215cd 100644 --- a/src/components/ui/jam/Balance.tsx +++ b/src/components/ui/jam/Balance.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, type MouseEvent, type MouseEventHandler, type PropsWithChildren } from 'react' import { SnowflakeIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol' import { useJamDisplayContext } from '@/context/JamDisplayContext' import { cn, satsToBtc, tryBtcToSat, isValidNumber, getBtcParts, formatSats } from '@/lib/utils' @@ -23,6 +24,8 @@ type ElementWithSymbolsProps = PropsWithChildren<{ frozenSymbol?: boolean className?: string onClick?: MouseEventHandler + 'aria-label'?: string + 'aria-pressed'?: boolean }> const ElementWithSymbols = ({ @@ -33,23 +36,40 @@ const ElementWithSymbols = ({ className, children, onClick, + 'aria-label': ariaLabel, + 'aria-pressed': ariaPressed, }: ElementWithSymbolsProps) => { - return ( - + const sharedClassName = cn( + 'balance-hook inline-flex items-center', + { + 'text-brand-info': frozen, + }, + className, + ) + + const content = ( + <> {children} {showSymbol && symbol} {frozen && frozenSymbol && FROZEN_SYMBOL} - + ) + + if (onClick) { + return ( + + ) + } + + return {content} } const DECIMAL_POINT_CHAR = '.' @@ -156,6 +176,7 @@ export const BalanceComponent = ({ enableVisibilityToggle, ...props }: BalanceComponentProps) => { + const { t } = useTranslation() const [isBalanceVisible, setIsBalanceVisible] = useState(showBalance) const displayMode = useMemo(() => { return isBalanceVisible ? (convertToUnit ?? 'default') : 'hidden' @@ -173,18 +194,25 @@ export const BalanceComponent = ({ setIsBalanceVisible((current) => !current) } const onClickHandler = enableVisibilityToggle === false ? undefined : toggleVisibility + const isInteractive = Boolean(onClickHandler || props.onClick) return { ...props, className: cn(props.className, { - 'cursor-pointer': onClickHandler || props.onClick, + 'cursor-pointer': isInteractive, }), - onClick: (event: MouseEvent) => { - onClickHandler?.(event) - props.onClick?.(event) - }, + onClick: isInteractive + ? (event: MouseEvent) => { + onClickHandler?.(event) + props.onClick?.(event) + } + : undefined, + 'aria-label': onClickHandler + ? t(isBalanceVisible ? 'settings.hide_balance' : 'settings.show_balance') + : undefined, + 'aria-pressed': onClickHandler ? !isBalanceVisible : undefined, } - }, [props, enableVisibilityToggle]) + }, [props, enableVisibilityToggle, isBalanceVisible, t]) const element = useMemo(() => { if (displayMode === 'hidden') { diff --git a/src/components/ui/jam/CookingPotIcon.tsx b/src/components/ui/jam/CookingPotIcon.tsx new file mode 100644 index 00000000..1dd2d288 --- /dev/null +++ b/src/components/ui/jam/CookingPotIcon.tsx @@ -0,0 +1,163 @@ +import { jarBadgeVariant } from '@/components/ui/badge-variants' +import { cn } from '@/lib/utils' + +export interface CookingPotIconProps { + sourceJarIndex?: number + destinationJarIndex?: number + className?: string +} + +const STROKE_CLASSES: Record = { + jar0: 'stroke-jar0', + jar1: 'stroke-jar1', + jar2: 'stroke-jar2', + jar3: 'stroke-jar3', + jar4: 'stroke-jar4', + jarUnknown: 'stroke-jar-unknown', +} + +export const CookingPotIcon = ({ sourceJarIndex, destinationJarIndex, className }: CookingPotIconProps) => { + const sourceVariant = jarBadgeVariant(sourceJarIndex) + const destinationVariant = jarBadgeVariant(destinationJarIndex) ?? sourceVariant + + const sourceStrokeClass = (sourceVariant && STROKE_CLASSES[sourceVariant]) || 'stroke-jar-unknown' + const destinationStrokeClass = (destinationVariant && STROKE_CLASSES[destinationVariant]) || sourceStrokeClass + + return ( +
+ + +
+ +
+
+ ) +} diff --git a/src/components/ui/jam/Jar.test.tsx b/src/components/ui/jam/Jar.test.tsx new file mode 100644 index 00000000..d9c82b42 --- /dev/null +++ b/src/components/ui/jam/Jar.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { type Jar as JarType } from '@/context/JamWalletInfoContext' +import type { AmountSats } from '@/types/global' +import { Jar } from './Jar' + +vi.mock('@/components/ui/jam/Balance', () => ({ + Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => ( + balance:{valueString} + ), +})) + +const DUMMY_JAR: JarType = { + balanceSummary: { + calculatedTotalBalanceInSats: 5, + calculatedAvailableBalanceInSats: 4, + calculatedConfirmedAvailableBalanceInSats: 3, + calculatedAvailableFrozenBalanceInSats: 2, + calculatedFrozenOrLockedBalanceInSats: 1, + }, + color: '#666', + jarIndex: 0, + name: 'Test jar', + utxos: [], +} + +describe('Jar', () => { + it('renders correctly', () => { + render( + , + ) + + expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument() + expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument() + expect( + screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats), + ).toBeInTheDocument() + expect( + screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats).parentNode, + ).not.toHaveClass('invisible') + }) + it('does not render zero frozen balance', () => { + const frozenBalance: AmountSats = 0 + + render( + , + ) + + expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument() + expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument() + expect(screen.getByText('balance:' + frozenBalance)).toBeInTheDocument() + expect(screen.getByText('balance:' + frozenBalance).parentNode).toHaveClass('invisible') + }) +}) diff --git a/src/components/ui/jam/Jar.tsx b/src/components/ui/jam/Jar.tsx index 451ea510..8cb6250f 100644 --- a/src/components/ui/jam/Jar.tsx +++ b/src/components/ui/jam/Jar.tsx @@ -1,7 +1,7 @@ +import { Balance } from '@/components/ui/jam/Balance' import type { JarColor } from '@/context/JamWalletInfoContext' import { cn } from '@/lib/utils' import type { AmountSats } from '@/types/global' -import { Balance } from './Balance' import { JarIcon } from './JarIcon' interface JarProps { @@ -53,7 +53,7 @@ export function Jar({
diff --git a/src/components/settings/LanguageSelector.test.tsx b/src/components/ui/jam/LanguageSelector.test.tsx similarity index 93% rename from src/components/settings/LanguageSelector.test.tsx rename to src/components/ui/jam/LanguageSelector.test.tsx index a0a590e2..c1b71220 100644 --- a/src/components/settings/LanguageSelector.test.tsx +++ b/src/components/ui/jam/LanguageSelector.test.tsx @@ -38,6 +38,8 @@ vi.mock('@/components/ui/select', () => ({ SelectTrigger: ({ children }: ChildrenProps) =>
{children}
, SelectValue: ({ placeholder }: { placeholder?: string }) =>
{placeholder}
, SelectContent: ({ children }: ChildrenProps) =>
{children}
, + SelectGroup: ({ children }: ChildrenProps) =>
{children}
, + SelectLabel: ({ children }: ChildrenProps) =>
{children}
, SelectItem: ({ children, value }: ChildrenProps & { value: string }) => (
{children}
), diff --git a/src/components/ui/jam/LanguageSelector.tsx b/src/components/ui/jam/LanguageSelector.tsx new file mode 100644 index 00000000..d0553a45 --- /dev/null +++ b/src/components/ui/jam/LanguageSelector.tsx @@ -0,0 +1,116 @@ +import type { ComponentProps, PropsWithChildren } from 'react' +import { LanguagesIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, + SelectLabel, +} from '@/components/ui/select' +import languages from '@/i18n/languages' +import { cn } from '@/lib/utils' +import { Button } from '../button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '../dropdown-menu' + +export const LanguageSelector = ({ + className, + ...props +}: ComponentProps & { className?: string } = {}) => { + const { i18n, t } = useTranslation() + const currentLanguage = languages.find((lang) => lang.key === i18n.resolvedLanguage) + const currentLanguageDescription = currentLanguage?.description || 'English' + + return ( + + ) +} + +export const LanguageSelectorDropdownMenu = ({ + className, + align, + loop = true, + ...props +}: PropsWithChildren< + ComponentProps & { + className?: string + align?: ComponentProps['align'] + loop?: ComponentProps['loop'] + } +> = {}) => { + const { i18n, t } = useTranslation() + + return ( + + + {props.children ?? ( + + )} + + + + + + {t('settings.label_select_language')} + + { + void i18n.changeLanguage(value) + }} + > + {languages.map((language) => ( + + {language.description} + + ))} + + + + + ) +} diff --git a/src/components/ui/jam/OrderbookEmptyAlert.test.tsx b/src/components/ui/jam/OrderbookEmptyAlert.test.tsx new file mode 100644 index 00000000..aae12d42 --- /dev/null +++ b/src/components/ui/jam/OrderbookEmptyAlert.test.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { OrderbookEmptyAlert } from './OrderbookEmptyAlert' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + Trans: ({ i18nKey, components }: { i18nKey: string; components?: Record }) => ( + + {i18nKey} + {components?.['1']} + + ), +})) + +vi.mock('react-router-dom', () => ({ + Link: ({ children, to }: { children?: ReactNode; to: string }) =>
{children}, +})) + +describe('OrderbookEmptyAlert', () => { + it('renders title, description, button and link to orderbook page', () => { + render( {}} />) + + expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument() + expect(screen.getByText(/orderbook.alert_precheck_empty_description/u)).toBeInTheDocument() + expect(screen.getByRole('link')).toHaveAttribute('href', '/orderbook') + + const actionCheck = screen.getByRole('button', { name: 'orderbook.alert_precheck_empty_text_button_check' }) + expect(actionCheck).toBeEnabled() + + const actionChecking = screen.queryByRole('button', { name: 'orderbook.alert_precheck_empty_text_button_checking' }) + expect(actionChecking).not.toBeInTheDocument() + }) + + it('disables button while checking', () => { + render( {}} />) + + const actionChecking = screen.getByRole('button', { name: 'orderbook.alert_precheck_empty_text_button_checking' }) + expect(actionChecking).toBeDisabled() + + const actionCheck = screen.queryByRole('button', { name: 'orderbook.alert_precheck_empty_text_button_check' }) + expect(actionCheck).not.toBeInTheDocument() + }) +}) diff --git a/src/components/ui/jam/OrderbookEmptyAlert.tsx b/src/components/ui/jam/OrderbookEmptyAlert.tsx new file mode 100644 index 00000000..95de8d79 --- /dev/null +++ b/src/components/ui/jam/OrderbookEmptyAlert.tsx @@ -0,0 +1,43 @@ +import { AlertTriangleIcon, RefreshCwIcon } from 'lucide-react' +import { Trans, useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { routes } from '@/constants/routes' +import { Button } from '../button' + +interface OrderbookEmptyAlertProps { + className?: string + isChecking?: boolean + onCheckClick?: () => Promise +} + +export const OrderbookEmptyAlert = ({ className, isChecking, onCheckClick }: OrderbookEmptyAlertProps) => { + const { t } = useTranslation() + + return ( + + + {t('orderbook.alert_precheck_empty_title')} + +

+ , + }} + /> +

+
+ {onCheckClick && ( + + )} +
+
+
+ ) +} diff --git a/src/components/ui/jam/PreconditionAlert.tsx b/src/components/ui/jam/PreconditionAlert.tsx new file mode 100644 index 00000000..ce42d921 --- /dev/null +++ b/src/components/ui/jam/PreconditionAlert.tsx @@ -0,0 +1,102 @@ +import { AlertTriangleIcon } from 'lucide-react' +import { Trans, useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { JarBadge } from '@/components/earn/fidelity-bond/FidelityBondDialogParts' +import type { SweepPreconditionSummary } from '@/components/sweep/preconditions' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Balance } from '@/components/ui/jam/Balance' +import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam' +import { routes } from '@/constants/routes' +import { shortenStringMiddle } from '@/lib/utils' + +export interface PreconditionAlertProps { + summary: SweepPreconditionSummary + i18nPrefix: 'send.coinjoin_precondition' | 'scheduler.precondition' +} + +const RetryLockedUtxoList = ({ summary, i18nPrefix }: PreconditionAlertProps) => { + const { t } = useTranslation() + + return ( + <> + + ), + }} + /> +
+
{t('global.utxos')}
+
    + {summary.retryLockedUtxos.map((utxo) => ( +
  • + + + + {shortenStringMiddle(utxo.utxo, 26)} + +
  • + ))} +
+
+ + ) +} + +export const PreconditionAlert = ({ summary, i18nPrefix }: PreconditionAlertProps) => { + const { t } = useTranslation() + + if (summary.isFulfilled) { + return null + } + + return ( + + + {t(`${i18nPrefix}.title`)} + +
    + {summary.numberOfNonFrozenFidelityBondOutputs === 0 ? null : ( +
  • + , + }} + /> +
  • + )} + {summary.numberOfMissingUtxos === 0 ? null : ( +
  • + {t(`${i18nPrefix}.hint_missing_utxos`, { + minConfirmations: summary.options.minConfirmations, + })} +
  • + )} + {summary.numberOfMissingConfirmations === 0 ? null : ( +
  • + {t(`${i18nPrefix}.hint_missing_confirmations`, { + minConfirmations: summary.options.minConfirmations, + amountOfMissingConfirmations: summary.numberOfMissingConfirmations, + })} +
  • + )} + {summary.retryLockedUtxos.length === 0 ? null : ( +
  • + +
  • + )} +
+
+
+ ) +} diff --git a/src/components/ui/jam/SelectableJar.test.tsx b/src/components/ui/jam/SelectableJar.test.tsx new file mode 100644 index 00000000..cb7c7a6e --- /dev/null +++ b/src/components/ui/jam/SelectableJar.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { beforeEach } from 'vitest' +import type { Jar } from '@/context/JamWalletInfoContext' +import { SelectableJar } from './SelectableJar' + +const mocks = vi.hoisted(() => ({ + onClick: vi.fn(), + onSelect: vi.fn(), +})) + +vi.mock('@/components/ui/jam/Balance', () => ({ + Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => ( + balance:{valueString} + ), +})) + +const DUMMY_JAR: Jar = { + balanceSummary: { + calculatedTotalBalanceInSats: 5, + calculatedAvailableBalanceInSats: 4, + calculatedConfirmedAvailableBalanceInSats: 3, + calculatedAvailableFrozenBalanceInSats: 2, + calculatedFrozenOrLockedBalanceInSats: 1, + }, + color: '#666', + jarIndex: 0, + name: 'Test jar', + utxos: [], +} + +describe('SelectableJar', () => { + beforeEach(() => { + mocks.onClick.mockReset() + mocks.onSelect.mockReset() + }) + + it('renders correctly', () => { + render( + , + ) + + expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument() + expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument() + expect( + screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats), + ).toBeInTheDocument() + }) + + it('can be selected', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button')) + + expect(mocks.onClick).toHaveBeenCalled() + expect(mocks.onSelect).toHaveBeenCalled() + }) + + it('can not be selected if disabled', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button')) + + expect(mocks.onClick).not.toHaveBeenCalled() + expect(mocks.onSelect).not.toHaveBeenCalled() + }) + + it('does not call onSelect if already selected', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button')) + + expect(mocks.onClick).toHaveBeenCalled() + expect(mocks.onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/ui/jam/SelectableJar.tsx b/src/components/ui/jam/SelectableJar.tsx index 60e76f9b..a526b7cf 100644 --- a/src/components/ui/jam/SelectableJar.tsx +++ b/src/components/ui/jam/SelectableJar.tsx @@ -4,7 +4,8 @@ import { Jar } from './Jar' interface SelectableJarProps extends ComponentProps { isSelected: NonNullable['checked']> - onClick: NonNullable['onChange']> + onClick?: NonNullable['onClick']> + onSelect: NonNullable['onChange']> } export const SelectableJar = ({ @@ -16,20 +17,19 @@ export const SelectableJar = ({ totalWalletBalance, isSelected, disabled = false, + onSelect, onClick, }: SelectableJarProps) => { const radioRef = useRef(null) return ( + )} + + + + {compact && onViewAll ? ( + + ) : null} +
+
+ + {queryResult.isLoading ? ( +
+ + {t('global.loading')} +
+ ) : queryResult.error ? ( + + + {t('tx_history.error_loading')} + + ) : ( + + )} + + {!compact && combinedHistory.length >= limit ? ( +
+ +
+ ) : null} + + ) +} diff --git a/src/components/wallet/TxHistoryOverlay.test.tsx b/src/components/wallet/TxHistoryOverlay.test.tsx new file mode 100644 index 00000000..f2f44194 --- /dev/null +++ b/src/components/wallet/TxHistoryOverlay.test.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { TxHistoryOverlay } from './TxHistoryOverlay' + +type ChildrenProps = { children: ReactNode } +type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void } + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('./TxHistoryContent', () => ({ + TxHistoryContent: ({ walletFileName }: { walletFileName: string }) => ( +
+ Content +
+ ), +})) + +vi.mock('../ui/jam/PageTitle', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})) + +// Mock Dialog to avoid dealing with portals and radix UI internals +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open, onOpenChange }: DialogProps) => + open ? ( +
+ + {children} +
+ ) : null, + DialogContent: ({ children }: ChildrenProps) =>
{children}
, + DialogHeader: ({ children }: ChildrenProps) =>
{children}
, + DialogTitle: ({ children }: ChildrenProps) =>
{children}
, +})) + +describe('TxHistoryOverlay', () => { + it('renders dialog content when open', () => { + render() + + expect(screen.getByTestId('page-title')).toHaveTextContent('tx_history.overlay_title') + + const content = screen.getByTestId('tx-history-content') + expect(content).toBeInTheDocument() + expect(content).toHaveAttribute('data-wallet', 'test-wallet.jmdat') + }) + + it('calls onOpenChange when closed', () => { + const onOpenChange = vi.fn() + render() + + const closeButton = screen.getByText('Close') + fireEvent.click(closeButton) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/wallet/TxHistoryOverlay.tsx b/src/components/wallet/TxHistoryOverlay.tsx new file mode 100644 index 00000000..6b77b12d --- /dev/null +++ b/src/components/wallet/TxHistoryOverlay.tsx @@ -0,0 +1,36 @@ +import type { ComponentProps } from 'react' +import { useTranslation } from 'react-i18next' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import type { WithRequiredProperty } from '@/types/global' +import PageTitle from '../ui/jam/PageTitle' +import { TxHistoryContent } from './TxHistoryContent' + +type TxHistoryOverlayProps = WithRequiredProperty< + Omit, 'children'>, + 'open' | 'onOpenChange' +> & + Pick, 'walletFileName'> + +export function TxHistoryOverlay({ open, onOpenChange, walletFileName, ...dialogProps }: TxHistoryOverlayProps) { + const { t } = useTranslation() + + return ( + onOpenChange(false)} {...dialogProps}> + + + + + + + +
+ +
+
+
+ ) +} diff --git a/src/components/wallet/TxHistoryPage.test.tsx b/src/components/wallet/TxHistoryPage.test.tsx new file mode 100644 index 00000000..c96405d0 --- /dev/null +++ b/src/components/wallet/TxHistoryPage.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { TxHistoryPage } from './TxHistoryPage' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('./TxHistoryContent', () => ({ + TxHistoryContent: ({ compact }: { compact: boolean }) => ( +
+ ), +})) + +describe('TxHistoryPage', () => { + it('renders TxHistoryContent in non-compact page mode with title', () => { + render() + + expect(screen.getByText('tx_history.title')).toBeInTheDocument() + const content = screen.getByTestId('tx-history-content') + expect(content).toBeInTheDocument() + expect(content).toHaveAttribute('data-compact', 'false') + }) +}) diff --git a/src/components/wallet/TxHistoryPage.tsx b/src/components/wallet/TxHistoryPage.tsx new file mode 100644 index 00000000..b7c2576f --- /dev/null +++ b/src/components/wallet/TxHistoryPage.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from 'react-i18next' +import PageTitle from '@/components/ui/jam/PageTitle' +import type { WalletFileName } from '@/lib/utils' +import { TxHistoryContent } from './TxHistoryContent' + +interface TxHistoryPageProps { + walletFileName: WalletFileName +} + +export function TxHistoryPage({ walletFileName }: TxHistoryPageProps) { + const { t } = useTranslation() + + return ( +
+ + +
+ ) +} diff --git a/src/components/wallet/TxHistoryTable.test.tsx b/src/components/wallet/TxHistoryTable.test.tsx new file mode 100644 index 00000000..f9dc23ac --- /dev/null +++ b/src/components/wallet/TxHistoryTable.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { HistoryEntry } from '@/hooks/useQueryWalletHistory' +import { TxHistoryTable } from './TxHistoryTable' + +const mocks = vi.hoisted(() => ({ + toastSuccess: vi.fn(), + toastError: vi.fn(), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('sonner', () => ({ + toast: { + success: mocks.toastSuccess, + error: mocks.toastError, + }, +})) + +vi.mock('../ui/jam/Address', () => ({ + Address: ({ value }: { value: string }) => {value}, +})) + +vi.mock('../ui/jam/Balance', () => ({ + Balance: ({ valueString }: { valueString: string }) => {valueString}, +})) + +const mockDeposit: HistoryEntry = { + timestamp: '2026-07-28T12:00:00Z', + role: 'deposit', + txid: 'tx1', + cj_amount: 5000, + net_fee: 0, + confirmations: 10, + success: true, + source_mixdepth: 0, +} + +const mockSend: HistoryEntry = { + timestamp: '2026-07-28T13:00:00Z', + role: 'send', + txid: 'tx2', + cj_amount: 10000, + net_fee: -100, + confirmations: 0, + success: true, + source_mixdepth: 0, +} + +const mockFailed: HistoryEntry = { + timestamp: '2026-07-28T14:00:00Z', + role: 'taker', + txid: 'tx3', + cj_amount: 0, + net_fee: 0, + confirmations: 0, + success: false, + failure_reason: 'aborted', + source_mixdepth: 1, +} + +describe('TxHistoryTable', () => { + beforeEach(() => { + mocks.toastSuccess.mockReset() + mocks.toastError.mockReset() + }) + + it('renders history rows and sorts by date', () => { + render() + + expect(screen.getByText('tx_history.label_role_deposit')).toBeInTheDocument() + expect(screen.getByText('tx_history.label_role_send')).toBeInTheDocument() + + const dateHeader = screen.getByText('tx_history.column_title_date') + fireEvent.click(dateHeader) + expect(dateHeader).toBeInTheDocument() + }) + + it('renders empty history alert', () => { + render() + expect(screen.getByText('tx_history.empty_history')).toBeInTheDocument() + }) + + it('expands row details to show raw JSON', () => { + render() + + const detailsButton = screen.getByRole('button', { name: 'jar_details.utxo_list.row_button_details' }) + fireEvent.click(detailsButton) + + // Check if the JSON is rendered + expect(screen.getByText(/"txid": "tx1"/u)).toBeInTheDocument() + }) + + it('sorts columns correctly', () => { + render() + + fireEvent.click(screen.getByText('tx_history.column_title_amount')) + fireEvent.click(screen.getByText('tx_history.column_title_net_fee')) + fireEvent.click(screen.getByText('tx_history.column_title_confirmations')) + fireEvent.click(screen.getByText('tx_history.column_title_role')) + + expect(screen.getByText('tx_history.label_role_taker')).toBeInTheDocument() + }) +}) diff --git a/src/components/wallet/TxHistoryTable.tsx b/src/components/wallet/TxHistoryTable.tsx new file mode 100644 index 00000000..8abb3ba5 --- /dev/null +++ b/src/components/wallet/TxHistoryTable.tsx @@ -0,0 +1,317 @@ +import { useEffect, useMemo, useState } from 'react' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getPaginationRowModel, + getSortedRowModel, + type CellContext, + type PaginationState, + type Row, + type SortingState, + useReactTable, +} from '@tanstack/react-table' +import type { TFunction } from 'i18next' +import { CheckIcon, ChevronDownIcon, CopyIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { TablePagination } from '@/components/ui/jam/TablePagination' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import type { HistoryEntry } from '@/hooks/useQueryWalletHistory' +import { cn, shortenStringMiddle } from '@/lib/utils' +import { Alert, AlertDescription } from '../ui/alert' +import { Button } from '../ui/button' +import { buttonVariants } from '../ui/button-variants' +import { Card, CardContent } from '../ui/card' +import { Balance } from '../ui/jam/Balance' +import { CopyButton } from '../ui/jam/CopyButton' +import { SortIcon } from '../ui/jam/SortIcon' +import { StatusBadge } from '../ui/jam/StatusBadge' + +const ITEMS_PER_PAGE = 25 + +type KnownHistoryRole = 'maker' | 'taker' | 'send' | 'deposit' +type StatusBadgeVariant = NonNullable[0]['variant']> + +const ROLE_VARIANT: Record = { + maker: 'cj-out', + taker: 'cj-change', + send: 'used-empty', + deposit: 'deposit', +} + +const columnHelper = createColumnHelper() + +const historyRole = (entry: HistoryEntry): KnownHistoryRole | undefined => { + return ['maker', 'taker', 'send', 'deposit'].includes(entry.role ?? '') ? (entry.role as KnownHistoryRole) : undefined +} + +const roleLabel = (entry: HistoryEntry, t: TFunction) => { + const role = historyRole(entry) + return role ? t(`tx_history.label_role_${role}`) : entry.role || '' +} + +const dateTimeValue = (value: string) => { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? 0 : date.getTime() +} + +const formatDateTime = (value: string) => { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toLocaleString() +} + +const TxHistoryDetails = ({ entry }: { entry: HistoryEntry }) => { + return ( + + +
{JSON.stringify(entry, null, 2)}
+
+
+ ) +} + +const TxHistoryTableRow = ({ row }: { row: Row }) => { + return ( + <> + + {row.getVisibleCells().map((cell) => { + const alignCenter = cell.column.columnDef.meta?.align === 'center' + const alignRight = cell.column.columnDef.meta?.align === 'right' + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + {row.getIsExpanded() && ( + + + + + + )} + + ) +} + +const txHistoryTableColumns = (t: TFunction) => { + return [ + columnHelper.accessor('timestamp', { + header: () => t('tx_history.column_title_date'), + sortingFn: (a, b) => dateTimeValue(a.original.timestamp) - dateTimeValue(b.original.timestamp), + cell: (info) => {formatDateTime(info.getValue())}, + meta: { + alphabetic: true, + }, + }), + columnHelper.accessor('role', { + header: () => t('tx_history.column_title_role'), + sortingFn: (a, b) => roleLabel(a.original, t).localeCompare(roleLabel(b.original, t)), + cell: (info) => { + const role = historyRole(info.row.original) + return ( + {roleLabel(info.row.original, t)} + ) + }, + }), + columnHelper.accessor('cj_amount', { + header: () => t('tx_history.column_title_amount'), + sortingFn: (a, b) => (a.original.cj_amount ?? 0) - (b.original.cj_amount ?? 0), + cell: (info) => , + meta: { + numeric: true, + align: 'right', + }, + }), + columnHelper.accessor('net_fee', { + header: () => t('tx_history.column_title_net_fee'), + sortingFn: (a, b) => (a.original.net_fee ?? 0) - (b.original.net_fee ?? 0), + cell: (info) => , + meta: { + numeric: true, + align: 'right', + }, + }), + columnHelper.accessor('confirmations', { + header: () => t('tx_history.column_title_confirmations'), + sortingFn: (a, b) => (a.original.confirmations ?? 0) - (b.original.confirmations ?? 0), + cell: (info) => <>{info.getValue() ?? 0}, + meta: { + numeric: true, + align: 'center', + }, + }), + columnHelper.accessor('txid', { + header: () => t('tx_history.column_title_txid'), + cell: (info) => { + const txid = info.getValue() ?? '' + return txid ? ( +
+ {shortenStringMiddle(txid, 16)} + } + successText={} + className={cn(buttonVariants({ variant: 'outline', size: 'icon-xs' }), 'shrink-0')} + onSuccess={() => toast.success(t('tx_history.copy_txid_success'))} + onError={() => toast.error(t('tx_history.copy_txid_error'))} + /> +
+ ) : ( + '' + ) + }, + enableSorting: false, + }), + { + id: 'expand-col', + cell: ({ row }: CellContext) => { + return row.getCanExpand() ? ( + + ) : ( + '' + ) + }, + enableSorting: false, + }, + ] +} + +interface TxHistoryTableProps { + history: HistoryEntry[] + compact?: boolean +} + +export const TxHistoryTable = ({ history, compact = false }: TxHistoryTableProps) => { + const { t } = useTranslation() + const [sorting, setSorting] = useState([{ id: 'timestamp', desc: true }]) + const [isShowAll, setIsShowAll] = useState(false) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: compact ? 5 : ITEMS_PER_PAGE, + }) + + useEffect(() => { + if (isShowAll) { + setPagination((previous) => ({ + ...previous, + pageSize: Math.max(1, history.length), + pageIndex: 0, + })) + } + }, [history.length, isShowAll]) + + const columns = useMemo(() => txHistoryTableColumns(t), [t]) + + const table = useReactTable({ + data: history, + columns, + state: { + sorting, + pagination, + }, + autoResetPageIndex: true, + getRowId: (row, index) => `${row.txid || 'notxid'}-${row.source_mixdepth ?? 0}-${row.timestamp}-${index}`, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowCanExpand: () => true, + getExpandedRowModel: getExpandedRowModel(), + paginateExpandedRows: false, + }) + + if (history.length === 0) { + return ( + + {t('tx_history.empty_history')} + + ) + } + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort() + const alignCenter = header.column.columnDef.meta?.align === 'center' + const alignRight = header.column.columnDef.meta?.align === 'right' + return ( + header.column.toggleSorting() : undefined} + > +
0 && !header.column.getIsSorted(), + })} + > + {flexRender(header.column.columnDef.header, header.getContext())} + {canSort ? : undefined} +
+
+ ) + })} +
+ ))} +
+ + {table.getRowModel().rows.map((row) => ( + + ))} + +
+
+ + {!compact && ( + table.setPageIndex(page - 1)} + onItemsPerPageChange={(newItemsPerPage) => { + if (newItemsPerPage === -1) { + setIsShowAll(true) + table.setPageSize(history.length || 1) + } else { + setIsShowAll(false) + table.setPageSize(newItemsPerPage) + } + table.setPageIndex(0) + }} + /> + )} +
+ ) +} diff --git a/src/components/wallet/WalletJarsDetailsContent.test.tsx b/src/components/wallet/WalletJarsDetailsContent.test.tsx index b9559bcf..fc289e62 100644 --- a/src/components/wallet/WalletJarsDetailsContent.test.tsx +++ b/src/components/wallet/WalletJarsDetailsContent.test.tsx @@ -5,8 +5,13 @@ import type { AccountMeta, AccountSummary, AddressMeta, AddressSummary, Jar } fr import type { Utxo } from '@/hooks/useQueryUtxos' import { WalletJarsDetailsContent } from './WalletJarsDetailsContent' -const walletInfoRefetch = vi.hoisted(() => vi.fn()) -const toastMocks = vi.hoisted(() => ({ success: vi.fn(), warning: vi.fn() })) +const { walletInfoRefetch, toastMocks, ...mocks } = vi.hoisted(() => ({ + walletInfoRefetch: vi.fn(), + toastMocks: { success: vi.fn(), warning: vi.fn() }, + rescanning: false, + takerRunning: false, + makerRunning: false, +})) vi.mock('sonner', () => ({ toast: { @@ -46,10 +51,18 @@ vi.mock('../ui/jam/Address', () => ({ Address: ({ value }: { value: string }) => {value}, })) -vi.mock('../ui/jam/Balance', () => ({ +vi.mock('@/components/ui/jam/Balance', () => ({ Balance: ({ valueString }: { valueString: string }) => {valueString}, })) +vi.mock('@/context/JamSessionInfoContext', () => ({ + useJamSessionInfoContext: () => ({ + takerInfo: { running: mocks.takerRunning }, + rescanInfo: { rescanning: mocks.rescanning }, + makerInfo: { running: mocks.makerRunning }, + }), +})) + const makeUtxo = (overrides: Partial): Utxo => ({ address: 'bc1qwallet-a', @@ -69,6 +82,7 @@ const jars: Jar[] = [ calculatedAvailableBalanceInSats: 12_000, calculatedConfirmedAvailableBalanceInSats: 12_000, calculatedFrozenOrLockedBalanceInSats: 0, + calculatedAvailableFrozenBalanceInSats: 0, calculatedTotalBalanceInSats: 12_000, }, color: '#e2b86a', @@ -81,6 +95,7 @@ const jars: Jar[] = [ calculatedAvailableBalanceInSats: 5_000, calculatedConfirmedAvailableBalanceInSats: 5_000, calculatedFrozenOrLockedBalanceInSats: 0, + calculatedAvailableFrozenBalanceInSats: 0, calculatedTotalBalanceInSats: 5_000, }, color: '#3b5ba9', @@ -93,6 +108,7 @@ const jars: Jar[] = [ calculatedAvailableBalanceInSats: 8_000, calculatedConfirmedAvailableBalanceInSats: 8_000, calculatedFrozenOrLockedBalanceInSats: 8_000, + calculatedAvailableFrozenBalanceInSats: 8_000, calculatedTotalBalanceInSats: 8_000, }, color: '#5ba93b', @@ -146,32 +162,51 @@ describe('WalletJarsDetailsContent', () => { walletInfoRefetch.mockResolvedValue({}) toastMocks.success.mockReset() toastMocks.warning.mockReset() + mocks.rescanning = false + mocks.takerRunning = false + mocks.makerRunning = false + // vi.resetAllMocks() + }) + + it('renders successfully', () => { + render() + + const jarTab = screen.getByRole('tab', { name: jars[0].name }) + expect(jarTab).toHaveAttribute('data-state', 'active') + expect(screen.getByText('bc1qwallet-a')).toBeInTheDocument() }) it('renders selected jar UTXOs and debug details', async () => { const user = userEvent.setup() + const jar = jars[1] - render() + render() - expect(screen.getByRole('tab', { name: 'One' })).toHaveAttribute('data-state', 'active') + const jarTab = screen.getByRole('tab', { name: jar.name }) + expect(jarTab).toHaveAttribute('data-state', 'active') expect(screen.getByText('bc1qwallet-b')).toBeInTheDocument() - await user.click(screen.getByRole('tab', { name: /Dev/u })) + const devTab = screen.getByRole('tab', { name: /Dev/u }) + await user.click(devTab) expect(screen.getByText('activeJar:')).toBeInTheDocument() }) it('switches jars with keyboard shortcuts and shows missing account information', async () => { const user = userEvent.setup() + const jar = jars[1] - render() + render() expect(screen.getByText('bc1qwallet-b')).toBeInTheDocument() await user.keyboard('{ArrowLeft}') + expect(screen.getByText('bc1qwallet-a')).toBeInTheDocument() - await user.click(screen.getByRole('tab', { name: 'One' })) + await user.click(screen.getByRole('tab', { name: jar.name })) + await user.click(screen.getByRole('tab', { name: 'jar_details.title_tab_jar_details' })) + expect(screen.getByText('jar_details.utxo_list.alert_no_account_info_title')).toBeInTheDocument() }) @@ -180,7 +215,10 @@ describe('WalletJarsDetailsContent', () => { render() + expect(screen.getByText('bc1qwallet-a')).toBeInTheDocument() + await user.keyboard('{ArrowRight}') + expect(screen.getByText('bc1qwallet-a')).toBeInTheDocument() }) @@ -192,7 +230,11 @@ describe('WalletJarsDetailsContent', () => { it('refreshes wallet info from the utxos tab', async () => { const user = userEvent.setup() render() - await user.click(screen.getByRole('button', { name: 'global.refresh' })) + + const refreshButton = screen.getByRole('button', { name: 'global.refresh' }) + expect(refreshButton).toBeEnabled() + + await user.click(refreshButton) expect(walletInfoRefetch).toHaveBeenCalled() }) @@ -200,11 +242,18 @@ describe('WalletJarsDetailsContent', () => { const user = userEvent.setup() render() + const freezeButton = screen.getByRole('button', { name: 'jar_details.utxo_list.button_freeze' }) + expect(freezeButton).toBeDisabled() + const dataRow = screen.getAllByRole('row').find((row) => within(row).queryByText('bc1qwallet-b'))! await user.click(within(dataRow).getByRole('checkbox')) - await user.click(screen.getByRole('button', { name: 'jar_details.utxo_list.button_freeze' })) - await waitFor(() => expect(toastMocks.success).toHaveBeenCalled()) + expect(freezeButton).toBeEnabled() + + await user.click(freezeButton) + await waitFor(() => + expect(toastMocks.success).toHaveBeenCalledWith('jar_details.utxo_list.toast_freeze_success:{"count":1}'), + ) expect(walletInfoRefetch).toHaveBeenCalled() }) @@ -212,10 +261,39 @@ describe('WalletJarsDetailsContent', () => { const user = userEvent.setup() render() + const unfreezeButton = screen.getByRole('button', { name: 'jar_details.utxo_list.button_unfreeze' }) + expect(unfreezeButton).toBeDisabled() + const dataRow = screen.getAllByRole('row').find((row) => within(row).queryByText('bc1qwallet-c'))! await user.click(within(dataRow).getByRole('checkbox')) - await user.click(screen.getByRole('button', { name: 'jar_details.utxo_list.button_unfreeze' })) - await waitFor(() => expect(toastMocks.success).toHaveBeenCalled()) + expect(unfreezeButton).toBeEnabled() + + await user.click(unfreezeButton) + await waitFor(() => + expect(toastMocks.success).toHaveBeenCalledWith('jar_details.utxo_list.toast_unfreeze_success:{"count":1}'), + ) + expect(walletInfoRefetch).toHaveBeenCalled() + }) + + it.each(['taker', 'maker', 'rescan'])('disables freeze/unfreeze if rescan/maker/taker is running', async (value) => { + const user = userEvent.setup() + mocks.takerRunning = value === 'taker' + mocks.makerRunning = value === 'maker' + mocks.rescanning = value === 'rescan' + + render() + + const freezeButton = screen.getByRole('button', { name: 'jar_details.utxo_list.button_freeze' }) + expect(freezeButton).toBeDisabled() + + const unfreezeButton = screen.getByRole('button', { name: 'jar_details.utxo_list.button_unfreeze' }) + expect(unfreezeButton).toBeDisabled() + + const dataRow = screen.getAllByRole('row').find((row) => within(row).queryByText('bc1qwallet-b'))! + await user.click(within(dataRow).getByRole('checkbox')) + + expect(freezeButton).toBeDisabled() + expect(unfreezeButton).toBeDisabled() }) }) diff --git a/src/components/wallet/WalletJarsDetailsContent.tsx b/src/components/wallet/WalletJarsDetailsContent.tsx index 595f15f3..e088f2fa 100644 --- a/src/components/wallet/WalletJarsDetailsContent.tsx +++ b/src/components/wallet/WalletJarsDetailsContent.tsx @@ -6,6 +6,8 @@ import type { TFunction } from 'i18next' import { AlertTriangleIcon, RefreshCwIcon, ThermometerSnowflakeIcon, ThermometerSunIcon } from 'lucide-react' import { Trans, useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { Balance } from '@/components/ui/jam/Balance' +import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext' import { useAccountSummary, useAddressSummary, @@ -25,7 +27,6 @@ import { Alert, AlertDescription, AlertTitle } from '../ui/alert' import { Button } from '../ui/button' import { ButtonGroup } from '../ui/button-group' import { Input } from '../ui/input' -import { Balance } from '../ui/jam/Balance' import { Spinner } from '../ui/spinner' import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' import { AccountDetailsTabContent } from './AccountDetailsTabContent' @@ -57,6 +58,7 @@ interface UtxosContentProps { export const UtxosContent = ({ enabled, walletFileName, addressSummary, jar }: UtxosContentProps) => { const { t } = useTranslation() + const { takerInfo, rescanInfo, makerInfo } = useJamSessionInfoContext() const { refetch: walletInfoRefetch, isFetching: walletInfoIsFetching } = useJamWalletInfoContext() const client = useApiClient() @@ -127,8 +129,12 @@ export const UtxosContent = ({ enabled, walletFileName, addressSummary, jar }: U return tableEntries.filter((entry) => entry.tags.some((tag) => tag.value === 'reused')).length }, [tableEntries]) - // TODO: makerRunning, takerRunner, rescanRunning, etc. - const operationsEnabled = enabled && !(walletInfoIsFetching || freezeUtxos.isPending || unfreezeUtxos.isPending) + const operationsEnabled = + enabled && + !makerInfo.running && + !takerInfo.running && + !rescanInfo.rescanning && + !(walletInfoIsFetching || freezeUtxos.isPending || unfreezeUtxos.isPending) const enableRowSelection = enabled && !(freezeUtxos.isPending || unfreezeUtxos.isPending) const onFreezeClick = async () => { diff --git a/src/constants/jam.ts b/src/constants/jam.ts index 617282be..13d62497 100644 --- a/src/constants/jam.ts +++ b/src/constants/jam.ts @@ -8,7 +8,7 @@ import { JM_NG_DEFAULT_TUMBLER_PARAMS, JM_WALLET_FILE_EXTENSION, } from './jm' -import { parseAsIntOrDefault } from './meta-env-utils' +import { parseAsBooleanOrDefault, parseAsIntOrDefault } from './meta-env-utils' export const APP_DISPLAY_VERSION = (() => { return parseSemanticVersion(packageInfoVersion) @@ -78,6 +78,54 @@ export const JAM_RESCAN_PROGRESS_INTERVAL: Milliseconds = Math.max( JAM_RESCAN_PROGRESS_MIN_INTERVAL, ) +export const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = Math.max( + parseAsIntOrDefault(import.meta.env.VITE_JAM_WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL, 3_000), + 1_000, +) +export const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = Math.max( + parseAsIntOrDefault(import.meta.env.VITE_JAM_WAIT_FOR_UPDATE_SESSION_POLLING_DELAY, 1_000), + 1, +) + +export const WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL: Milliseconds = Math.max( + parseAsIntOrDefault(import.meta.env.VITE_JAM_WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL, 15_000), + 1_000, +) +export const VISIBLE_ORDERBOOK_POLLING_INTERVAL: Milliseconds = Math.max( + parseAsIntOrDefault(import.meta.env.VITE_JAM_VISIBLE_ORDERBOOK_POLLING_INTERVAL, 120_000), + 1_000, +) + +export const RUNNING_COINJOIN_POLLING_INTERVAL: Milliseconds = Math.max( + parseAsIntOrDefault(import.meta.env.VITE_JAM_RUNNING_COINJOIN_POLLING_INTERVAL, 5_000), + 1_000, +) +export const RUNNING_COINJOIN_POLLING_DELAY: Milliseconds = Math.max( + parseAsIntOrDefault(import.meta.env.VITE_JAM_RUNNING_COINJOIN_POLLING_DELAY, 1_000), + 1, +) + +export const RUNNING_SCHEDULE_POLLING_INTERVAL: Milliseconds = Math.max( + parseAsIntOrDefault(import.meta.env.VITE_JAM_RUNNING_SCHEDULE_POLLING_INTERVAL, 10_000), + 1_000, +) + +/** + * 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. + */ +export const MAKER_STOP_RESPONSE_DELAY: Milliseconds = Math.max( + parseAsIntOrDefault(import.meta.env.VITE_JAM_MAKER_STOP_RESPONSE_DELAY, 2_000), + 1, +) + +export const JMWALLETD_LOGS_POLLING_INTERVAL: Milliseconds = Math.max( + parseAsIntOrDefault(import.meta.env.VITE_JAM_JMWALLETD_LOGS_POLLING_INTERVAL, 2_500), + 500, +) + const JAM_SEED_MODAL_MIN_TIMEOUT: Milliseconds = 5_000 const JAM_SEED_MODAL_DEFAULT_TIMEOUT: Milliseconds = 30_000 export const JAM_SEED_MODAL_TIMEOUT: Milliseconds = Math.max( @@ -183,6 +231,23 @@ export const JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT = Math.max( JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT, ) +const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DEFAULT = true +export const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS = parseAsBooleanOrDefault( + import.meta.env.VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS, + JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DEFAULT, +) + +// provide some time for the backend to catch up after fb is created, small delay of ~1s seems to be enough +export const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DEFAULT_DELAY: Milliseconds = 1000 +export const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_MIN_DELAY: Milliseconds = 21 +export const JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY = Math.max( + parseAsIntOrDefault( + import.meta.env.VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY, + JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DEFAULT_DELAY, + ), + JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_MIN_DELAY, +) + /** * 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 diff --git a/src/constants/meta-env-utils.test.ts b/src/constants/meta-env-utils.test.ts index 0cb7236a..6c2c4d00 100644 --- a/src/constants/meta-env-utils.test.ts +++ b/src/constants/meta-env-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { parseAsIntOrDefault } from './meta-env-utils' +import { parseAsIntOrDefault, parseAsBooleanOrDefault } from './meta-env-utils' describe('parseAsIntOrDefault', () => { it('should parse input as int or default', () => { @@ -23,3 +23,27 @@ describe('parseAsIntOrDefault', () => { expect(parseAsIntOrDefault(Number.MIN_SAFE_INTEGER - 1, 21)).toBe(21) }) }) + +describe('parseAsBooleanOrDefault', () => { + it('should parse input as boolean or default', () => { + expect(parseAsBooleanOrDefault('true', false)).toBe(true) + expect(parseAsBooleanOrDefault('yes', false)).toBe(true) + expect(parseAsBooleanOrDefault('1', false)).toBe(true) + expect(parseAsBooleanOrDefault('false', true)).toBe(false) + expect(parseAsBooleanOrDefault('no', true)).toBe(false) + expect(parseAsBooleanOrDefault('0', true)).toBe(false) + + expect(parseAsBooleanOrDefault(true, false)).toBe(true) + expect(parseAsBooleanOrDefault(false, true)).toBe(false) + + expect(parseAsBooleanOrDefault(1, false)).toBe(true) + expect(parseAsBooleanOrDefault(0, true)).toBe(false) + + expect(parseAsBooleanOrDefault(undefined, true)).toBe(true) + expect(parseAsBooleanOrDefault('', true)).toBe(true) + expect(parseAsBooleanOrDefault(null, true)).toBe(true) + expect(parseAsBooleanOrDefault(String(Number.NaN), true)).toBe(true) + expect(parseAsBooleanOrDefault(Number.MAX_SAFE_INTEGER + 1, true)).toBe(true) + expect(parseAsBooleanOrDefault(Number.MIN_SAFE_INTEGER - 1, true)).toBe(true) + }) +}) diff --git a/src/constants/meta-env-utils.ts b/src/constants/meta-env-utils.ts index 37324584..cc410856 100644 --- a/src/constants/meta-env-utils.ts +++ b/src/constants/meta-env-utils.ts @@ -3,3 +3,28 @@ export const parseAsIntOrDefault = (value: any, defaultValue: number): number => const parsed = Number.parseInt(`${value ?? Number.NaN}`, 10) return Number.isSafeInteger(parsed) ? parsed : defaultValue } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- okay when trying to parse arbitrary value on purpose +export const parseAsBooleanOrDefault = (value: any, defaultValue: boolean): boolean => { + if (typeof value === 'boolean') { + return value + } else if (typeof value === 'string') { + const lowercased = value.trim().toLowerCase() + if (['true', 'yes', '1'].includes(lowercased)) { + return true + } + if (['false', 'no', '0'].includes(lowercased)) { + return false + } + } else { + const asNumber = parseAsIntOrDefault(value, Number.NaN) + if (asNumber === 1) { + return true + } + if (asNumber === 0) { + return false + } + } + + return defaultValue +} diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 150fb238..d307413d 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -13,6 +13,7 @@ export const routes = { orderbook: '/orderbook', logs: '/logs', walletJarsDetails: '/wallet/jars', + txHistory: '/wallet/history', /* walletList: '/', wallet: '/wallet' */ __dev: '/dev', __devErrorExample: '/dev/error-example', diff --git a/src/context/JamDisplayContextProvider.test.tsx b/src/context/JamDisplayContextProvider.test.tsx index acba726c..e2c2d2a4 100644 --- a/src/context/JamDisplayContextProvider.test.tsx +++ b/src/context/JamDisplayContextProvider.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it } from 'vitest' import { jamSettingsStore } from '@/store/jamSettingsStore' +import { withRuntimeLocale } from '@/test/withRuntimeLocale' import { useJamDisplayContext } from './JamDisplayContext' import { JamDisplayContextProvider } from './JamDisplayContextProvider' @@ -24,16 +25,18 @@ describe('JamDisplayContextProvider', () => { }) it('should format amounts with the current display settings', () => { - render( - - - , - ) + withRuntimeLocale('en-US', () => { + render( + + + , + ) - expect(screen.getByTestId('default-amount')).toHaveTextContent('123,456,789') - expect(screen.getByTestId('btc-amount')).toHaveTextContent('1.23456789') - expect(screen.getByTestId('hidden-amount')).toHaveTextContent('*****') - expect(screen.getByTestId('sats-symbol')).toBeInTheDocument() + expect(screen.getByTestId('default-amount')).toHaveTextContent('123,456,789') + expect(screen.getByTestId('btc-amount')).toHaveTextContent('1.23456789') + expect(screen.getByTestId('hidden-amount')).toHaveTextContent('*****') + expect(screen.getByTestId('sats-symbol')).toBeInTheDocument() + }) }) it('should hide default amounts when privacy mode is enabled', () => { diff --git a/src/context/JamWalletInfoContext.ts b/src/context/JamWalletInfoContext.ts index d10a24a3..70b225d2 100644 --- a/src/context/JamWalletInfoContext.ts +++ b/src/context/JamWalletInfoContext.ts @@ -28,6 +28,7 @@ export type Jar = { export type FidelityBondSummary = { fbOutputs: FidelityBondUtxo[] + // TODO: add `numberOfUnfrozenFbOutputs: number` } export type WalletBalanceSummary = BalanceSummary @@ -83,6 +84,7 @@ interface JamWalletInfoContextType { walletName: string | null walletBalanceSummary: WalletBalanceSummary maxJarAvailableBalance: AmountSats + hasEligibleFidelityBondUtxo: boolean fidelityBondSummary: FidelityBondSummary addressSummary: AddressSummary accountSummary: AccountSummary diff --git a/src/context/JamWalletInfoContextProvider.test.tsx b/src/context/JamWalletInfoContextProvider.test.tsx index 27738364..984aed9f 100644 --- a/src/context/JamWalletInfoContextProvider.test.tsx +++ b/src/context/JamWalletInfoContextProvider.test.tsx @@ -1,3 +1,5 @@ +import { sha256 } from '@noble/hashes/sha2.js' +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js' import { render, screen, waitFor } from '@testing-library/react' import { Network } from 'bitcoin-address-validation' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -75,7 +77,7 @@ const utxo = (overrides: Partial): Utxo => ({ ...overrides, }) -const walletInfo = (): WalletInfoApiObject => +const walletInfo = (status = 'new'): WalletInfoApiObject => ({ wallet_name: 'testing.jmdat', total_balance: '0', @@ -89,7 +91,7 @@ const walletInfo = (): WalletInfoApiObject => { address, hd_path: "m/84'/1'/0'/0/0", - status: 'new', + status, label: '', balance: 0, used_count: 0, @@ -145,6 +147,7 @@ describe('', () => { expect(context?.jars.map((jar) => jar.jarIndex)).toEqual([0, 1, 2, 3, 4, 7]) expect(context?.jars.find((jar) => jar.jarIndex === 7)?.name).toBe('Jar #7') expect(context?.fidelityBondSummary.fbOutputs.map((entry) => entry.utxo)).toEqual([`${txid('c')}:2`]) + expect(context?.hasEligibleFidelityBondUtxo).toBe(false) expect(context?.accountSummary[0].branches[0]).toMatchObject({ type: 'external addresses', derivation: "m/84'/1'/0'/0", @@ -160,6 +163,19 @@ describe('', () => { expect(context?.error).toBeNull() }) + it('reports when an eligible fidelity-bond UTXO is available', () => { + let context: ReturnType | undefined + mocks.walletInfo = walletInfo('cj-out') + + render( + + (context = value)} /> + , + ) + + expect(context?.hasEligibleFidelityBondUtxo).toBe(true) + }) + it('handles malformed accounts, missing branches, and entries without a status', () => { let context: ReturnType | undefined mocks.walletInfo = { @@ -269,3 +285,92 @@ describe('', () => { expect(balanceSummary?.calculatedTotalBalanceInSats).toBe(25_000) }) }) + +/** + * Reference implementation of the old O(n²) combinedUtxosHash logic. + * Used only in tests to verify the optimized implementation is a drop-in replacement. + */ +const combinedUtxosHashReference = (utxoList: Utxo[]): string => { + const utxoIds = utxoList.map((it) => hexToBytes(it.utxo.split(':', 1)[0])) + const combinedUtxoIds = new Uint8Array(utxoIds.reduce((acc, current) => [...acc, ...current], [] as number[])) + return bytesToHex(sha256(combinedUtxoIds)) +} + +describe('combinedUtxosHash — optimized vs reference (regression)', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.waitQueryError = null + mocks.walletInfo = undefined + mocks.utxosRefetch.mockResolvedValue({ data: { utxos: [] } }) + mocks.displayWalletRefetch.mockResolvedValue({ data: { walletinfo: undefined } }) + }) + + /** Render the provider with the given UTXO list and capture the computed utxosHashHex. */ + const captureHash = (utxoList: Utxo[]): string => { + mocks.utxos = utxoList + let captured = '' + render( + + (captured = context.utxosHashHex)} /> + , + ) + return captured + } + + it('produces the same hash for an empty UTXO list', () => { + const utxoList: Utxo[] = [] + const actual = captureHash(utxoList) + expect(actual).toBe(combinedUtxosHashReference(utxoList)) + expect(actual).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') + }) + + it('produces the same hash for a single UTXO', () => { + const utxoList = [utxo({ utxo: `${txid('a')}:0` })] + const actual = captureHash(utxoList) + expect(actual).toBe(combinedUtxosHashReference(utxoList)) + expect(actual).toBe('e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e') + }) + + it('is okay for it to produce the same hash for different output in same txid', () => { + const utxoList0 = [utxo({ utxo: `${txid('a')}:0` })] + const actual0 = captureHash(utxoList0) + expect(actual0).toBe(combinedUtxosHashReference(utxoList0)) + expect(actual0).toBe('e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e') + + const utxoList1 = [utxo({ utxo: `${txid('a')}:1` })] + const actual1 = captureHash(utxoList1) + expect(actual1).toBe(combinedUtxosHashReference(utxoList1)) + expect(actual1).toBe('e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e') + expect(actual1).toBe(actual0) + }) + + it('produces the same hash for multiple UTXOs', () => { + const utxoList = [ + utxo({ utxo: `${txid('a')}:0` }), + utxo({ utxo: `${txid('b')}:1` }), + utxo({ utxo: `${txid('c')}:2` }), + ] + + const actual = captureHash(utxoList) + expect(actual).toBe(combinedUtxosHashReference(utxoList)) + expect(actual).toBe('6bdaf8651d78f983ab7ce864414ace095b2942cbe5ff43deade48d51aa34b2cd') + }) + + it('produces the same hash for UTXOs whose txid bytes vary across the full byte range', () => { + const utxoList = [ + utxo({ utxo: `${txid('1')}:0` }), + utxo({ utxo: `${'ab'.repeat(32)}:3` }), + utxo({ utxo: `${'ff'.repeat(32)}:7` }), + ] + expect(captureHash(utxoList)).toBe(combinedUtxosHashReference(utxoList)) + }) + + it('produces the same hash for a large UTXO set (50 entries)', () => { + const chars = '0123456789abcdef' + const utxoList = Array.from({ length: 50 }, (_, i) => { + const ch = chars[i % chars.length] + return utxo({ utxo: `${ch.repeat(64)}:${i}`, mixdepth: i % 5 }) + }) + expect(captureHash(utxoList)).toBe(combinedUtxosHashReference(utxoList)) + }) +}) diff --git a/src/context/JamWalletInfoContextProvider.tsx b/src/context/JamWalletInfoContextProvider.tsx index 1ae1d21a..7962f4f2 100644 --- a/src/context/JamWalletInfoContextProvider.tsx +++ b/src/context/JamWalletInfoContextProvider.tsx @@ -1,11 +1,11 @@ import { useMemo, useState, type Dispatch, type PropsWithChildren, type SetStateAction } from 'react' import { sha256 } from '@noble/hashes/sha2.js' -import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js' +import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils.js' import { CancelledError, useMutation, useQuery } from '@tanstack/react-query' import { getAddressInfo } from 'bitcoin-address-validation' import { useQueryDisplayWallet, type WalletInfoApiObject } from '@/hooks/useQueryDisplayWallet' import { useQueryUtxos, type Utxo, type UtxoId } from '@/hooks/useQueryUtxos' -import { toBalanceSummary } from '@/lib/balanceSummary' +import { BALANCE_SUMMARY_EMPTY, toBalanceSummary } from '@/lib/balanceSummary' import * as fb from '@/lib/fidelityBondUtils' import { delayedPromise, walletDisplayName, type WalletFileName } from '@/lib/utils' import type { JarIndex, Milliseconds } from '@/types/global' @@ -107,7 +107,6 @@ type UtxosByJarIndex = ByJarIndex type JarTemplateByJarIndex = ByJarIndex const EMPTY_UTXOS: Utxo[] = [] -const EMPTY_BALANCE_SUMMARY = toBalanceSummary(EMPTY_UTXOS) type JarTemplate = Pick const jarTemplates: JarTemplate[] = [ @@ -132,7 +131,7 @@ interface JamWalletInfoContextProviderProps { const combinedUtxosHash = (utxos: Utxo[]) => { const utxoIds = utxos.map((it) => hexToBytes(it.utxo.split(':', 1)[0])) - const combinedUtxoIds = new Uint8Array(utxoIds.reduce((acc, current) => [...acc, ...current], [] as number[])) + const combinedUtxoIds = concatBytes(...utxoIds) return sha256(combinedUtxoIds) } @@ -184,7 +183,7 @@ export const JamWalletInfoContextProvider = ({ if (!jarPresent) { jars.push({ ...jarTemplate, - balanceSummary: EMPTY_BALANCE_SUMMARY, + balanceSummary: BALANCE_SUMMARY_EMPTY, utxos: EMPTY_UTXOS, }) } @@ -199,6 +198,9 @@ export const JamWalletInfoContextProvider = ({ : toAccountSummary(displayWalletQuery.walletInfo) const addressSummary = displayWalletQuery.walletInfo === undefined ? EMPTY_ADDRESS_SUMMARY : toAddressSummary(accountSummary) + const hasEligibleFidelityBondUtxo = utxos.some((utxo) => + fb.utxo.isEligibleForCreation(utxo, addressSummary[utxo.address]?.status), + ) const detectedNetwork = useMemo(() => { const eligibleAddress = Object.values(addressSummary).find((it) => it.info !== undefined) @@ -249,6 +251,7 @@ export const JamWalletInfoContextProvider = ({ walletName: walletFileName ? walletDisplayName(walletFileName) : null, walletBalanceSummary: walletBalanceSummary, maxJarAvailableBalance, + hasEligibleFidelityBondUtxo, fidelityBondSummary, addressSummary, accountSummary, @@ -271,13 +274,13 @@ export const JamWalletInfoContextProvider = ({ // Delaying the poll requests gives the wallet some time to synchronize // the utxo set and reduces amount of http requests -const WAIT_FOR_UTXOS_TO_BE_SPENT_DEFAUL_DELAY: Milliseconds = 1_000 +const WAIT_FOR_UTXOS_TO_BE_SPENT_DEFAULT_DELAY: Milliseconds = 1_000 const useWaitForUtxosToBeSpent = ({ refetch, walletFileName, utxos, - delay = WAIT_FOR_UTXOS_TO_BE_SPENT_DEFAUL_DELAY, + delay = WAIT_FOR_UTXOS_TO_BE_SPENT_DEFAULT_DELAY, resetOnErrors = true, }: { refetch: (options?: WalletRefetchOptions) => Promise diff --git a/src/hooks/useQueryOrderbook.test.ts b/src/hooks/useQueryOrderbook.test.ts new file mode 100644 index 00000000..3314324b --- /dev/null +++ b/src/hooks/useQueryOrderbook.test.ts @@ -0,0 +1,47 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useQueryOrderbook } from './useQueryOrderbook' + +const queryMock = vi.fn<(options: unknown) => unknown>() + +vi.mock('@/lib/queryClient', () => ({ + withQueryDelay: (queryFn: unknown) => queryFn, +})) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: (options: unknown): unknown => queryMock(options), +})) + +vi.mock('@/lib/api/orderbook', () => ({ + fetchOrderbook: vi.fn(), +})) + +describe('useQueryOrderbook', () => { + it('returns hasOrders: true when orderbook offers exist', () => { + const mockOrderbook = { + offers: [{ counterparty: 'maker1' }], + } + queryMock.mockReturnValue({ data: mockOrderbook, isLoading: false, isError: false }) + + const { result } = renderHook(() => useQueryOrderbook()) + + expect(result.current.hasOrders).toBe(true) + expect(result.current.queryResult.data).toEqual(mockOrderbook) + }) + + it('returns hasOrders: false when orderbook offers array is empty', () => { + queryMock.mockReturnValue({ data: { offers: [] }, isLoading: false, isError: false }) + + const { result } = renderHook(() => useQueryOrderbook()) + + expect(result.current.hasOrders).toBe(false) + }) + + it('returns hasOrders: false when data is undefined', () => { + queryMock.mockReturnValue({ data: undefined, isLoading: false, isError: false }) + + const { result } = renderHook(() => useQueryOrderbook()) + + expect(result.current.hasOrders).toBe(false) + }) +}) diff --git a/src/hooks/useQueryOrderbook.ts b/src/hooks/useQueryOrderbook.ts new file mode 100644 index 00000000..1249d4f6 --- /dev/null +++ b/src/hooks/useQueryOrderbook.ts @@ -0,0 +1,28 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query' +import { fetchOrderbook, type OrderbookResponse } from '@/lib/api/orderbook' +import { withQueryDelay } from '@/lib/queryClient' + +export type UseQueryOrderbookResult = { + hasOrders: boolean + queryResult: UseQueryResult +} + +export function useQueryOrderbook( + options: Omit>[0], 'queryFn' | 'queryKey'> = {}, +): UseQueryOrderbookResult { + const queryResult = useQuery({ + queryKey: ['orderbook-precheck'], + queryFn: withQueryDelay(fetchOrderbook, { + // avoid flickering and let user briefly know that something is happening in the background + delayBefore: 1_000, + }), + retry: false, + staleTime: 30 * 1_000, + ...options, + }) + + return { + hasOrders: (queryResult.data?.offers.length ?? 0) > 0, + queryResult, + } +} diff --git a/src/hooks/useQueryWalletHistory.test.ts b/src/hooks/useQueryWalletHistory.test.ts new file mode 100644 index 00000000..440e5fa8 --- /dev/null +++ b/src/hooks/useQueryWalletHistory.test.ts @@ -0,0 +1,72 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { WalletFileName } from '@/lib/utils' +import { useQueryWalletHistory } from './useQueryWalletHistory' + +const queryMock = vi.fn<(options: unknown) => unknown>() + +vi.mock('@tanstack/react-query', () => ({ + useQuery: (options: unknown) => queryMock(options), +})) + +vi.mock('zustand', () => ({ + useStore: () => ({ session: 'mock-session' }), +})) + +vi.mock('@/hooks/useApiClient', () => ({ + useApiClient: () => ({}), +})) + +vi.mock('@/constants/debugFeatures', () => ({ + isDevMode: () => false, +})) + +vi.mock('@/lib/queryClient', () => ({ + withQueryDelay: (function_: unknown) => function_, +})) + +vi.mock('@/store/jmSessionStore', () => ({ + jmSessionStore: {}, +})) + +vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({ + wallethistoryOptions: () => ({ queryKey: ['history'], queryFn: vi.fn() }), +})) + +describe('useQueryWalletHistory', () => { + it('returns history and queryResult when data exists', () => { + const mockHistory = [{ txid: 'tx1', timestamp: '2026-07-28' }] + queryMock.mockReturnValue({ data: { history: mockHistory } }) + + const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat' })) + + expect(result.current.history).toEqual(mockHistory) + expect(result.current.queryResult.data?.history).toEqual(mockHistory) + }) + + it('returns empty array when data does not exist', () => { + queryMock.mockReturnValue({ data: null }) + + const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat' })) + + expect(result.current.history).toEqual([]) + }) + + it('is not enabled if walletFileName is empty', () => { + queryMock.mockImplementation((options) => options) + + const { result } = renderHook(() => + useQueryWalletHistory({ walletFileName: undefined as unknown as WalletFileName }), + ) + + expect((result.current.queryResult as { enabled?: boolean }).enabled).toBe(false) + }) + + it('is not enabled if explicitly disabled', () => { + queryMock.mockImplementation((options) => options) + + const { result } = renderHook(() => useQueryWalletHistory({ walletFileName: 'wallet.jmdat', enabled: false })) + + expect((result.current.queryResult as { enabled?: boolean }).enabled).toBe(false) + }) +}) diff --git a/src/hooks/useQueryWalletHistory.ts b/src/hooks/useQueryWalletHistory.ts new file mode 100644 index 00000000..978030cb --- /dev/null +++ b/src/hooks/useQueryWalletHistory.ts @@ -0,0 +1,67 @@ +import { useEffect } from 'react' +import { wallethistoryOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' +import type { HistoryEntry, WalletHistoryResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' +import { useQuery, type UseQueryResult } from '@tanstack/react-query' +import { useStore } from 'zustand' +import { isDevMode } from '@/constants/debugFeatures' +import { useApiClient } from '@/hooks/useApiClient' +import { withQueryDelay } from '@/lib/queryClient' +import type { WalletFileName } from '@/lib/utils' +import { jmSessionStore } from '@/store/jmSessionStore' + +export type { HistoryEntry } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' + +export type UseQueryWalletHistoryResult = { + history: HistoryEntry[] + queryResult: UseQueryResult +} + +interface UseQueryWalletHistoryProps { + walletFileName: WalletFileName + limit?: number + enabled?: boolean + utxosHashHex?: string +} + +const EMPTY_HISTORY: HistoryEntry[] = [] + +export function useQueryWalletHistory({ + walletFileName, + limit, + enabled = true, + utxosHashHex = '', +}: UseQueryWalletHistoryProps): UseQueryWalletHistoryResult { + const client = useApiClient() + const jmSession = useStore(jmSessionStore, (state) => state.state?.session) + + const queryOptions = wallethistoryOptions({ + client, + path: { walletname: walletFileName || '' }, + query: { limit }, + meta: { + cacheBuster: utxosHashHex, + }, + }) + + const queryResult = useQuery({ + ...queryOptions, + queryFn: withQueryDelay(queryOptions.queryFn, { + // simulate slow mainnet responses in dev mode + throttle: isDevMode() ? 210 : 0, + }), + enabled: enabled && !!walletFileName && !!jmSession, + }) + + const { refetch } = queryResult + + useEffect(() => { + if (utxosHashHex && enabled) { + void refetch() + } + }, [utxosHashHex, enabled, refetch]) + + return { + history: queryResult.data?.history ?? EMPTY_HISTORY, + queryResult, + } +} diff --git a/src/hooks/useUtxoSelectionDialog.test.ts b/src/hooks/useUtxoSelectionDialog.test.ts index ba49cb01..0fde5cd4 100644 --- a/src/hooks/useUtxoSelectionDialog.test.ts +++ b/src/hooks/useUtxoSelectionDialog.test.ts @@ -65,6 +65,15 @@ const utxo = (overrides: Partial): Utxo => ({ ...overrides, }) +const fbUtxo = (overrides: Partial) => + utxo({ + utxo: 'fb:0', + address: 'bcrt1qaddressfb', + locktime: '2999-01-01 00:00:00', + path: "m/84'/1'/0'/2/0:32503680000", + ...overrides, + }) + const addressSummary: AddressSummary = { bcrt1qaddressa: { status: 'cj-out' }, bcrt1qaddressb: { status: 'change-out' }, @@ -76,15 +85,10 @@ const sourceJar = { color: '#e2b86a', balanceSummary: {}, utxos: [ - utxo({ utxo: 'a:0', address: 'bcrt1qaddressa', frozen: false, label: 'keep together' }), + utxo({ utxo: 'a:0', address: 'bcrt1qaddressa', label: 'keep together' }), utxo({ utxo: 'a:1', address: 'bcrt1qaddressa', frozen: true }), - utxo({ utxo: 'b:0', address: 'bcrt1qaddressb', frozen: false }), - utxo({ - utxo: 'fb:0', - address: 'bcrt1qaddressfb', - locktime: '2999-01-01 00:00:00', - path: "m/84'/1'/0'/0/9:32503680000", - }), + utxo({ utxo: 'b:0', address: 'bcrt1qaddressb' }), + fbUtxo({}), ], } as unknown as Jar @@ -162,15 +166,16 @@ describe('useUtxoSelectionDialog', () => { path: { walletname: 'wallet.jmdat' }, body: { 'utxo-string': 'a:1', freeze: false }, }) - expect(mocks.toastSuccess).toHaveBeenCalledWith('jar_details.utxo_list.toast_freeze_success:1') + expect(mocks.toastSuccess).toHaveBeenCalledWith('jar_details.utxo_list.toast_freeze_success:2') expect(mocks.toastSuccess).toHaveBeenCalledWith('jar_details.utxo_list.toast_unfreeze_success:1') + expect(mocks.walletInfoRefetch).toHaveBeenCalledOnce() await waitFor(() => expect(result.current.dialogProps.open).toBe(false)) }) it('closes without mutations when selection does not change freeze state', async () => { const jarWithoutFrozenGroupedUtxos = { ...sourceJar, - utxos: sourceJar.utxos.filter((entry) => entry.utxo !== 'a:1'), + utxos: [utxo({ utxo: 'a:0' }), utxo({ utxo: 'b:0' })], } const { result } = renderHook(() => useUtxoSelectionDialog({ @@ -187,4 +192,38 @@ describe('useUtxoSelectionDialog', () => { expect(mocks.walletInfoRefetch).not.toHaveBeenCalled() expect(result.current.dialogProps.open).toBe(false) }) + + it('does not pre-select fidelity bonds and freezes unfrozen fidelity bonds by default', async () => { + const jarWithUnfrozenFidelityBond = { + ...sourceJar, + utxos: [ + utxo({ utxo: 'a:0' }), + fbUtxo({ utxo: 'b:0', address: 'fb0', frozen: false }), + fbUtxo({ utxo: 'c:0', address: 'fb1', frozen: true }), + ], + } + const { result } = renderHook(() => + useUtxoSelectionDialog({ + walletFileName: 'wallet.jmdat', + sourceJar: jarWithUnfrozenFidelityBond, + addressSummary, + }), + ) + + act(() => result.current.onOpenUtxoSelector()) + await act(async () => result.current.dialogProps.onSubmit()) + + expect(mocks.freezeOrUnfreeze).toHaveBeenCalledExactlyOnceWith({ + body: { + freeze: true, + 'utxo-string': 'b:0', + }, + path: { + walletname: 'wallet.jmdat', + }, + }) + + expect(mocks.walletInfoRefetch).toHaveBeenCalledOnce() + expect(result.current.dialogProps.open).toBe(false) + }) }) diff --git a/src/hooks/useUtxoSelectionDialog.ts b/src/hooks/useUtxoSelectionDialog.ts index 369837c3..0346155a 100644 --- a/src/hooks/useUtxoSelectionDialog.ts +++ b/src/hooks/useUtxoSelectionDialog.ts @@ -1,10 +1,17 @@ -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { yupResolver } from '@hookform/resolvers/yup' import { freezeMutation } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' import { useMutation } from '@tanstack/react-query' import type { RowSelectionState } from '@tanstack/react-table' +import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import type { UtxoSelectionDialogProps } from '@/components/send/UtxoSelectionDialog' +import { + createUtxoSelectionFormSchema, + UTXO_SELECTION_FORM_DEFAULT_VALUES, + type UtxoSelectionFormValues, +} from '@/components/send/UtxoSelectionDialog.schema' import { useJamWalletInfoContext, type AddressSummary, type Jar } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' import type { Utxo } from '@/hooks/useQueryUtxos' @@ -26,8 +33,6 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma const { refetch: walletInfoRefetch } = useJamWalletInfoContext() const [open, setOpen] = useState(false) - const [filter, setFilter] = useState('') - const [rowSelection, setRowSelection] = useState({}) const tableEntries = useMemo(() => { return (sourceJar?.utxos || []).map((utxo) => ({ @@ -45,6 +50,23 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma }, {} as RowSelectionState) }, [sourceJar?.utxos]) + const selectableUtxoIds = useMemo(() => (sourceJar?.utxos || []).map((utxo) => utxo.utxo), [sourceJar?.utxos]) + const formSchema = useMemo(() => createUtxoSelectionFormSchema(selectableUtxoIds), [selectableUtxoIds]) + const { + control, + getValues, + handleSubmit, + reset, + setValue, + formState: { isSubmitting: isFormSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues: UTXO_SELECTION_FORM_DEFAULT_VALUES, + resolver: yupResolver(formSchema), + }) + const filter = useWatch({ control, name: 'filter', defaultValue: '' }) + const rowSelection = useWatch({ control, name: 'rowSelection', defaultValue: {} }) + const selectedUtxos = useMemo(() => { return (sourceJar?.utxos || []).filter((utxo) => rowSelection[utxo.utxo] === true) }, [sourceJar?.utxos, rowSelection]) @@ -56,7 +78,7 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma retry: false, }) - const { mutateAsync: applyUtxoSelectionMutateAsync, isPending: isSubmitting } = useMutation({ + const { mutateAsync: applyUtxoSelectionMutateAsync, isPending: isMutationPending } = useMutation({ mutationFn: async ({ utxosToFreeze, utxosToUnfreeze }: { utxosToFreeze: Utxo[]; utxosToUnfreeze: Utxo[] }) => { const [freezeResult, unfreezeResult] = await Promise.all([ Promise.allSettled( @@ -91,28 +113,31 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma }, }) - const onOpenUtxoSelector = () => { + const onOpenUtxoSelector = useCallback(() => { if (!sourceJar) return toast.dismiss(SEND_AUTO_SELECTION_TOAST_ID) - setFilter('') - setRowSelection(initialRowSelection) + reset({ + filter: '', + rowSelection: initialRowSelection, + }) setOpen(true) - } + }, [sourceJar, reset, initialRowSelection]) - const onSubmit = async () => { + const onSubmit = handleSubmit(async ({ rowSelection: submittedRowSelection }) => { if (!sourceJar) return - const selectedUtxoIds = new Set(selectedUtxos.map((it) => it.utxo)) - const selectedAddresses = new Set(selectedUtxos.map((it) => it.address)) - const mutableUtxos = sourceJar.utxos.filter((it) => !fb.utxo.isFidelityBond(it)) + const submittedUtxos = sourceJar.utxos.filter((utxo) => submittedRowSelection[utxo.utxo] === true) + const selectedUtxoIds = new Set(submittedUtxos.map((it) => it.utxo)) + const selectedAddresses = new Set(submittedUtxos.map((it) => it.address)) + const mutableUtxos = sourceJar.utxos const groupedSelectedUtxos = mutableUtxos.filter((it) => selectedAddresses.has(it.address)) const groupedDeselectedUtxos = mutableUtxos.filter((it) => !selectedAddresses.has(it.address)) const userDeselectedUtxos = mutableUtxos.filter((it) => !selectedUtxoIds.has(it.utxo)) - if (groupedSelectedUtxos.length > selectedUtxos.length) { + if (groupedSelectedUtxos.length > submittedUtxos.length) { toast.warning(t('jar_details.utxo_list.toast_auto_selection_title'), { description: t('jar_details.utxo_list.toast_auto_selected_matching', { - count: groupedSelectedUtxos.length - selectedUtxos.length, + count: groupedSelectedUtxos.length - submittedUtxos.length, }), id: SEND_AUTO_SELECTION_TOAST_ID, }) @@ -128,7 +153,7 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma } // The selected set should remain spendable; everything else becomes frozen. - const utxosToFreeze = mutableUtxos.filter((it) => !selectedAddresses.has(it.address) && it.frozen === false) + const utxosToFreeze = mutableUtxos.filter((it) => !selectedAddresses.has(it.address) && it.frozen !== true) const utxosToUnfreeze = mutableUtxos.filter((it) => selectedAddresses.has(it.address) && it.frozen === true) if (utxosToFreeze.length === 0 && utxosToUnfreeze.length === 0) { @@ -178,7 +203,9 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma toast.error(t('jar_details.utxo_list.toast_unfreeze_error', { count: utxosToUnfreeze.length })) } } - } + }) + + const isSubmitting = isFormSubmitting || isMutationPending const dialogProps: UtxoSelectionDialogProps = { open, @@ -190,10 +217,25 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma enableRowSelection: isSubmitting ? false : undefined, onOpenChange: (nextOpen: boolean) => { if (isSubmitting) return + if (!nextOpen) { + reset(UTXO_SELECTION_FORM_DEFAULT_VALUES) + } setOpen(nextOpen) }, - onFilterChange: setFilter, - onRowSelectionChange: setRowSelection, + onFilterChange: (nextFilter) => + setValue('filter', nextFilter, { + shouldDirty: true, + shouldValidate: true, + }), + onRowSelectionChange: (updater) => { + const currentSelection = getValues('rowSelection') + const nextSelection = typeof updater === 'function' ? updater(currentSelection) : updater + setValue('rowSelection', nextSelection, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + }, onSubmit, } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 1a2d3716..b5bb57a2 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -80,6 +80,9 @@ "item_jars": { "label": "Jars" }, + "item_history": { + "label": "History" + }, "item_tour": { "label": "Tour" }, @@ -290,6 +293,8 @@ "use_address_chunking_enabled": "Address display chunking enabled", "use_address_chunking_disabled": "Address display chunking disabled", "label_select_language": "Language", + "label_select_language_title": "Select language", + "label_select_language_aria_label": "$t(settings.label_select_language_title)", "show_seed": "Show seed phrase", "show_xpubs": "Show account xpubs", "reveal_seed": "Reveal seed phrase", @@ -335,7 +340,7 @@ "text_info_title": "Keep in mind", "text_info_message": "Account-level extended public keys can be imported into watch-only wallets for monitoring. They reveal all addresses in the derivation path. Share them with caution.", "button_copy_title": "Copy Extended Public Key for {{account}}", - "button_reveal_xpub_qrcode": "Reveal xpub QR-Code" + "button_reveal_xpub_qrcode": "Reveal QR-Code" }, "documentation": "Documentation", "github": "GitHub", @@ -440,6 +445,10 @@ "qr_scan_file_no_qr": "No QR code found in the selected image.", "qr_scan_bip21_applied": "Payment request applied.", "feedback_invalid_source_jar": "Please select a jar to send from.", + "feedback_invalid_source_jar_must_unfreeze_utxos": "Please select UTXOs from the source jar.", + "feedback_invalid_source_jar_must_freeze_fidelity_bond_one": "Please freeze your Fidelity Bond in the source jar before you continue.", + "feedback_invalid_source_jar_must_freeze_fidelity_bond_other": "Please freeze all Fidelity Bonds in the source jar before you continue.", + "feedback_invalid_source_jar_no_available_utxos": "Please select a jar with spendable balance to send from.", "feedback_invalid_destination_address": "Please enter a valid destination address.", "feedback_destination_network_mismatch": "This address does not match your wallet's network.", "feedback_reused_address": "This address is already used. To preserve your privacy please choose another one.", @@ -487,9 +496,12 @@ "text_body": "Are you sure you want to abort the collaborative transaction?" }, "coinjoin_precondition": { + "title": "Collaborative Transaction preconditions are not fulfilled", "hint_missing_utxos": "To execute a collaborative transaction you need UTXOs with {{ minConfirmations }} or more confirmations in the source jar. $t(send.coinjoin_precondition.nested_hint_fund_jar, {\"count\": {{ minConfirmations }} })", "hint_missing_confirmations": "A collaborative transaction requires your UTXOs to have {{ minConfirmations }} or more confirmations. $t(send.coinjoin_precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })", - "hint_missing_retries": "Failed at sourcing commitments for this jar too many times in a row. For security reasons, you need a fresh UTXO in the jar to try again. See <1>the docs for more information.", + "hint_missing_retries": "Failed at sourcing commitments for this jar too many times in a row. For security reasons, you need a fresh UTXO in the jar to try again. <1>See the documentation for more information.", + "hint_non_frozen_fidelity_bond_one": "<1>Please freeze your Fidelity Bond before you continue.", + "hint_non_frozen_fidelity_bond_other": "<1>Please freeze all Fidelity Bonds before you continue.", "nested_hint_wait_for_block": "Select another jar to send from or wait for one more block.", "nested_hint_wait_for_block_other": "Select another jar to send from or wait for {{ count }} more blocks.", "nested_hint_fund_jar": "Select another jar to send from or fund this jar and wait for one block.", @@ -528,10 +540,6 @@ "alert_start_failed": "Error while starting the earn process. Reason: {{ reason }}", "alert_loading_offer": "Loading offer...", "alert_running": "Offering sats to earn more sats...", - "alert_unconfirmed_balance_title": "Balance is not confirmed yet", - "alert_unconfirmed_balance_description": "Your wallet has available funds, but they need at least one confirmation before the earn service can start. This should update automatically after the next block.", - "alert_no_spendable_balance_title": "No spendable balance available", - "alert_no_spendable_balance_description": "<0>Fund your wallet to start earning.", "radio_abs_offer_label": "Absolute offer", "radio_rel_offer_label": "Relative offer", "label_rel_fee": "Relative fee {{ fee }}", @@ -551,6 +559,13 @@ "text_starting": "Starting", "text_stopping": "Stopping", "button_show_report": "Show earnings report", + "precondition": { + "title": "Earn preconditions are not fulfilled", + "hint_missing_utxos": "No spendable balance available: <0>Fund your wallet to start earning.", + "hint_missing_confirmations_one": "Balance is not confirmed yet: Your wallet has available funds, but they need at least one more confirmation before the earn service can start. Please wait for one more block.", + "hint_missing_confirmations_other": "Balance is not confirmed yet: Your wallet has available funds, but they need at least {{count}} confirmations before the earn service can start. Please wait for {{count}} more blocks.", + "hint_non_frozen_fidelity_bond": "$t(send.coinjoin_precondition.hint_non_frozen_fidelity_bond, {\"count\": {{ count }} })" + }, "current": { "text_offer": "Offer", "text_cjfee": "Fee", @@ -559,7 +574,14 @@ "text_txfee": "Transaction Fee", "text_offer_id": "Offer ID", "text_offer_type_absolute": "absolute", - "text_offer_type_relative": "relative" + "text_offer_type_relative": "relative", + "text_orderbook_checking": "Checking local orderbook...", + "text_orderbook_visible": "Visible in local orderbook", + "text_orderbook_missing": "Not found in local orderbook yet", + "text_orderbook_error": "Could not check local orderbook", + "text_fidelity_bond": "Fidelity Bond", + "text_bond_value": "Bond value", + "text_bond_locktime": "locked until {{date}}" }, "report": { "title": "Earnings Report", @@ -575,7 +597,6 @@ "heading_earned": "Earned", "heading_notes": "Notes", "text_button_generate_demo_report": "Generate demo entry", - "text_button_download_csv": "Download", "stats": { "earned_total": "Earned (total)", "earned_90days": "Earned (90 days)", @@ -665,6 +686,8 @@ "create_form": { "confirmation_toggle_title": "I have reviewed my inputs carefully", "confirmation_toggle_subtitle": "I understand that the locked funds will be inaccessible for the given duration", + "alert_no_eligible_utxos_title": "No eligible UTXOs", + "alert_no_eligible_utxos_description": "A Fidelity Bond must be funded by an unfrozen collaborative transaction output (cj-out). <1>Complete a collaborative transaction or <3>unfreeze an eligible UTXO to continue.", "button_create": "Create Fidelity Bond" }, "existing": { @@ -741,6 +764,10 @@ "text_orderbook_summary_filtered_one": "found {{ count }} offer by {{ counterpartyCount }} counterparty", "text_orderbook_summary_filtered_other": "found {{ count }} offers by {{ counterpartyCount }} counterparties", "alert_empty_orderbook": "Orderbook is empty", + "alert_precheck_empty_title": "No active makers found", + "alert_precheck_empty_description": "Your local Orderbook appears to be empty right now. Collaborative transactions might fail. Please check <1>your local Orderbook.", + "alert_precheck_empty_text_button_check": "Check again", + "alert_precheck_empty_text_button_checking": "Checking...", "error_loading_orderbook_failed": "Error while loading the orderbook. Your current local setup might not support fetching the orderbook. Reason: {{ reason }}", "text_offer_type_absolute": "absolute", "text_offer_type_relative": "relative", @@ -783,6 +810,7 @@ "complete_wallet_subtitle": "This will use all of your non-frozen funds.", "description_destination_addresses": "A Scheduled Sweep will send all available funds to multiple destinations, splitting them up in random chunks.", "feedback_invalid_destination_address": "Please enter a valid destination address.", + "feedback_destination_network_mismatch": "$t(send.feedback_destination_network_mismatch)", "feedback_reused_destination_address": "This address is already used. To preserve your privacy please choose another one.", "label_destination_input": "Destination {{ destination }}", "placeholder_destination_input": "Enter destination address...", @@ -805,9 +833,11 @@ "progress_entry_wait_before_next_title": "Wait period before next action", "progress_done_awaiting_stop": "All actions completed successfully. The Scheduled Sweep will stop soon.", "precondition": { + "title": "Scheduled Sweep preconditions are not fulfilled", "hint_missing_utxos": "A Scheduled Sweep needs UTXOs with {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })", "hint_missing_confirmations": "A Scheduled Sweep requires your UTXOs to have {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })", - "hint_missing_retries": "Failed at sourcing commitments too many times. For security reasons, you need a fresh UTXO in the jar to try again. See <1>the docs for more information.", + "hint_missing_retries": "Failed at sourcing commitments too many times. For security reasons, you need a fresh UTXO in the jar to try again. <1>See the documentation for more information.", + "hint_non_frozen_fidelity_bond": "$t(send.coinjoin_precondition.hint_non_frozen_fidelity_bond, {\"count\": {{ count }} })", "nested_hint_wait_for_block": "Wait for one more block.", "nested_hint_wait_for_block_other": "Wait for {{ count }} more blocks.", "nested_hint_fund_wallet": "Fund your wallet and wait for one more block.", @@ -886,5 +916,30 @@ "subtitle_one": "The selected UTXO is eligible for inclusion in the transaction.", "subtitle_other": "The selected UTXOs are eligible for inclusion in the transaction.", "text_subtitle_addon": "Unselected UTXOs will be frozen and can be unfrozen later anytime." + }, + "tx_history": { + "title": "Transaction History (Experimental)", + "section_title": "Recent Activity", + "overlay_title": "Transaction History (Experimental)", + "button_view_all": "View All", + "button_load_more": "Load More", + "button_generate_demo_entry": "Add Demo Entry", + "button_reload_title": "Reload", + "expand_history": "Show transaction history", + "collapse_history": "Hide transaction history", + "empty_history": "No transactions found.", + "error_loading": "Failed to load transaction history.", + "copy_txid_success": "Transaction ID copied.", + "copy_txid_error": "Failed to copy transaction ID.", + "column_title_date": "Date", + "column_title_role": "Type", + "column_title_amount": "Amount", + "column_title_net_fee": "Net Fee", + "column_title_confirmations": "Confs.", + "column_title_txid": "Transaction", + "label_role_maker": "Maker", + "label_role_taker": "Taker", + "label_role_send": "Send", + "label_role_deposit": "Deposit" } } diff --git a/src/lib/api/orderbook.ts b/src/lib/api/orderbook.ts index 5fe18b8d..388f6108 100644 --- a/src/lib/api/orderbook.ts +++ b/src/lib/api/orderbook.ts @@ -5,11 +5,18 @@ export interface OrderbookOffer { counterparty: string oid: number ordertype: OfferType - minsize: AmountSats | null | undefined - maxsize: AmountSats | null | undefined - txfee: number | null | undefined - cjfee: number | null | undefined - fidelity_bond_value: number | null | undefined + minsize?: AmountSats | null + maxsize?: AmountSats | null + txfee?: number | null + cjfee?: number | null + fidelity_bond_value?: number | null + fidelity_bond_verification_stale?: boolean | null + directory_nodes?: string[] | null + directly_reachable?: boolean | null + // TODO: many more fields, e.g.: + // fidelity_bond_value + // fidelity_bond_verified + // features { neutrino_compat: true, nick_auth: true, peerlist_features: true, … } } export interface OrderbookFidelityBond { diff --git a/src/lib/balanceSummary.test.ts b/src/lib/balanceSummary.test.ts index b7e1f620..b12ce76e 100644 --- a/src/lib/balanceSummary.test.ts +++ b/src/lib/balanceSummary.test.ts @@ -5,7 +5,7 @@ import { toBalanceSummary } from './balanceSummary' const now = Date.UTC(2009, 0, 3) describe('BalanceSummary', () => { - it('should populate balance properties calculated from utxo data', () => { + it('should calculate balances from utxo data', () => { const balanceSummary = toBalanceSummary( [ { @@ -40,14 +40,16 @@ describe('BalanceSummary', () => { ], now, ) - - expect(balanceSummary.calculatedTotalBalanceInSats).toBe(11) - expect(balanceSummary.calculatedAvailableBalanceInSats).toBe(6) - expect(balanceSummary.calculatedConfirmedAvailableBalanceInSats).toBe(1) - expect(balanceSummary.calculatedFrozenOrLockedBalanceInSats).toBe(5) + expect(balanceSummary).toEqual({ + calculatedTotalBalanceInSats: 11, + calculatedAvailableBalanceInSats: 6, + calculatedConfirmedAvailableBalanceInSats: 1, + calculatedAvailableFrozenBalanceInSats: 3, + calculatedFrozenOrLockedBalanceInSats: 5, + }) }) - it('should populate account balance data', () => { + it('should calculate balances from utxo data (bondless)', () => { const balanceSummary = toBalanceSummary( [ { @@ -71,13 +73,73 @@ describe('BalanceSummary', () => { confirmations: 0, frozen: true, } as Utxo, + { + value: 0, + mixdepth: 4, + confirmations: 1, + frozen: true, + } as Utxo, ], now, ) - expect(balanceSummary.calculatedTotalBalanceInSats).toBe(677777777) - expect(balanceSummary.calculatedAvailableBalanceInSats).toBe(333333333) - expect(balanceSummary.calculatedConfirmedAvailableBalanceInSats).toBe(111111111) - expect(balanceSummary.calculatedFrozenOrLockedBalanceInSats).toBe(344444444) + expect(balanceSummary).toEqual({ + calculatedTotalBalanceInSats: 677777777, + calculatedAvailableBalanceInSats: 333333333, + calculatedConfirmedAvailableBalanceInSats: 111111111, + calculatedAvailableFrozenBalanceInSats: 344444444, + calculatedFrozenOrLockedBalanceInSats: 344444444, + }) + }) + + it('should calculate balances from utxo data (bonds only)', () => { + const balanceSummary = toBalanceSummary( + [ + { + value: 1, + mixdepth: 0, + confirmations: 1, + frozen: true, + // frozen and not yet expired + locktime: '2999-12-01 00:00:00', + path: `m/84'/1'/0'/0/2:${now / 1_000 + 1}`, + } as Utxo, + { + value: 2, + mixdepth: 0, + frozen: false, + // unfrozen but not yet expired + locktime: '2999-12-01 00:00:00', + path: `m/84'/1'/0'/0/2:${now / 1_000 + 1}`, + } as Utxo, + { + value: 3, + mixdepth: 0, + confirmations: 1, + // frozen and expired + frozen: true, + locktime: '2009-01-01 00:00:00', + path: `m/84'/1'/0'/0/2:${now / 1_000 - 1}`, + } as Utxo, + { + value: 5, + mixdepth: 0, + confirmations: 0, + // unfrozen and expired + frozen: false, + locktime: '2009-01-01 00:00:00', + path: `m/84'/1'/0'/0/2:${now / 1_000 - 1}`, + } as Utxo, + ], + now, + ) + + expect(balanceSummary).toEqual({ + calculatedTotalBalanceInSats: 11, + calculatedAvailableBalanceInSats: 5, + calculatedConfirmedAvailableBalanceInSats: 0, + calculatedAvailableFrozenBalanceInSats: 3, + calculatedFrozenOrLockedBalanceInSats: 6, + }) }) }) diff --git a/src/lib/balanceSummary.ts b/src/lib/balanceSummary.ts index 84bf3e95..69040e06 100644 --- a/src/lib/balanceSummary.ts +++ b/src/lib/balanceSummary.ts @@ -16,31 +16,51 @@ export type BalanceSummary = { * @description Manually calculated confirmed available balance in sats. */ calculatedConfirmedAvailableBalanceInSats: AmountSats + /** + * @description Manually calculated frozen balance in sats, that can be unfrozen. + */ + calculatedAvailableFrozenBalanceInSats: AmountSats /** * @description Manually calculated frozen or locked balance in sats. */ calculatedFrozenOrLockedBalanceInSats: AmountSats } -const calculateFrozenOrLockedBalance = (utxos: Utxo[], refTime: Milliseconds = Date.now()) => { - const frozenOrLockedUtxos = utxos.filter((utxo) => utxo.frozen || fb.utxo.isLocked(utxo, refTime)) - return frozenOrLockedUtxos.reduce((acc, utxo) => acc + utxo.value, 0) -} +export const BALANCE_SUMMARY_EMPTY: BalanceSummary = Object.freeze({ + calculatedTotalBalanceInSats: 0, + calculatedAvailableBalanceInSats: 0, + calculatedConfirmedAvailableBalanceInSats: 0, + calculatedAvailableFrozenBalanceInSats: 0, + calculatedFrozenOrLockedBalanceInSats: 0, +}) export const toBalanceSummary = (utxos: Utxo[], now?: Milliseconds): BalanceSummary => { const refTime = now !== undefined ? now : Date.now() - - const walletTotalCalculated: AmountSats = utxos.reduce((acc, utxo) => acc + utxo.value, 0) - const walletFrozenOrLockedCalculated: AmountSats = calculateFrozenOrLockedBalance(utxos, refTime) - const walletAvailableCalculated = walletTotalCalculated - walletFrozenOrLockedCalculated - const walletConfirmedAvailableCalculated = utxos - .filter((utxo) => !utxo.frozen && !fb.utxo.isLocked(utxo, refTime) && utxo.confirmations > 0) - .reduce((acc, utxo) => acc + utxo.value, 0) - - return { - calculatedTotalBalanceInSats: walletTotalCalculated, - calculatedFrozenOrLockedBalanceInSats: walletFrozenOrLockedCalculated, - calculatedAvailableBalanceInSats: walletAvailableCalculated, - calculatedConfirmedAvailableBalanceInSats: walletConfirmedAvailableCalculated, - } + return utxos + .map((utxo) => { + const isFidelityBond = fb.utxo.isFidelityBond(utxo) + const isLocked = isFidelityBond && fb.utxo.isLocked(utxo, refTime) + const frozen = utxo.frozen && !isLocked + const frozenOrLocked = frozen || isLocked + const available = !frozenOrLocked + return { + total: utxo.value, + available: available ? utxo.value : 0, + confirmedAvailable: available && utxo.confirmations > 0 ? utxo.value : 0, + frozenOrLocked: frozenOrLocked ? utxo.value : 0, + frozen: frozen ? utxo.value : 0, + bond: isFidelityBond ? utxo.value : 0, + } + }) + .reduce( + (acc, info) => ({ + calculatedTotalBalanceInSats: acc.calculatedTotalBalanceInSats + info.total, + calculatedAvailableBalanceInSats: acc.calculatedAvailableBalanceInSats + info.available, + calculatedConfirmedAvailableBalanceInSats: + acc.calculatedConfirmedAvailableBalanceInSats + info.confirmedAvailable, + calculatedAvailableFrozenBalanceInSats: acc.calculatedAvailableFrozenBalanceInSats + info.frozen, + calculatedFrozenOrLockedBalanceInSats: acc.calculatedFrozenOrLockedBalanceInSats + info.frozenOrLocked, + }), + BALANCE_SUMMARY_EMPTY, + ) } diff --git a/src/lib/bip21.test.ts b/src/lib/bip21.test.ts index 5d36cb2f..2b906e7a 100644 --- a/src/lib/bip21.test.ts +++ b/src/lib/bip21.test.ts @@ -10,17 +10,17 @@ describe('parseBip21Uri', () => { describe('raw addresses', () => { it('parses a valid mainnet address', () => { const result = parseBip21Uri(VALID_ADDRESS) - expect(result).toEqual({ address: VALID_ADDRESS }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: false }) }) it('parses a valid testnet address', () => { const result = parseBip21Uri(VALID_TESTNET_ADDRESS) - expect(result).toEqual({ address: VALID_TESTNET_ADDRESS }) + expect(result).toEqual({ address: VALID_TESTNET_ADDRESS, fromUri: false }) }) it('trims whitespace around address', () => { const result = parseBip21Uri(` ${VALID_ADDRESS} `) - expect(result).toEqual({ address: VALID_ADDRESS }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: false }) }) it('returns undefined for invalid address', () => { @@ -35,32 +35,38 @@ describe('parseBip21Uri', () => { describe('BIP21 URIs', () => { it('parses bitcoin: URI with address only', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}`) - expect(result).toEqual({ address: VALID_ADDRESS }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true }) }) it('parses bitcoin: URI with amount', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?amount=0.5`) - expect(result).toEqual({ address: VALID_ADDRESS, amount: 50_000_000 }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true, amount: 50_000_000 }) }) it('parses bitcoin: URI with 1 BTC amount', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?amount=1`) - expect(result).toEqual({ address: VALID_ADDRESS, amount: 100_000_000 }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true, amount: 100_000_000 }) }) it('parses bitcoin: URI with small amount (1 sat)', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?amount=0.00000001`) - expect(result).toEqual({ address: VALID_ADDRESS, amount: 1 }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true, amount: 1 }) }) it('handles case-insensitive scheme', () => { - const result = parseBip21Uri(`BITCOIN:${VALID_ADDRESS}?amount=0.1`) - expect(result).toEqual({ address: VALID_ADDRESS, amount: 10_000_000 }) + const result = parseBip21Uri(`BiTcOiN:${VALID_ADDRESS}?amount=0.1`) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true, amount: 10_000_000 }) }) it('ignores extra query parameters', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?amount=0.01&label=test&message=hello`) - expect(result).toEqual({ address: VALID_ADDRESS, amount: 1_000_000, label: 'test', message: 'hello' }) + expect(result).toEqual({ + address: VALID_ADDRESS, + fromUri: true, + amount: 1_000_000, + label: 'test', + message: 'hello', + }) }) it('returns undefined for invalid address in URI', () => { @@ -73,48 +79,48 @@ describe('parseBip21Uri', () => { it('ignores zero amount', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?amount=0`) - expect(result).toEqual({ address: VALID_ADDRESS }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true }) }) it('ignores negative amount', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?amount=-1`) - expect(result).toEqual({ address: VALID_ADDRESS }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true }) }) it('ignores non-numeric amount', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?amount=abc`) - expect(result).toEqual({ address: VALID_ADDRESS }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true }) }) it('returns undefined for non-bitcoin URI scheme', () => { - expect(parseBip21Uri(`ethereum:${VALID_ADDRESS}`)).toBeUndefined() + expect(parseBip21Uri(`lnurl:${VALID_ADDRESS}`)).toBeUndefined() }) }) describe('label parameter', () => { it('parses label from BIP21 URI', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?label=Donation`) - expect(result).toEqual({ address: VALID_ADDRESS, label: 'Donation' }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true, label: 'Donation' }) }) it('parses label with amount', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?amount=0.01&label=Payment`) - expect(result).toEqual({ address: VALID_ADDRESS, amount: 1_000_000, label: 'Payment' }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true, amount: 1_000_000, label: 'Payment' }) }) it('ignores empty label', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?label=`) - expect(result).toEqual({ address: VALID_ADDRESS }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true }) }) it('decodes URL-encoded label', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?label=My%20Wallet`) - expect(result).toEqual({ address: VALID_ADDRESS, label: 'My Wallet' }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true, label: 'My Wallet' }) }) it('does not include label for raw address', () => { const result = parseBip21Uri(VALID_ADDRESS) - expect(result).toEqual({ address: VALID_ADDRESS }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: false }) expect(result).not.toHaveProperty('label') }) }) @@ -122,21 +128,28 @@ describe('parseBip21Uri', () => { describe('message parameter', () => { it('parses message from BIP21 URI', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?message=Invoice%20%231234`) - expect(result).toEqual({ address: VALID_ADDRESS, message: 'Invoice #1234' }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true, message: 'Invoice #1234' }) }) it('parses message with amount and label', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?amount=0.05&label=Shop&message=Order%20789`) - expect(result).toEqual({ address: VALID_ADDRESS, amount: 5_000_000, label: 'Shop', message: 'Order 789' }) + expect(result).toEqual({ + address: VALID_ADDRESS, + fromUri: true, + amount: 5_000_000, + label: 'Shop', + message: 'Order 789', + }) }) it('ignores empty message', () => { const result = parseBip21Uri(`bitcoin:${VALID_ADDRESS}?message=`) - expect(result).toEqual({ address: VALID_ADDRESS }) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: true }) }) it('does not include message for raw address', () => { const result = parseBip21Uri(VALID_ADDRESS) + expect(result).toEqual({ address: VALID_ADDRESS, fromUri: false }) expect(result).not.toHaveProperty('message') }) }) diff --git a/src/lib/bip21.ts b/src/lib/bip21.ts index 4bb34f43..ea14b054 100644 --- a/src/lib/bip21.ts +++ b/src/lib/bip21.ts @@ -2,34 +2,36 @@ import { validate as isValidBitcoinAddress } from 'bitcoin-address-validation' import { tryBtcToSat } from '@/lib/utils' import type { AmountSats, BitcoinAddress } from '@/types/global' +const BITCOIN_URI_SCHEME = 'bitcoin:' + export type Bip21ParseResult = { address: BitcoinAddress amount?: AmountSats label?: string message?: string + fromUri: boolean } export const parseBip21Uri = (raw: string): Bip21ParseResult | undefined => { const trimmed = raw.trim() - // raw address (no URI scheme) - if (isValidBitcoinAddress(trimmed)) { - return { address: trimmed } - } - // BIP21: bitcoin:
?amount=&... - if (!trimmed.toLowerCase().startsWith('bitcoin:')) { + if (!trimmed.toLowerCase().startsWith(BITCOIN_URI_SCHEME)) { + // raw address (no URI scheme) + if (isValidBitcoinAddress(trimmed)) { + return { address: trimmed, fromUri: false } + } return undefined } - const withoutScheme = trimmed.slice('bitcoin:'.length) + const withoutScheme = trimmed.slice(BITCOIN_URI_SCHEME.length) const [addressPart, queryString] = withoutScheme.split('?', 2) if (!addressPart || !isValidBitcoinAddress(addressPart)) { return undefined } - const result: Bip21ParseResult = { address: addressPart } + const result: Bip21ParseResult = { address: addressPart, fromUri: true } if (queryString) { const parameters = new URLSearchParams(queryString) diff --git a/src/lib/fidelityBondUtils.test.ts b/src/lib/fidelityBondUtils.test.ts index 47bde1df..d90452be 100644 --- a/src/lib/fidelityBondUtils.test.ts +++ b/src/lib/fidelityBondUtils.test.ts @@ -180,6 +180,19 @@ describe('utils', () => { expect(fb.utxo.allAreFrozen(utxos)).toBe(false) }) + it('should identify UTXOs eligible for fidelity-bond creation', () => { + expect(fb.utxo.isEligibleForCreation(makeUtxo('foo:0'), 'cj-out')).toBe(true) + expect(fb.utxo.isEligibleForCreation(makeUtxo('foo:0', '', true), 'cj-out')).toBe(false) + expect(fb.utxo.isEligibleForCreation(makeUtxo('foo:0'), 'deposit')).toBe(false) + expect(fb.utxo.isEligibleForCreation(makeUtxo('foo:0'))).toBe(false) + + const fidelityBond = { + ...makeUtxo('foo:0'), + locktime: '2999-01-01 00:00:00', + } as Utxo + expect(fb.utxo.isEligibleForCreation(fidelityBond, 'cj-out')).toBe(false) + }) + describe('isLocked', () => { const now = Date.UTC(2009, 0, 3) diff --git a/src/lib/fidelityBondUtils.ts b/src/lib/fidelityBondUtils.ts index ad38fd6e..12452b19 100644 --- a/src/lib/fidelityBondUtils.ts +++ b/src/lib/fidelityBondUtils.ts @@ -98,6 +98,10 @@ export const utxo = (() => { const isFidelityBond = (utxo: Utxo): utxo is FidelityBondUtxo => !!utxo.locktime + // TODO: allow selecting frozen UTXOs and unfreeze them before sending the transaction + const isEligibleForCreation = (utxo: Utxo, addressStatus?: string) => + !utxo.frozen && !isFidelityBond(utxo) && addressStatus === 'cj-out' + const getLocktime = (utxo: Utxo): Milliseconds | null => { if (!isFidelityBond(utxo)) return null @@ -115,5 +119,14 @@ export const utxo = (() => { return locktime !== null && locktime >= refTime } - return { isEqual, isInList, utxosToFreeze, allAreFrozen, isFidelityBond, isLocked, getLocktime } + return { + isEqual, + isInList, + utxosToFreeze, + allAreFrozen, + isFidelityBond, + isEligibleForCreation, + isLocked, + getLocktime, + } })() diff --git a/src/lib/formValidation.test.ts b/src/lib/formValidation.test.ts index 32f80763..1c87f48f 100644 --- a/src/lib/formValidation.test.ts +++ b/src/lib/formValidation.test.ts @@ -1,4 +1,4 @@ -import { Network } from 'bitcoin-address-validation' +import { getAddressInfo, Network } from 'bitcoin-address-validation' import { describe, expect, it } from 'vitest' import type { AddressSummary } from '@/context/JamWalletInfoContext' import { @@ -7,21 +7,41 @@ import { isReusedAddress, isValidAddress, sourceJarField, + blockHeightField, + INPUT_BLOCK_HEIGHT_MIN, } from './formValidation' -const mainnetAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT' +const mainnetAddress = '1BitcoinEaterAddressDontSend8MUo1T' const testnetAddress = 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn' +const regtestBech32Address = 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' +const testnetBech32Address = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx' +// bech32m/taproot pair - same witness program, differing only by HRP (tb1p vs bcrt1p) +const testnetTaprootAddress = 'tb1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq47zagq' +const regtestTaprootAddress = 'bcrt1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqc8gma6' +const regtestLegacyAddressLabelledTestnet = 'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV' const addressSummary = { [mainnetAddress]: { address: mainnetAddress, used: false }, } as unknown as AddressSummary describe('isValidAddress', () => { - it('accepts a valid address and rejects everything else', () => { - expect(isValidAddress(mainnetAddress)).toBe(true) + it.each([ + mainnetAddress, + testnetAddress, + regtestBech32Address, + testnetBech32Address, + testnetTaprootAddress, + regtestTaprootAddress, + regtestLegacyAddressLabelledTestnet, + ])('accepts valid addresses', (address) => { + expect(isValidAddress(address)).toBe(true) + }) + + it('reject invalid addresses', () => { expect(isValidAddress('not-an-address')).toBe(false) expect(isValidAddress('')).toBe(false) expect(isValidAddress(undefined)).toBe(false) + expect(isValidAddress(null)).toBe(false) expect(isValidAddress(42)).toBe(false) }) }) @@ -30,12 +50,42 @@ describe('isAddressOnNetwork', () => { it('matches the address network', () => { expect(isAddressOnNetwork(mainnetAddress, Network.mainnet)).toBe(true) expect(isAddressOnNetwork(mainnetAddress, Network.testnet)).toBe(false) + expect(isAddressOnNetwork(testnetAddress, Network.mainnet)).toBe(false) expect(isAddressOnNetwork(testnetAddress, Network.testnet)).toBe(true) }) it('returns false for unparseable input instead of throwing', () => { expect(isAddressOnNetwork('not-an-address', Network.mainnet)).toBe(false) }) + + it('distinguishes testnet and regtest for bech32 and bech32m addresses via their distinct HRP', () => { + // bech32/bech32m addresses carry a network-specific prefix (tb1 vs bcrt1), so each is only + // valid on its own network - a regtest address must not pass on a testnet wallet, nor vice versa. + // bech32 (segwit v0): + expect(isAddressOnNetwork(regtestBech32Address, Network.regtest)).toBe(true) + expect(isAddressOnNetwork(regtestBech32Address, Network.testnet)).toBe(false) + expect(isAddressOnNetwork(testnetBech32Address, Network.testnet)).toBe(true) + expect(isAddressOnNetwork(testnetBech32Address, Network.regtest)).toBe(false) + // bech32m (taproot): + expect(isAddressOnNetwork(regtestTaprootAddress, Network.regtest)).toBe(true) + expect(isAddressOnNetwork(regtestTaprootAddress, Network.testnet)).toBe(false) + expect(isAddressOnNetwork(testnetTaprootAddress, Network.testnet)).toBe(true) + expect(isAddressOnNetwork(testnetTaprootAddress, Network.regtest)).toBe(false) + // ...and none pass on mainnet. + expect(isAddressOnNetwork(regtestBech32Address, Network.mainnet)).toBe(false) + expect(isAddressOnNetwork(testnetBech32Address, Network.mainnet)).toBe(false) + expect(isAddressOnNetwork(regtestTaprootAddress, Network.mainnet)).toBe(false) + expect(isAddressOnNetwork(testnetTaprootAddress, Network.mainnet)).toBe(false) + }) + + it('treats testnet and regtest as interchangeable for ambiguous base58 addresses', () => { + expect(getAddressInfo(regtestLegacyAddressLabelledTestnet).network, 'sanity check').toBe(Network.testnet) + + // A base58 (P2PKH here; P2SH shares the trait) address on a regtest wallet is labeled "testnet" + // by the library because the two share version bytes, so it must still be accepted on regtest. + expect(isAddressOnNetwork(regtestLegacyAddressLabelledTestnet, Network.regtest)).toBe(true) + expect(isAddressOnNetwork(regtestLegacyAddressLabelledTestnet, Network.testnet)).toBe(true) + }) }) describe('isReusedAddress', () => { @@ -116,3 +166,53 @@ describe('destinationAddressField', () => { expect(() => reusedSchema.validateSync(mainnetAddress)).toThrow('reused') }) }) + +describe('blockHeightField', () => { + // eslint-disable-next-line unicorn/consistent-function-scoping -- okay in tests + const invalidMessage = ({ min, max }: { min: number; max: number }) => `Invalid blockheight ${min}-${max}` + + it('handles currentBlockHeight = 0', () => { + const schema = blockHeightField({ currentBlockHeight: 0, messages: { invalid: invalidMessage } }) + // min should be 0, max should be 0 + expect(schema.isValidSync(-1)).toBe(false) + expect(schema.isValidSync(0)).toBe(true) + expect(schema.isValidSync(1)).toBe(false) + }) + + it('handles currentBlockHeight = undefined', () => { + const schema = blockHeightField({ currentBlockHeight: undefined, messages: { invalid: invalidMessage } }) + // min should be 0, max should be Number.MAX_SAFE_INTEGER + expect(schema.isValidSync(-1)).toBe(false) + expect(schema.isValidSync(0)).toBe(true) + expect(schema.isValidSync(100)).toBe(true) + expect(schema.isValidSync(Number.MAX_SAFE_INTEGER + 1)).toBe(false) + }) + + it('handles currentBlockHeight = null', () => { + // @ts-expect-error Intentionally pass null to test JS boundary cases + const schema = blockHeightField({ currentBlockHeight: null, messages: { invalid: invalidMessage } }) + expect(schema.isValidSync(-1)).toBe(false) + expect(schema.isValidSync(0)).toBe(true) + expect(schema.isValidSync(100)).toBe(true) + }) + + it('handles currentBlockHeight > minBlockHeight', () => { + const schema = blockHeightField({ currentBlockHeight: 100, messages: { invalid: invalidMessage } }) + // min should be 0, max should be 100 + expect(schema.isValidSync(-1)).toBe(false) + expect(schema.isValidSync(0)).toBe(true) + expect(schema.isValidSync(50)).toBe(true) + expect(schema.isValidSync(100)).toBe(true) + expect(schema.isValidSync(101)).toBe(false) + }) + + it('handles currentBlockHeight == minBlockHeight (0)', () => { + const schema = blockHeightField({ + currentBlockHeight: INPUT_BLOCK_HEIGHT_MIN, + messages: { invalid: invalidMessage }, + }) + expect(schema.isValidSync(INPUT_BLOCK_HEIGHT_MIN - 1)).toBe(false) + expect(schema.isValidSync(INPUT_BLOCK_HEIGHT_MIN)).toBe(true) + expect(schema.isValidSync(INPUT_BLOCK_HEIGHT_MIN + 1)).toBe(false) + }) +}) diff --git a/src/lib/formValidation.ts b/src/lib/formValidation.ts index 2da65b3e..fee03563 100644 --- a/src/lib/formValidation.ts +++ b/src/lib/formValidation.ts @@ -1,4 +1,10 @@ -import { getAddressInfo, validate as isValidBitcoinAddress, type Network } from 'bitcoin-address-validation' +import { + getAddressInfo, + Network, + validate as isValidBitcoinAddress, + AddressType, + type AddressInfo, +} from 'bitcoin-address-validation' import * as yup from 'yup' import type { AddressSummary } from '@/context/JamWalletInfoContext' import type { BitcoinAddress, BlockHeight, JarIndex } from '@/types/global' @@ -11,9 +17,32 @@ import { isValidInteger } from './utils' export const isValidAddress = (value: unknown): value is BitcoinAddress => typeof value === 'string' && isValidBitcoinAddress(value) -export const isAddressOnNetwork = (value: string, network: Network): boolean => { +// p2pkh/p2sh addresses (base58) share the same version bytes on testnet and regtest, so +// address validation can't tell them apart and always labels them "testnet". +// Treat the two as interchangeable for those so a regtest wallet doesn't reject its own +// legacy-style addresses as "wrong network". +// Types like p2wpkh/p2tr (bech32) carry a distinct HRP per network (tb1 vs bcrt1), so they are +// already identified correctly - don't relax them, or a bcrt1… (regtest) address would +// wrongly pass on a testnet wallet and vice versa. Only base58 addresses are ambiguous. +const AMBIGUOUS_NETWORKS_BY_ADDRESS_TYPE: ReadonlyMap = new Map([ + [AddressType.p2pkh, [Network.testnet, Network.regtest]], + [AddressType.p2sh, [Network.testnet, Network.regtest]], +]) + +const isAmbiguousAddressForNetwork = (info: AddressInfo, expectedNetwork: Network) => { + // Bech32/bech32m addresses carry a distinct HRP per network (tb1 vs bcrt1), so they are + // already identified correctly - don't relax them, or a bcrt1… (regtest) address would + // wrongly pass on a testnet wallet and vice versa. Only base58 addresses are ambiguous. + return [info.network, expectedNetwork].every( + (it) => AMBIGUOUS_NETWORKS_BY_ADDRESS_TYPE.get(info.type)?.includes(it) ?? false, + ) +} + +export const isAddressOnNetwork = (value: string, expectedNetwork: Network): boolean => { try { - return getAddressInfo(value).network === network + const addressInfo = getAddressInfo(value) + if (addressInfo.network === expectedNetwork) return true + return isAmbiguousAddressForNetwork(addressInfo, expectedNetwork) } catch (_ignoredOnPurpose) { return false } @@ -92,7 +121,7 @@ export const blockHeightField = ({ } }) => { const minBlockHeight = Math.min(INPUT_BLOCK_HEIGHT_MIN, currentBlockHeight || INPUT_BLOCK_HEIGHT_MIN) - const maxBlockheight = Math.max(minBlockHeight, currentBlockHeight || INPUT_BLOCK_HEIGHT_MAX) + const maxBlockheight = Math.max(minBlockHeight, currentBlockHeight ?? INPUT_BLOCK_HEIGHT_MAX) const invalidBlockheightMessage = invalid({ min: minBlockHeight, max: maxBlockheight }) return yup diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 63e14d6a..f63bcad5 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { JM_WALLET_FILE_EXTENSION } from '@/constants/jm' +import { withRuntimeLocale } from '@/test/withRuntimeLocale' import { cn, debounce, @@ -37,24 +38,6 @@ import { } from './utils' import type { WalletFileName } from './utils' -const withRuntimeLocale = (locale: string, callback: () => void) => { - /* eslint-disable unicorn/no-this-outside-of-class -- mocking Number.prototype.toLocaleString requires `this` */ - const numberToLocaleStringMock = vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function ( - this: number, - locales, - options, - ) { - return Intl.NumberFormat(locales ?? locale, options).format(this) - }) - /* eslint-enable unicorn/no-this-outside-of-class */ - - try { - callback() - } finally { - numberToLocaleStringMock.mockRestore() - } -} - describe('cn', () => { it('should merge class names correctly', () => { expect(cn('text-red-500', 'bg-blue-500')).toBe('text-red-500 bg-blue-500') @@ -502,8 +485,10 @@ describe('formatBtc', () => { }) it('should handle large BTC values', () => { - expect(formatBtc(21000000)).toBe('21,000,000.00000000') - expect(formatBtc(100.99999999)).toBe('100.99999999') + withRuntimeLocale('en-US', () => { + expect(formatBtc(21000000)).toBe('21,000,000.00000000') + expect(formatBtc(100.99999999)).toBe('100.99999999') + }) }) it('should handle very small BTC values', () => { @@ -555,9 +540,11 @@ describe('getBtcParts', () => { describe('formatSats', () => { it('should format satoshi values with locale-specific thousands separators', () => { - expect(formatSats(1000)).toBe('1,000') - expect(formatSats(1000000)).toBe('1,000,000') - expect(formatSats(100000000)).toBe('100,000,000') // 1 BTC in sats + withRuntimeLocale('en-US', () => { + expect(formatSats(1000)).toBe('1,000') + expect(formatSats(1000000)).toBe('1,000,000') + expect(formatSats(100000000)).toBe('100,000,000') // 1 BTC in sats + }) }) it('should handle small satoshi values', () => { @@ -568,13 +555,17 @@ describe('formatSats', () => { }) it('should handle large satoshi values', () => { - expect(formatSats(2100000000000000)).toBe('2,100,000,000,000,000') // 21M BTC in sats - expect(formatSats(12345678901)).toBe('12,345,678,901') + withRuntimeLocale('en-US', () => { + expect(formatSats(2100000000000000)).toBe('2,100,000,000,000,000') // 21M BTC in sats + expect(formatSats(12345678901)).toBe('12,345,678,901') + }) }) it('should handle negative satoshi values', () => { - expect(formatSats(-1000)).toBe('-1,000') - expect(formatSats(-1234567)).toBe('-1,234,567') + withRuntimeLocale('en-US', () => { + expect(formatSats(-1000)).toBe('-1,000') + expect(formatSats(-1234567)).toBe('-1,234,567') + }) }) it('should follow runtime locale when no explicit locale is provided', () => { diff --git a/src/main.tsx b/src/main.tsx index e1161d82..b69cfcd0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,8 +2,28 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from '@/App.tsx' import '@/i18n/config' +import { JAM_REPO_URL } from './constants/jam' import './index.css' +console.debug( + `%c/${'*'.repeat(42)} +* Thanks for testing! Please report anything that looks off. +* Found a bug? Open an issue at ${JAM_REPO_URL}/issues/new?labels=bug&template=bug_report.md +* +* This message should be dropped from preview/prod builds. +* Use the above link and open a bug report if you see this message outside development builds. +${'*'.repeat(42)}/`, + 'color:#e2b86a;font-size:14px;', +) + +setTimeout(() => { + console.log('%cWarning!', 'color:oklch(0.577 0.245 27.325);font-size:48px;font-weight:bold;') + console.log( + "%cYou opened the browser console, a developer tool. Don't enter or paste code you do not understand. Never share your seed phrase or any other info with anyone. If someone told you to do this, it is very likely a scam.", + 'color:oklch(0.777 0.245 27.325);font-size:16px;', + ) +}, 2_100) + createRoot(document.querySelector('#root')!).render( diff --git a/src/store/jamSettingsStore.ts b/src/store/jamSettingsStore.ts index 1ba6fd65..3363386d 100644 --- a/src/store/jamSettingsStore.ts +++ b/src/store/jamSettingsStore.ts @@ -3,8 +3,15 @@ import { persist, createJSONStorage } from 'zustand/middleware' import { isDevMode } from '@/constants/debugFeatures' import type { Currency } from '@/types/global' +type PreviewFeatures = { + 'tx-history'?: boolean + // add more entries on demand + // 'myCoolNewFeature'?: boolean +} + export type JamSettings = { developerMode: boolean + previewFeatures: PreviewFeatures | undefined privateMode: boolean addressChunking: boolean currencyUnit: Currency @@ -19,6 +26,7 @@ interface JamSettingsStoreState { const initial: JamSettings = { developerMode: isDevMode(), + previewFeatures: isDevMode() ? {} : undefined, privateMode: false, addressChunking: true, currencyUnit: 'sats', @@ -43,3 +51,7 @@ export const useDeveloperMode = () => { const isDeveloperMode = useStore(jamSettingsStore, (state) => state.state.developerMode) return { enabled: isDeveloperMode } } + +export const usePreviewFeatures = () => { + return useStore(jamSettingsStore, (state) => state.state.previewFeatures) +} diff --git a/src/stories/jam/ActiveCollaborativeSendAlert.stories.tsx b/src/stories/jam/ActiveCollaborativeSendAlert.stories.tsx new file mode 100644 index 00000000..4299292f --- /dev/null +++ b/src/stories/jam/ActiveCollaborativeSendAlert.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { ActiveCollaborativeSendAlert } from '@/components/send/ActiveCollaborativeSendAlert' +import type { PaymentAttempt } from '@/context/JamSessionInfoContext' +import type { Jar } from '@/context/JamWalletInfoContext' +import { TX_FEE_UNITS } from '@/lib/feeConfig' + +const meta: Meta = { + title: 'Jam/ActiveCollaborativeSendAlert', + component: ActiveCollaborativeSendAlert, + tags: ['autodocs'], + args: { + jars: [], + isAborting: false, + onAbort: () => alert('onAbort clicked'), + }, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: {}, +} + +const jars: Jar[] = [ + { + balanceSummary: { + calculatedAvailableBalanceInSats: 100_000, + calculatedConfirmedAvailableBalanceInSats: 100_000, + calculatedAvailableFrozenBalanceInSats: 0, + calculatedFrozenOrLockedBalanceInSats: 0, + calculatedTotalBalanceInSats: 100_000, + }, + color: '#e2b86a', + jarIndex: 0, + name: 'Jar Zero', + utxos: [], + }, +] + +const paymentAttempt: PaymentAttempt = { + createdAt: 1_000, + utxosHashHex: 'abc123hash', + walletFileName: 'Satoshi.jmdat', + data: { + amount: { amount: 21_000, isSweep: false as const, sweepAmount: undefined }, + destination: { address: 'bcrt1qdestinationaddress123456789', fromJar: undefined }, + isCoinJoin: true, + numCollaborators: 5, + source: { fromJar: 0 as const }, + txFee: { txFeeInBlocks: 3, txFeeInSatsPerVbyte: undefined, txFeeUnit: TX_FEE_UNITS.BLOCKS }, + }, +} + +export const WithPaymentAttempt: Story = { + args: { + paymentAttempt, + jars, + }, +} + +export const WithSweepPaymentAttempt: Story = { + args: { + paymentAttempt: { + ...paymentAttempt, + data: { + ...paymentAttempt.data, + amount: { amount: undefined, isSweep: true, sweepAmount: 21_000 }, + destination: { address: 'bcrt1qdestinationaddress123456789', fromJar: paymentAttempt.data.source.fromJar }, + }, + }, + jars, + }, +} diff --git a/src/stories/jam/OfferCard.stories.tsx b/src/stories/jam/OfferCard.stories.tsx new file mode 100644 index 00000000..b89f48c5 --- /dev/null +++ b/src/stories/jam/OfferCard.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { OfferCard } from '@/components/earn/OfferCard' + +const meta: Meta = { + title: 'Jam/OfferCard', + component: OfferCard, + tags: ['autodocs'], +} +export default meta + +type Story = StoryObj + +export const Absolute: Story = { + args: { + value: { + oid: 0, + ordertype: 'sw0absoffer', + minsize: 90_463, + maxsize: 9_008_744_958, + txfee: 0, + cjfee: '21', + }, + nickname: 'TEST7i74jfC9M8MV', + }, +} + +export const Relative: Story = { + args: { + value: { + oid: 0, + ordertype: 'sw0reloffer', + minsize: 90_463, + maxsize: 9_008_744_958, + txfee: 0, + cjfee: '0.000021', + }, + nickname: 'TESTCKqXnDjxnFHN', + }, +} + +export const Checking: Story = { + args: { + value: { + oid: 0, + ordertype: 'sw0reloffer', + minsize: 90_463, + maxsize: 9_008_744_958, + txfee: 0, + cjfee: '0.000021', + }, + nickname: 'TESTCKqXnDjxnFHN', + orderbookStatus: 'checking', + }, +} + +export const Visible: Story = { + args: { + value: { + oid: 0, + ordertype: 'sw0reloffer', + minsize: 90_463, + maxsize: 9_008_744_958, + txfee: 0, + cjfee: '0.000021', + }, + nickname: 'TESTCKqXnDjxnFHN', + orderbookStatus: 'visible', + }, +} + +export const Missing: Story = { + args: { + value: { + oid: 0, + ordertype: 'sw0reloffer', + minsize: 90_463, + maxsize: 9_008_744_958, + txfee: 0, + cjfee: '0.000021', + }, + nickname: 'TESTCKqXnDjxnFHN', + orderbookStatus: 'missing', + }, +} + +export const Error: Story = { + args: { + value: { + oid: 0, + ordertype: 'sw0reloffer', + minsize: 90_463, + maxsize: 9_008_744_958, + txfee: 0, + cjfee: '0.000021', + }, + nickname: 'TESTCKqXnDjxnFHN', + orderbookStatus: 'error', + }, +} + +export const RelativeWithFidelityBondNotYetInLocalOrderbook: Story = { + args: { + value: { + oid: 0, + ordertype: 'sw0reloffer', + minsize: 90_463, + maxsize: 9_008_744_958, + txfee: 0, + cjfee: '0.000021', + }, + nickname: 'TESTCKqXnDjxnFHN', + orderbookStatus: 'visible', + orderbookOffer: { + counterparty: 'TESTCKqXnDjxnFHN', + oid: 0, + ordertype: 'sw0reloffer', + fidelity_bond_value: 0, + }, + fidelityBond: { + counterparty: 'TESTCKqXnDjxnFHN', + amount: 123_456_789, + locktime: 1_000_000, + }, + }, +} + +export const RelativeWithFidelityBondInLocalOrderbook: Story = { + args: { + value: { + oid: 0, + ordertype: 'sw0reloffer', + minsize: 90_463, + maxsize: 9_008_744_958, + txfee: 0, + cjfee: '0.000021', + }, + nickname: 'TESTCKqXnDjxnFHN', + orderbookStatus: 'visible', + orderbookOffer: { + counterparty: 'TESTCKqXnDjxnFHN', + oid: 0, + ordertype: 'sw0reloffer', + fidelity_bond_value: 123_456_789.1337, + }, + fidelityBond: { + counterparty: 'TESTCKqXnDjxnFHN', + amount: 123_456_789, + locktime: 0, + }, + }, +} diff --git a/src/stories/jam/OrderbookEmptyAlert.stories.tsx b/src/stories/jam/OrderbookEmptyAlert.stories.tsx new file mode 100644 index 00000000..c7a39a0f --- /dev/null +++ b/src/stories/jam/OrderbookEmptyAlert.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { OrderbookEmptyAlert } from '@/components/ui/jam/OrderbookEmptyAlert' + +const meta: Meta = { + title: 'Jam/OrderbookEmptyAlert', + component: OrderbookEmptyAlert, + tags: ['autodocs'], +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + isChecking: false, + onCheckClick: async () => alert('onCheck clicked'), + }, +} + +export const Checking: Story = { + args: { + isChecking: true, + onCheckClick: async () => alert('onCheck clicked'), + }, +} diff --git a/src/stories/jam/PreconditionAlert.stories.tsx b/src/stories/jam/PreconditionAlert.stories.tsx new file mode 100644 index 00000000..45b2d11c --- /dev/null +++ b/src/stories/jam/PreconditionAlert.stories.tsx @@ -0,0 +1,179 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { SWEEP_PRECONDITION_DEFAULT_OPTIONS } from '@/components/sweep/preconditions' +import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert' +import type { Utxo } from '@/hooks/useQueryUtxos' + +const DEFAULT_SUMMARY = Object.freeze({ + isFulfilled: false, + options: SWEEP_PRECONDITION_DEFAULT_OPTIONS, + numberOfMissingUtxos: 0, + numberOfMissingConfirmations: 0, + numberOfNonFrozenFidelityBondOutputs: 0, + retryLockedUtxos: [], +}) + +const makeUtxo = (overrides: Partial = {}): Utxo => + ({ + address: 'bc1qsource', + confirmations: 6, + frozen: false, + label: '', + locktime: undefined, + mixdepth: 0, + path: '', + tries_remaining: 3, + utxo: '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:0', + value: 100_000, + ...overrides, + }) as Utxo + +const meta: Meta = { + title: 'Jam/PreconditionAlert', + component: PreconditionAlert, + tags: ['autodocs'], + args: { + summary: DEFAULT_SUMMARY, + i18nPrefix: 'send.coinjoin_precondition', + }, +} +export default meta + +type Story = StoryObj + +// --- Send Flow Stories --- + +export const SendDefault: Story = { + args: { + i18nPrefix: 'send.coinjoin_precondition', + }, +} + +export const SendMissingUtxos: Story = { + args: { + i18nPrefix: 'send.coinjoin_precondition', + summary: { + ...DEFAULT_SUMMARY, + numberOfMissingUtxos: 3, + }, + }, +} + +export const SendMissingConfirmations: Story = { + args: { + i18nPrefix: 'send.coinjoin_precondition', + summary: { + ...DEFAULT_SUMMARY, + numberOfMissingConfirmations: 3, + }, + }, +} + +export const SendNonFrozenFidelityBondsPresent: Story = { + args: { + i18nPrefix: 'send.coinjoin_precondition', + summary: { + ...DEFAULT_SUMMARY, + numberOfNonFrozenFidelityBondOutputs: 3, + }, + }, +} + +export const SendRetryLockedUtxos: Story = { + args: { + i18nPrefix: 'send.coinjoin_precondition', + summary: { + ...DEFAULT_SUMMARY, + retryLockedUtxos: [ + makeUtxo({ mixdepth: 0, value: 21 }), + makeUtxo({ mixdepth: 1, value: 21_000 }), + makeUtxo({ mixdepth: 2, value: 21_000_000 }), + ], + }, + }, +} + +export const SendMultiple: Story = { + args: { + i18nPrefix: 'send.coinjoin_precondition', + summary: { + ...DEFAULT_SUMMARY, + numberOfMissingUtxos: 3, + numberOfMissingConfirmations: 3, + numberOfNonFrozenFidelityBondOutputs: 3, + retryLockedUtxos: [ + makeUtxo({ mixdepth: 0, value: 21 }), + makeUtxo({ mixdepth: 1, value: 21_000 }), + makeUtxo({ mixdepth: 2, value: 21_000_000 }), + ], + }, + }, +} + +// --- Sweep Flow Stories --- + +export const SweepDefault: Story = { + args: { + i18nPrefix: 'scheduler.precondition', + }, +} + +export const SweepMissingUtxos: Story = { + args: { + i18nPrefix: 'scheduler.precondition', + summary: { + ...DEFAULT_SUMMARY, + numberOfMissingUtxos: 3, + }, + }, +} + +export const SweepMissingConfirmations: Story = { + args: { + i18nPrefix: 'scheduler.precondition', + summary: { + ...DEFAULT_SUMMARY, + numberOfMissingConfirmations: 3, + }, + }, +} + +export const SweepNonFrozenFidelityBondsPresent: Story = { + args: { + i18nPrefix: 'scheduler.precondition', + summary: { + ...DEFAULT_SUMMARY, + numberOfNonFrozenFidelityBondOutputs: 3, + }, + }, +} + +export const SweepRetryLockedUtxos: Story = { + args: { + i18nPrefix: 'scheduler.precondition', + summary: { + ...DEFAULT_SUMMARY, + retryLockedUtxos: [ + makeUtxo({ mixdepth: 0, value: 21 }), + makeUtxo({ mixdepth: 1, value: 21_000 }), + makeUtxo({ mixdepth: 2, value: 21_000_000 }), + ], + }, + }, +} + +export const SweepMultiple: Story = { + args: { + i18nPrefix: 'scheduler.precondition', + summary: { + ...DEFAULT_SUMMARY, + numberOfMissingUtxos: 3, + numberOfMissingConfirmations: 3, + numberOfNonFrozenFidelityBondOutputs: 3, + retryLockedUtxos: [ + makeUtxo({ mixdepth: 0, value: 21 }), + makeUtxo({ mixdepth: 1, value: 21_000 }), + makeUtxo({ mixdepth: 2, value: 21_000_000 }), + ], + }, + }, +} diff --git a/src/stories/jam/ScheduleEntryItem.stories.tsx b/src/stories/jam/ScheduleEntryItem.stories.tsx index da69cace..e303631f 100644 --- a/src/stories/jam/ScheduleEntryItem.stories.tsx +++ b/src/stories/jam/ScheduleEntryItem.stories.tsx @@ -2,6 +2,7 @@ import type { TumblerPhaseResponse } from '@joinmarket-webui/joinmarket-ng-api-t import type { Meta, StoryObj } from '@storybook/react-vite' import { ScheduleEntryItem } from '@/components/sweep/ScheduleEntryItem' import { toScheduleEntry } from '@/components/sweep/scheduleUtils' +import type { Jar } from '@/context/JamWalletInfoContext' const meta: Meta = { title: 'Jam/ScheduleEntryItem', @@ -12,6 +13,22 @@ export default meta type Story = StoryObj +const jars: Jar[] = [ + { + balanceSummary: { + calculatedAvailableBalanceInSats: 100_000, + calculatedConfirmedAvailableBalanceInSats: 100_000, + calculatedAvailableFrozenBalanceInSats: 0, + calculatedFrozenOrLockedBalanceInSats: 0, + calculatedTotalBalanceInSats: 100_000, + }, + color: '#e2b86a', + jarIndex: 0, + name: 'Jar Zero', + utxos: [], + }, +] + const takerPhase: TumblerPhaseResponse = { kind: 'taker_coinjoin', index: 0, @@ -20,7 +37,7 @@ const takerPhase: TumblerPhaseResponse = { started_at: '2026-07-19T10:35:52.775747+00:00', finished_at: null, error: null, - mixdepth: 0, + mixdepth: jars[0].jarIndex, amount: 0, amount_fraction: null, counterparty_count: 21, @@ -54,9 +71,28 @@ const makerPhase: TumblerPhaseResponse = { attempt_count: 0, } +export const Maker: Story = { + args: { + value: toScheduleEntry(makerPhase, jars), + }, +} + export const Active: Story = { args: { - value: toScheduleEntry(takerPhase, []), + value: toScheduleEntry(takerPhase, jars), + active: true, + }, +} + +export const ActiveExternal: Story = { + args: { + value: toScheduleEntry( + { + ...takerPhase, + destination: 'bcrt1qdestinationaddress123456789', + }, + jars, + ), active: true, }, } @@ -68,7 +104,7 @@ export const Running: Story = { ...takerPhase, status: 'running', }, - [], + jars, ), active: false, }, @@ -81,7 +117,7 @@ export const Pending: Story = { ...takerPhase, status: 'pending', }, - [], + jars, ), active: false, }, @@ -93,8 +129,9 @@ export const Completed: Story = { { ...takerPhase, status: 'completed', + finished_at: '2026-07-19T10:35:52.775747+00:00', }, - [], + jars, ), active: false, }, @@ -107,7 +144,7 @@ export const Skipped: Story = { ...takerPhase, status: 'skipped', }, - [], + jars, ), active: false, }, @@ -120,7 +157,7 @@ export const Cancelled: Story = { ...takerPhase, status: 'cancelled', }, - [], + jars, ), active: false, }, @@ -134,14 +171,8 @@ export const Failed: Story = { status: 'failed', error: 'Error description', }, - [], + jars, ), active: false, }, } - -export const Maker: Story = { - args: { - value: toScheduleEntry(makerPhase, []), - }, -} diff --git a/src/stories/jam/SelectableJar.stories.tsx b/src/stories/jam/SelectableJar.stories.tsx index f04497d7..763a559f 100644 --- a/src/stories/jam/SelectableJar.stories.tsx +++ b/src/stories/jam/SelectableJar.stories.tsx @@ -76,6 +76,7 @@ export const Selected: Story = { frozenOrLockedBalance: 0, totalWalletBalance: 500_000, isSelected: true, + onSelect: () => alert('Selected'), onClick: () => alert('Selected jar clicked'), }, parameters: { @@ -97,6 +98,7 @@ export const Unselected: Story = { frozenOrLockedBalance: 0, totalWalletBalance: 500_000, isSelected: false, + onSelect: () => alert('Selected'), onClick: () => alert('Unselected jar clicked'), }, parameters: { @@ -118,6 +120,7 @@ export const EmptyJar: Story = { frozenOrLockedBalance: 0, totalWalletBalance: 500_000, isSelected: false, + onSelect: () => alert('Selected'), onClick: () => alert('Empty jar clicked'), }, parameters: { @@ -139,6 +142,7 @@ export const FullJar: Story = { frozenOrLockedBalance: 0, totalWalletBalance: 500_000, isSelected: false, + onSelect: () => alert('Selected'), onClick: () => alert('Full jar clicked'), }, parameters: { @@ -161,6 +165,7 @@ export const DisabledJar: Story = { totalWalletBalance: 500_000, isSelected: false, disabled: true, + onSelect: () => alert('Selected'), onClick: () => alert('Disabled jar clicked'), }, parameters: { @@ -182,6 +187,7 @@ export const JarGrid: Story = { frozenOrLockedBalance: 0, totalWalletBalance: 500_000, isSelected: true, + onSelect: () => alert('Selected'), onClick: () => alert('Jar clicked'), }, parameters: { @@ -216,7 +222,7 @@ export const JarGrid: Story = { totalWalletBalance={totalWalletBalance} isSelected={jar.isSelected} disabled={jar.disabled} - onClick={() => alert(`Clicked on ${jar.name}`)} + onSelect={() => alert(`Clicked on ${jar.name}`)} /> ))}
@@ -235,6 +241,7 @@ export const Interactive: Story = { frozenOrLockedBalance: 100_000, totalWalletBalance: 500_000, isSelected: false, + onSelect: () => alert('Selected'), onClick: () => alert('Interactive jar clicked'), }, parameters: { diff --git a/src/test/withRuntimeLocale.ts b/src/test/withRuntimeLocale.ts new file mode 100644 index 00000000..d8d41354 --- /dev/null +++ b/src/test/withRuntimeLocale.ts @@ -0,0 +1,52 @@ +import { vi } from 'vitest' + +const OriginalIntlNumberFormat = Intl.NumberFormat + +/** + * Runs `callback` with `Number.prototype.toLocaleString` and `Intl.NumberFormat` + * mocked so that calls without an explicit locale argument format using + * `locale` instead of the host machine's default locale. Calls that already + * pass an explicit locale are left untouched. This keeps locale-dependent + * assertions deterministic across contributors' machines and CI without + * pinning a locale in app code. + */ +export function withRuntimeLocale(locale: string, callback: () => Promise): Promise +export function withRuntimeLocale(locale: string, callback: () => void): void +export function withRuntimeLocale(locale: string, callback: () => void | Promise): void | Promise { + /* eslint-disable unicorn/no-this-outside-of-class -- mocking Number.prototype.toLocaleString requires `this` */ + const numberToLocaleStringMock = vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function ( + this: number, + locales, + options, + ) { + return Intl.NumberFormat(locales ?? locale, options).format(this) + }) + /* eslint-enable unicorn/no-this-outside-of-class */ + + const intlNumberFormatMock = vi.spyOn(Intl, 'NumberFormat').mockImplementation(function ( + locales?: string | string[], + options?: Intl.NumberFormatOptions, + ) { + return new OriginalIntlNumberFormat(locales ?? locale, options) + } as unknown as typeof Intl.NumberFormat) + + const restore = () => { + numberToLocaleStringMock.mockRestore() + intlNumberFormatMock.mockRestore() + } + + let result: void | Promise + try { + result = callback() + } catch (error) { + restore() + throw error + } + + if (result instanceof Promise) { + return result.finally(restore) + } + + restore() + return undefined +} diff --git a/vite.config.ts b/vite.config.ts index ba35e702..56362412 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -75,7 +75,19 @@ export default defineConfig((config): UserConfig => { ], resolve: { alias: { - '@': path.resolve(__dirname, './src'), + '@': path.resolve(import.meta.dirname, './src'), + }, + }, + build: { + rolldownOptions: { + output: { + minify: { + compress: { + dropConsole: buildOrPreview, + dropDebugger: buildOrPreview, + }, + }, + }, }, }, server: { diff --git a/vitest.config.ts b/vitest.config.ts index edb3a027..4c06ecea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import { playwright } from '@vitest/browser-playwright' import path from 'node:path' import { mergeConfig } from 'vite' import { ConfigEnv, coverageConfigDefaults, defineConfig, type ViteUserConfig } from 'vitest/config' -import viteConfig from './vite.config' +import viteConfig from './vite.config.ts' export default defineConfig((args: ConfigEnv): ViteUserConfig => { return mergeConfig(viteConfig(args), { @@ -29,10 +29,10 @@ export default defineConfig((args: ConfigEnv): ViteUserConfig => { 'src/main.tsx', ], thresholds: { - lines: 80, - functions: 80, + lines: 85, + functions: 85, branches: 80, - statements: 80, + statements: 85, }, }, projects: [ @@ -41,7 +41,7 @@ export default defineConfig((args: ConfigEnv): ViteUserConfig => { plugins: [ // The plugin will run tests for the stories defined in your Storybook config // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest - storybookTest({ configDir: path.resolve(__dirname, './.storybook') }), + storybookTest({ configDir: path.resolve(import.meta.dirname, './.storybook') }), ], test: { name: 'storybook', @@ -69,7 +69,7 @@ export default defineConfig((args: ConfigEnv): ViteUserConfig => { }, resolve: { alias: { - '@': path.resolve(__dirname, './src'), + '@': path.resolve(import.meta.dirname, './src'), }, }, },