diff --git a/.prettierignore b/.prettierignore index 496242d6..23583537 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,9 @@ # Dist folder dist/ +# mws +public/mockServiceWorker.js + # Other common ignores node_modules/ .DS_Store diff --git a/.storybook/msw-handlers.ts b/.storybook/msw-handlers.ts new file mode 100644 index 00000000..ffaeac7a --- /dev/null +++ b/.storybook/msw-handlers.ts @@ -0,0 +1,11 @@ +import { http, HttpResponse } from 'msw' + +export default { + getseed: [ + http.get('/api/v1/wallet/Satoshi.jmdat/getseed', () => { + return HttpResponse.json({ + seedphrase: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + }) + }), + ], +} diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ec6c69e7..d18f60e8 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,14 +1,22 @@ 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 { ThemeProvider } from 'next-themes' import { I18nextProvider } from 'react-i18next' import { MemoryRouter } from 'react-router-dom' import { CoreTypes, GlobalTypes } from 'storybook/internal/csf' import { DEFAULT_VIEWPORT, MINIMAL_VIEWPORTS, INITIAL_VIEWPORTS } from 'storybook/viewport' import { JamDisplayContextProvider } from '../src/context/JamDisplayContextProvider' +import { JamWalletInfoContextProvider } from '../src/context/JamWalletInfoContextProvider' import i18n from '../src/i18n/config' import '../src/index.css' +import mswHandlers from './msw-handlers' + +// Initialize MSW (https://github.com/mswjs/msw-storybook-addon) +initialize({ + onUnhandledRequest: 'bypass', +}) const locales = [ { value: 'en', title: 'en' }, @@ -69,7 +77,12 @@ const preview: Preview = { viewport: { options: { ...MINIMAL_VIEWPORTS, ...INITIAL_VIEWPORTS }, }, + msw: { + handlers: mswHandlers, + }, }, + // Provide the MSW addon loader globally + loaders: [mswLoader], } const withTheme = (Story: React.ComponentType, context: GlobalContext) => { @@ -114,7 +127,7 @@ export const withQueryClient = (Story: React.ComponentType) => { ) } -export const withJamDisplayContent = (Story: React.ComponentType) => { +export const withJamDisplayContext = (Story: React.ComponentType) => { return ( @@ -122,7 +135,22 @@ export const withJamDisplayContent = (Story: React.ComponentType) => { ) } +export const withJamWalletInfoContext = (Story: React.ComponentType) => { + return ( + + + + ) +} + // export decorators for storybook to wrap your stories in -export const decorators = [withTheme, withMemoryRouter, withI18next, withQueryClient, withJamDisplayContent] +export const decorators = [ + withTheme, + withMemoryRouter, + withI18next, + withJamWalletInfoContext, + withQueryClient, + withJamDisplayContext, +] export default preview diff --git a/eslint.config.js b/eslint.config.js index 23117c77..d96a277e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,7 +11,9 @@ import globals from 'globals' import tseslint from 'typescript-eslint' export default defineConfig( - { ignores: ['dist', './.storybook/**', './storybook-static/**'] }, + { + ignores: ['dist', './.storybook/**', './storybook-static/**', 'public/mockServiceWorker.js'], + }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], diff --git a/package-lock.json b/package-lock.json index d2a5d302..f8cf52a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,8 @@ "husky": "9.1.7", "jsdom": "29.1.1", "lint-staged": "17.0.7", + "msw": "2.14.6", + "msw-storybook-addon": "2.0.7", "prettier": "3.8.3", "prettier-plugin-tailwindcss": "0.8.0", "storybook": "10.4.2", @@ -1461,6 +1463,93 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@joinmarket-webui/joinmarket-api-ts": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@joinmarket-webui/joinmarket-api-ts/-/joinmarket-api-ts-0.3.0.tgz", @@ -1560,6 +1649,31 @@ "react": ">=16" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1612,6 +1726,31 @@ "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", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz", @@ -5140,6 +5279,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -6232,6 +6388,16 @@ "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", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -7166,6 +7332,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fd-package-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", @@ -7411,6 +7604,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.1.tgz", + "integrity": "sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -7456,6 +7659,24 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/headers-polyfill/node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -7738,6 +7959,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8568,6 +8796,201 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.0.1" + }, + "peerDependencies": { + "msw": "^2.0.0" + } + }, + "node_modules/msw/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/msw/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/msw/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/msw/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/msw/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/msw/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/msw/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/msw/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -8696,6 +9119,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/oxc-parser": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", @@ -8902,6 +9332,13 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9657,6 +10094,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -9938,6 +10382,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -10006,6 +10460,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -10131,6 +10592,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -10462,6 +10936,16 @@ "node": ">=18.12.0" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index 65ea5232..699eda5e 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,8 @@ "husky": "9.1.7", "jsdom": "29.1.1", "lint-staged": "17.0.7", + "msw": "2.14.6", + "msw-storybook-addon": "2.0.7", "prettier": "3.8.3", "prettier-plugin-tailwindcss": "0.8.0", "storybook": "10.4.2", @@ -149,5 +151,10 @@ "@joinmarket-webui/joinmarket-api-ts": { "@tanstack/react-query": "$@tanstack/react-query" } + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 00000000..d20cd4e0 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,336 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.14.6' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse(event, client, requestId, requestInterceptedAt) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter((value) => value !== 'msw/passthrough') + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.tsx b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.tsx index 062720b1..8e3e01d5 100644 --- a/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.tsx +++ b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.tsx @@ -11,11 +11,11 @@ import { } from 'lucide-react' import { Trans } from 'react-i18next' import { toast } from 'sonner' -import { BitcoinQR } from '@/components/receive/BitcoinQR' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { buttonVariants } from '@/components/ui/button-variants' import { Card, CardContent } from '@/components/ui/card' +import { BitcoinAddressQrCode } from '@/components/ui/jam/BitcoinQrCode' import { CopyButton } from '@/components/ui/jam/CopyButton' import { Label } from '@/components/ui/label' import { @@ -435,7 +435,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial address && (
- +
diff --git a/src/components/receive/BitcoinQR.tsx b/src/components/receive/BitcoinQR.tsx deleted file mode 100644 index 8a85f18f..00000000 --- a/src/components/receive/BitcoinQR.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useEffect, useState } from 'react' -import { DownloadIcon } from 'lucide-react' -import QRCode from 'qrcode' -import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' -import { cn, satsToBtc } from '@/lib/utils' -import type { AmountSats, BitcoinAddress } from '@/types/global' - -const DEFAULT_ERROR_CORRECTION: QRCode.QRCodeErrorCorrectionLevel = 'high' -const DEFAULT_IMAGE_MEDIA_TYPE: QRCode.QRCodeDataURLType = 'image/png' - -interface BitcoinQRProps { - className?: string - width: number - address: BitcoinAddress - amount?: AmountSats - errorCorrectionLevel?: QRCode.QRCodeErrorCorrectionLevel - type?: QRCode.QRCodeDataURLType -} - -export const BitcoinQR = ({ - className, - width, - address, - amount, - errorCorrectionLevel = DEFAULT_ERROR_CORRECTION, - type = DEFAULT_IMAGE_MEDIA_TYPE, -}: BitcoinQRProps) => { - const { t } = useTranslation() - const [data, setData] = useState() - const [imageDataUrl, setImageDataUrl] = useState() - - useEffect(() => { - const btc = amount ? satsToBtc(String(amount)) || 0 : 0 - const uri = `bitcoin:${address}${btc > 0 ? `?amount=${btc.toFixed(8)}` : ''}` - - const abortCtrl = new AbortController() - QRCode.toDataURL(uri, { - type, - errorCorrectionLevel, - width, - }) - .then((val) => { - if (abortCtrl.signal.aborted) return - setImageDataUrl(val) - setData(uri) - }) - .catch(() => { - if (abortCtrl.signal.aborted) return - setImageDataUrl(undefined) - setData(uri) - }) - - return () => abortCtrl.abort() - }, [address, amount, errorCorrectionLevel, width, type]) - - return ( -
- {imageDataUrl && ( - <> - {data} - - - )} -
- ) -} diff --git a/src/components/receive/ReceivePage.tsx b/src/components/receive/ReceivePage.tsx index 2be70f0a..30a86d17 100644 --- a/src/components/receive/ReceivePage.tsx +++ b/src/components/receive/ReceivePage.tsx @@ -20,8 +20,8 @@ import type { AmountSats, BitcoinAddress } from '@/types/global' import { Badge } from '../ui/badge' import { buttonVariants } from '../ui/button-variants' import { Address } from '../ui/jam/Address' +import { BitcoinAddressQrCode } from '../ui/jam/BitcoinQrCode' import { CopyButton } from '../ui/jam/CopyButton' -import { BitcoinQR } from './BitcoinQR' import { ReceiveForm } from './ReceiveForm' const QRCODE_WIDTH = 320 @@ -121,7 +121,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => { {getAddressMutation.isPending ? ( ) : getAddressMutation.data?.address ? ( - { disabled={getAddressMutation.isPending} > - {t('receive.button_reveal_address', { - defaultValue: 'Reveal address', - })} + { + /*TODO: i18n*/ t('receive.button_reveal_address', { + defaultValue: 'Reveal address', + }) + }
) : ( diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index ed6f3b85..8c863f4d 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -3,7 +3,7 @@ import { getseedOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/re import { mnemonicToSeed } from '@scure/bip39' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Network } from 'bitcoin-address-validation' -import { AlertTriangleIcon, ClockIcon, CopyIcon, CheckIcon, AlertCircleIcon } from 'lucide-react' +import { AlertTriangleIcon, ClockIcon, CopyIcon, CheckIcon, AlertCircleIcon, HatGlassesIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' @@ -27,10 +27,12 @@ import type { JarIndex, Milliseconds, MnemonicPhrase, WithRequiredProperty } fro import { Alert, AlertDescription, AlertTitle } from '../ui/alert' import { Badge } from '../ui/badge' import { buttonVariants } from '../ui/button-variants' +import { BitcoinXpubQrCode } from '../ui/jam/BitcoinQrCode' import { CopyButton } from '../ui/jam/CopyButton' import { Spinner } from '../ui/spinner' import { PasswordVerificationForm } from '../utils/PasswordVerificationForm' +const QRCODE_WIDTH = 320 const HD_PATH_PURPOSE: number = 84 type Xpub = { @@ -94,11 +96,76 @@ async function deriveAccountXpubsFromSeed( }) } +interface AccountXpubItemProps { + xpub: Xpub + accountNameAndLabel: string +} + +const AccountXpubItem = ({ xpub, accountNameAndLabel }: AccountXpubItemProps) => { + const { t } = useTranslation() + const [showQrCode, setShowQrCode] = useState(false) + + return ( +
+
+
{xpub.xpub}
+ } + successText={} + title={t('settings.xpubs_modal.button_copy_title', { + account: accountNameAndLabel, + })} + aria-label={t('settings.xpubs_modal.button_copy_title', { + account: accountNameAndLabel, + })} + onSuccess={() => + toast.success( + t('settings.xpubs_modal.alert_success_account_xpub_copied_message', { + account: accountNameAndLabel, + }), + ) + } + onError={(error) => + toast.error( + t('global.errors.error_copy_to_clipboard_failed', { + reason: (error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown'), + }), + ) + } + /> +
+ {showQrCode ? ( + + ) : ( +
+ +
+ )} +
+ ) +} + interface AccountXpubsAccordionProps { values: AccountXpubInfo[] } -const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) => { +export const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) => { const { t } = useTranslation() return ( @@ -126,42 +193,7 @@ const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) => { {account.xpubs.map((xpub, index) => { const accountNameAndLabel = `${account.accountName} (${accountLabel})` - return ( -
-
{xpub.xpub}
- } - successText={} - title={t('settings.xpubs_modal.button_copy_title', { - account: accountNameAndLabel, - })} - aria-label={t('settings.xpubs_modal.button_copy_title', { - account: accountNameAndLabel, - })} - onSuccess={() => - toast.success( - t('settings.xpubs_modal.alert_success_account_xpub_copied_message', { - account: accountNameAndLabel, - }), - ) - } - onError={(error) => - toast.error( - t('global.errors.error_copy_to_clipboard_failed', { - reason: - (error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown'), - }), - ) - } - /> -
- ) + return })} diff --git a/src/components/ui/jam/BitcoinQrCode.tsx b/src/components/ui/jam/BitcoinQrCode.tsx new file mode 100644 index 00000000..e5b9f498 --- /dev/null +++ b/src/components/ui/jam/BitcoinQrCode.tsx @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { DownloadIcon } from 'lucide-react' +import QRCode from 'qrcode' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import { cn, satsToBtc } from '@/lib/utils' +import type { AmountSats, BitcoinAddress } from '@/types/global' + +const DEFAULT_ERROR_CORRECTION: QRCode.QRCodeErrorCorrectionLevel = 'high' +const DEFAULT_IMAGE_MEDIA_TYPE: QRCode.QRCodeDataURLType = 'image/png' + +interface QrCodeComponentProps { + value: string + width: number + errorCorrectionLevel?: QRCode.QRCodeErrorCorrectionLevel + type?: QRCode.QRCodeDataURLType + className?: string + children?: (imageDataUrl: string) => ReactNode | undefined +} + +const QrCodeComponent = ({ + className, + width, + value, + errorCorrectionLevel = DEFAULT_ERROR_CORRECTION, + type = DEFAULT_IMAGE_MEDIA_TYPE, + children, +}: QrCodeComponentProps) => { + const [data, setData] = useState() + const [imageDataUrl, setImageDataUrl] = useState() + + useEffect(() => { + const abortCtrl = new AbortController() + QRCode.toDataURL(value, { + type, + errorCorrectionLevel, + width, + }) + .then((val) => { + if (abortCtrl.signal.aborted) return + setImageDataUrl(val) + setData(value) + }) + .catch(() => { + if (abortCtrl.signal.aborted) return + setImageDataUrl(undefined) + setData(value) + }) + + return () => abortCtrl.abort() + }, [value, errorCorrectionLevel, width, type]) + + return ( +
+ {imageDataUrl && ( + <> + {data} + {children?.(imageDataUrl)} + + )} +
+ ) +} + +type BitcoinAddressQrCodeProps = Omit & { + address: BitcoinAddress + amount?: AmountSats +} + +export const BitcoinAddressQrCode = ({ address, amount, type, ...props }: BitcoinAddressQrCodeProps) => { + const { t } = useTranslation() + + const uri = useMemo(() => { + const btc = amount ? satsToBtc(String(amount)) || 0 : 0 + return `bitcoin:${address}${btc > 0 ? `?amount=${btc.toFixed(8)}` : ''}` + }, [address, amount]) + + return ( + + {(imageDataUrl) => ( + + )} + + ) +} + +type BitcoinXpubQrCodeProps = Omit & { + xpub: string +} + +export const BitcoinXpubQrCode = ({ xpub, ...props }: BitcoinXpubQrCodeProps) => ( + +) diff --git a/src/stories/Avatar.stories.tsx b/src/stories/Avatar.stories.tsx index 6a659e02..ea040def 100644 --- a/src/stories/Avatar.stories.tsx +++ b/src/stories/Avatar.stories.tsx @@ -13,7 +13,7 @@ type Story = StoryObj export const WithImage: Story = { render: (args) => ( - + AB ), diff --git a/src/stories/jam/BitcoinAddressQrCode.stories.tsx b/src/stories/jam/BitcoinAddressQrCode.stories.tsx new file mode 100644 index 00000000..999a7eeb --- /dev/null +++ b/src/stories/jam/BitcoinAddressQrCode.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { BitcoinAddressQrCode } from '@/components/ui/jam/BitcoinQrCode' + +const meta: Meta = { + title: 'Jam/BitcoinAddressQrCode', + component: BitcoinAddressQrCode, + tags: ['autodocs'], + args: { + address: 'bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx', + width: 250, + }, + argTypes: {}, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: {}, +} + +export const Legacy: Story = { + args: { + address: 'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV', + }, +} diff --git a/src/stories/jam/BitcoinXpubQrCode.stories.tsx b/src/stories/jam/BitcoinXpubQrCode.stories.tsx new file mode 100644 index 00000000..271bc59c --- /dev/null +++ b/src/stories/jam/BitcoinXpubQrCode.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { BitcoinXpubQrCode } from '@/components/ui/jam/BitcoinQrCode' + +const meta: Meta = { + title: 'Jam/BitcoinXpubQrCode', + component: BitcoinXpubQrCode, + tags: ['autodocs'], + args: { + xpub: 'vpub5Y6cjg78GGuNLsaPhmYsiw4gYX3HoQiRBiSwDaBXKUafCt9bNwWQiitDk5VZ5BVxYnQdwoTyXSs2JHRPAgjAvtbBrf8ZhDYe2jWAqvZVnsc', + width: 250, + }, + argTypes: {}, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: {}, +} diff --git a/src/stories/jam/dialogs/AccountXpubsDialog.stories.tsx b/src/stories/jam/dialogs/AccountXpubsDialog.stories.tsx new file mode 100644 index 00000000..de4e6e0e --- /dev/null +++ b/src/stories/jam/dialogs/AccountXpubsDialog.stories.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Network } from 'bitcoin-address-validation' +import { AccountXpubsAccordion, AccountXpubsDialog } from '@/components/settings/AccountXpubsDialog' +import { Button } from '@/components/ui/button' + +const meta: Meta = { + title: 'Dialog/AccountXpubsDialog', + component: AccountXpubsDialog, + tags: ['autodocs'], + render: (args) => { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)} /> + + ) + }, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + walletFileName: 'Satoshi.jmdat', + hashedPassword: /* hash("test") := */ 'da41454ecc40c48499decbca7b1df4595f0a856caada3f182d47293fbad03004', + autoCloseTimeout: 60_000, + }, +} + +export const Content: StoryObj = { + render: () => ( + + ), +} diff --git a/vite.config.ts b/vite.config.ts index 8a082446..77bf949e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,6 @@ import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' +import fs from 'node:fs/promises' import path from 'node:path' import { type ServerOptions, type UserConfig, defineConfig } from 'vite' @@ -64,7 +65,16 @@ export default defineConfig((config): UserConfig => { const buildOrPreview = config.command === 'build' || config.isPreview === true return { - plugins: [react(), tailwindcss()], + plugins: [ + react(), + tailwindcss({ optimize: buildOrPreview }), + { + name: 'delete-service-worker', + async writeBundle() { + await fs.rm('dist/mockServiceWorker.js', { recursive: true }) + }, + }, + ], resolve: { alias: { '@': path.resolve(__dirname, './src'),