From 054f5fcd635480394e98e37ce4e47de9b517fcb9 Mon Sep 17 00:00:00 2001 From: Nischal Shetty Date: Fri, 27 Jun 2025 20:32:49 +0530 Subject: [PATCH] feat: add receive page with amount input, selectable jar and QR code --- package-lock.json | 357 +++++++++++++++++- package.json | 5 + src/App.tsx | 33 +- src/components/CreateWallet.tsx | 1 - src/components/DisplayLogo.tsx | 5 +- src/components/JamLanding.tsx | 2 +- src/components/Navbar.tsx | 70 ++-- src/components/layout/Jar.tsx | 74 +--- src/components/layout/Layout.tsx | 1 - src/components/layout/display-mode-context.ts | 1 - src/components/receive/BitcoinAmountInput.tsx | 54 +++ src/components/receive/BitcoinQR.tsx | 80 ++++ src/components/receive/Receive.tsx | 225 +++++++++++ src/components/ui/JarIcon.tsx | 86 +++++ src/components/ui/SelectableJar.tsx | 36 ++ src/components/ui/accordion.tsx | 50 +++ src/hooks/useSession.ts | 21 ++ src/hooks/useWalletDisplay/index.ts | 2 - src/index.css | 5 + src/lib/jm-api/generated/client/types.gen.ts | 4 +- src/lib/session.ts | 12 +- src/lib/utils.test.ts | 115 ++++++ src/lib/utils.ts | 15 +- src/stories/Accordion.stories.tsx | 90 +++++ src/stories/BitcoinAmountInput.stories.tsx | 69 ++++ src/stories/Navbar.stories.tsx | 1 - src/stories/SelectableJar.stories.tsx | 198 ++++++++++ vite.config.ts | 9 + 28 files changed, 1477 insertions(+), 144 deletions(-) create mode 100644 src/components/receive/BitcoinAmountInput.tsx create mode 100644 src/components/receive/BitcoinQR.tsx create mode 100644 src/components/receive/Receive.tsx create mode 100644 src/components/ui/JarIcon.tsx create mode 100644 src/components/ui/SelectableJar.tsx create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/hooks/useSession.ts create mode 100644 src/lib/utils.test.ts create mode 100644 src/stories/Accordion.stories.tsx create mode 100644 src/stories/BitcoinAmountInput.stories.tsx create mode 100644 src/stories/SelectableJar.stories.tsx diff --git a/package-lock.json b/package-lock.json index a0244ecb..128d913c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@hey-api/client-fetch": "^0.13.1", + "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", @@ -21,6 +22,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.518.0", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.2", @@ -39,6 +41,7 @@ "@storybook/react-vite": "^9.0.12", "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/node": "^22.15.29", + "@types/qrcode": "^1.5.5", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.5.2", @@ -1414,6 +1417,37 @@ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.11.tgz", + "integrity": "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collapsible": "1.1.11", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1464,6 +1498,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz", + "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -3191,6 +3255,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -3789,7 +3863,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3799,7 +3872,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4021,6 +4093,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001720", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", @@ -4215,6 +4296,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/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==", + "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/cliui/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==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4228,7 +4366,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4241,7 +4378,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -4449,6 +4585,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4545,6 +4690,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -5170,6 +5321,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", @@ -5555,7 +5715,6 @@ "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" @@ -6702,6 +6861,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -6726,7 +6894,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6887,6 +7054,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", @@ -7072,6 +7248,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7339,6 +7532,21 @@ "node": ">=8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -7497,6 +7705,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", @@ -8644,6 +8858,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -8784,6 +9004,12 @@ } } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8804,6 +9030,125 @@ "node": ">= 14.6" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/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==", + "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/yargs/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==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 83f32fad..5b525518 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "build-storybook": "storybook build", "prepare": "husky install", "test": "vitest run", + "test:unit": "vitest run --project unit", + "test:storybook": "vitest run --project storybook", "regtest:build": "npm run regtest:clear && docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml build --pull", "regtest:rebuild": "rm -rf docker/regtest/.tmp && npm run regtest:build -- --no-cache", "regtest:clear": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml down --volumes --remove-orphans", @@ -35,6 +37,7 @@ }, "dependencies": { "@hey-api/client-fetch": "^0.13.1", + "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", @@ -47,6 +50,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.518.0", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.2", @@ -65,6 +69,7 @@ "@storybook/react-vite": "^9.0.12", "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/node": "^22.15.29", + "@types/qrcode": "^1.5.5", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.5.2", diff --git a/src/App.tsx b/src/App.tsx index ca5bec85..0f2780a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { QueryClientProvider } from '@tanstack/react-query' import { ThemeProvider } from 'next-themes' import { Navigate, Route, BrowserRouter as Router, Routes } from 'react-router-dom' @@ -10,39 +10,50 @@ import { Layout } from '@/components/layout/Layout' import { Toaster } from '@/components/ui/sonner' import { JM_API_AUTH_TOKEN_EXPIRY } from '@/constants/jm' import { useApiClient } from '@/hooks/useApiClient' +import { useSession } from '@/hooks/useSession' import { token } from '@/lib/jm-api/generated/client' import { queryClient } from '@/lib/queryClient' import { clearSession, getSession, setSession } from '@/lib/session' import { setIntervalDebounced } from '@/lib/utils' +import { Receive } from './components/receive/Receive' -const isAuthenticated = () => { - const session = getSession() - return session?.auth?.token !== undefined -} - -const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { - return isAuthenticated() ? <>{children} : +const ProtectedRoute = ({ children, authenticated }: { children: React.ReactNode; authenticated: boolean }) => { + return authenticated ? <>{children} : } function App() { + const session = useSession() + const authenticated = useMemo(() => session?.auth?.token !== undefined, [session]) + const walletFileName = useMemo(() => session?.walletFileName || '', [session]) + return ( - : } /> - : } /> + : } /> + : } /> + } /> + + + + + + } + /> } /> diff --git a/src/components/CreateWallet.tsx b/src/components/CreateWallet.tsx index c60eefc3..462f4091 100644 --- a/src/components/CreateWallet.tsx +++ b/src/components/CreateWallet.tsx @@ -25,7 +25,6 @@ const CreateWallet = () => { const handleCreateWallet = async (e: React.FormEvent) => { e.preventDefault() - // Validation if (!walletName.trim()) { toast.error('Wallet name is required') return diff --git a/src/components/DisplayLogo.tsx b/src/components/DisplayLogo.tsx index 7d9ca6fb..370a70e8 100644 --- a/src/components/DisplayLogo.tsx +++ b/src/components/DisplayLogo.tsx @@ -5,20 +5,19 @@ type DisplayLogoProps = { export function DisplayLogo({ displayMode, size = 'lg' }: DisplayLogoProps) { if (displayMode === 'btc') { - return + return } return ( diff --git a/src/components/JamLanding.tsx b/src/components/JamLanding.tsx index ae6020f7..91c1fb4e 100644 --- a/src/components/JamLanding.tsx +++ b/src/components/JamLanding.tsx @@ -33,7 +33,7 @@ export default function JamLanding() { {formatAmount(totalBalance)}{' '} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 8ba24b91..c61337f1 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,4 +1,5 @@ import { Loader2, LogOut, Moon, Settings, Sun, Wallet } from 'lucide-react' +import { Link } from 'react-router-dom' import type { Jar } from '@/components/layout/display-mode-context' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -7,22 +8,13 @@ import { clearSession } from '@/lib/session' interface NavbarProps { theme: string toggleTheme: () => void - toggleDisplayMode: () => void formatAmount: (amount: number) => string getLogo: (size: 'sm' | 'lg') => React.ReactNode jars: Jar[] isLoading?: boolean } -export function Navbar({ - theme, - toggleTheme, - toggleDisplayMode, - formatAmount, - getLogo, - jars, - isLoading = false, -}: NavbarProps) { +export function Navbar({ theme, toggleTheme, formatAmount, getLogo, jars, isLoading = false }: NavbarProps) { const handleLogout = () => { clearSession() window.location.href = '/login' @@ -33,39 +25,43 @@ export function Navbar({ return (
- -
- Satoshi - - dev - -
- {isLoading ? ( - - ) : ( - <> - - {formatAmount(totalBalance)} - - {getLogo('sm')} - - )} + + +
+ Satoshi + + dev + +
+ {isLoading ? ( + + ) : ( + <> + + {formatAmount(totalBalance)} + + {getLogo('sm')} + + )} +
-
+
- Receive - + + Receive + + Earn - - Send + + + Send + | - Sweep + + Sweep +
+
+ + ) +} diff --git a/src/components/receive/BitcoinQR.tsx b/src/components/receive/BitcoinQR.tsx new file mode 100644 index 00000000..3b83de8f --- /dev/null +++ b/src/components/receive/BitcoinQR.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react' +import { Download } from 'lucide-react' +import QRCode from 'qrcode' +import { toast } from 'sonner' +import { satsToBtc } from '@/lib/utils' +import { Button } from '../ui/button' + +interface BitcoinQRProps { + address: string + amount?: number + errorCorrectionLevel?: QRCode.QRCodeErrorCorrectionLevel + width?: number +} + +export const BitcoinQR = ({ address, amount, errorCorrectionLevel = 'H', width = 260 }: BitcoinQRProps) => { + const [data, setData] = useState() + const [image, setImage] = useState() + const [isHovering, setIsHovering] = useState(false) + + useEffect(() => { + const btc = amount ? satsToBtc(String(amount)) || 0 : 0 + const uri = `bitcoin:${address}${btc > 0 ? `?amount=${btc.toFixed(8)}` : ''}` + + QRCode.toDataURL(uri, { + errorCorrectionLevel, + width, + }) + .then((val) => { + setImage(val) + setData(uri) + }) + .catch(() => { + setImage(undefined) + setData(uri) + }) + }, [address, amount, errorCorrectionLevel, width]) + + const downloadQR = () => { + if (!image) return + + const link = document.createElement('a') + link.href = image + link.download = `bitcoin-qr-${address}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + toast.success('QR code downloaded') + } + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + {image && ( + <> + {data} + {isHovering && ( + + )} + + )} +
+ ) +} diff --git a/src/components/receive/Receive.tsx b/src/components/receive/Receive.tsx new file mode 100644 index 00000000..360cf854 --- /dev/null +++ b/src/components/receive/Receive.tsx @@ -0,0 +1,225 @@ +import { useState, useEffect, useCallback, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Copy, CopyCheck, RefreshCw, Share } from 'lucide-react' +import { toast } from 'sonner' +import { useApiClient } from '@/hooks/useApiClient' +import { getaddressOptions } from '@/lib/jm-api/generated/client/@tanstack/react-query.gen' +import { btcToSats, satsToBtc } from '@/lib/utils' +import { useJamDisplayContext } from '../layout/display-mode-context' +import { SelectableJar } from '../ui/SelectableJar' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' +import { Button } from '../ui/button' +import { Skeleton } from '../ui/skeleton' +import { BitcoinAmountInput } from './BitcoinAmountInput' +import { BitcoinQR } from './BitcoinQR' + +interface ReceiveProps { + walletFileName: string +} + +export const Receive = ({ walletFileName }: ReceiveProps) => { + const [selectedJarIndex, setSelectedJarIndex] = useState(0) + const [amount, setAmount] = useState() + const [bitcoinAddress, setBitcoinAddress] = useState() + const [copied, setCopied] = useState(false) + const [amountDisplayMode, setAmountDisplayMode] = useState<'sats' | 'btc'>('sats') + + const { jars } = useJamDisplayContext() + const client = useApiClient() + + const totalBalance = useMemo(() => { + return jars.reduce((total, jar) => total + (jar.balance || 0), 0) + }, [jars]) + + const getAddressQuery = useQuery({ + ...getaddressOptions({ + client, + path: { + walletname: walletFileName!, + mixdepth: String(selectedJarIndex), + }, + }), + retry: false, + enabled: walletFileName !== undefined && selectedJarIndex !== undefined, + staleTime: 1, + }) + + useEffect(() => { + if (getAddressQuery.data === undefined) return + setBitcoinAddress(getAddressQuery.data.address) + }, [getAddressQuery.data]) + + const isQrLoading = useMemo(() => { + return getAddressQuery.isFetching + }, [getAddressQuery.isFetching]) + + const copyToClipboard = () => { + if (bitcoinAddress) { + navigator.clipboard.writeText(bitcoinAddress) + setCopied(true) + toast.success('Address copied to clipboard') + } else { + toast.error('No Address to copy') + } + } + + const shareAddress = () => { + if ('share' in navigator && bitcoinAddress) { + navigator + .share({ + title: 'Bitcoin Address', + text: bitcoinAddress, + }) + .catch(() => { + // Fall back to clipboard if sharing fails + copyToClipboard() + }) + } else if (bitcoinAddress) { + copyToClipboard() + } else { + toast.error('No Address to share') + } + } + + const getNewAddress = useCallback(() => { + getAddressQuery.refetch() + }, [getAddressQuery]) + + const selectJar = (index: number) => { + setSelectedJarIndex(index) + } + + const handleAmountChange = (e: React.ChangeEvent) => { + const value = e.target.value + if (value === '') { + setAmount(undefined) + return + } + + const numValue = parseFloat(value) + if (amountDisplayMode === 'btc') { + setAmount(btcToSats(numValue.toString())) + } else { + setAmount(Math.floor(numValue)) + } + } + + const toggleDisplayMode = () => { + setAmountDisplayMode((prev) => (prev === 'sats' ? 'btc' : 'sats')) + } + + const getDisplayAmount = () => { + if (!amount) return '' + if (amountDisplayMode === 'btc') { + return satsToBtc(amount.toString()).toFixed(8) + } + return amount.toString() + } + + useEffect(() => { + const timer = setTimeout(() => { + setCopied(false) + }, 1_500) + + return () => clearTimeout(timer) + }, [copied]) + + if (!walletFileName) { + return ( +
+

Receive

+

No wallet available

+
+ ) + } + + if (!jars || jars.length === 0) { + return ( +
+

Receive

+

Loading wallet data...

+
+ ) + } + + return ( +
+

Receive

+

Send sats to the address below to fund your wallet.

+ +
+ {isQrLoading ? ( + + ) : bitcoinAddress ? ( + + ) : ( +
+ No address available +
+ )} + + {isQrLoading ? ( + + ) : ( +

{bitcoinAddress}

+ )} + +
+ + + + + {'share' in navigator && ( + + )} +
+
+ + + + + Receive options + + +
+

Receive to

+ +
+ {jars.map((jar, index) => ( + selectJar(index)} + /> + ))} +
+ +
+ +
+
+
+
+
+
+ ) +} diff --git a/src/components/ui/JarIcon.tsx b/src/components/ui/JarIcon.tsx new file mode 100644 index 00000000..2b8bdf74 --- /dev/null +++ b/src/components/ui/JarIcon.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from 'react' +import { useState } from 'react' +import { cn } from '@/lib/utils' + +const calculateJarFillLevel = (jarBalance: number, totalBalance: number): JarFillLevel => { + if (totalBalance === 0) return 0 + if (jarBalance > totalBalance / 2) return 3 + if (jarBalance > totalBalance / 4) return 2 + if (jarBalance > 0) return 1 + return 0 +} + +type JarFillLevel = 0 | 1 | 2 | 3 + +const fillLevelToPercent = (fillLevel: JarFillLevel): number => { + switch (fillLevel) { + case 0: + return 0 + case 1: + return 30 + case 2: + return 60 + case 3: + return 90 + } +} + +interface JarIconProps { + color?: string + amount?: number + totalBalance?: number + width?: number + height?: number + isSelected?: boolean + className?: string + children?: ReactNode +} + +export function JarIcon({ + color = '#e2b86a', + amount = 0, + totalBalance = 0, + isSelected = false, + className, +}: JarIconProps) { + const [isHovered, setIsHovered] = useState(false) + const fillLevel = calculateJarFillLevel(amount, totalBalance) + const fillPercent = fillLevelToPercent(fillLevel) + + const isLidOpen = isHovered || isSelected + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Jar body */} +
+
+ {/* Fill */} +
+
+
+ {/* Jar neck */} +
+ {/* Jar lid */} +
+
+ ) +} diff --git a/src/components/ui/SelectableJar.tsx b/src/components/ui/SelectableJar.tsx new file mode 100644 index 00000000..817626d1 --- /dev/null +++ b/src/components/ui/SelectableJar.tsx @@ -0,0 +1,36 @@ +import { DisplayLogo } from '../DisplayLogo' +import { JarIcon } from './JarIcon' + +interface SelectableJarProps { + name: string + color: string + balance: number + totalBalance: number + isSelected: boolean + onClick: () => void +} + +export const SelectableJar = ({ name, color, balance, totalBalance, isSelected, onClick }: SelectableJarProps) => { + return ( + + ) +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 00000000..822edd5e --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { ChevronDownIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +function Accordion({ ...props }: React.ComponentProps) { + return +} + +function AccordionItem({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ className, children, ...props }: React.ComponentProps) { + return ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ className, children, ...props }: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/hooks/useSession.ts b/src/hooks/useSession.ts new file mode 100644 index 00000000..133218e7 --- /dev/null +++ b/src/hooks/useSession.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react' +import { getSession } from '@/lib/session' + +export const useSession = () => { + const [session, setSessionState] = useState(getSession()) + + useEffect(() => { + // Listen for custom session update events + const handleSessionUpdate = () => { + setSessionState(getSession()) + } + + window.addEventListener('sessionUpdate', handleSessionUpdate) + + return () => { + window.removeEventListener('sessionUpdate', handleSessionUpdate) + } + }, []) + + return session +} diff --git a/src/hooks/useWalletDisplay/index.ts b/src/hooks/useWalletDisplay/index.ts index 57fd587f..a69efbd9 100644 --- a/src/hooks/useWalletDisplay/index.ts +++ b/src/hooks/useWalletDisplay/index.ts @@ -22,11 +22,9 @@ interface AccountBalance { export function useWalletDisplay(): UseWalletDisplayResult { const client = useApiClient() - // Get wallet name from session const session = getSession() const walletFileName = session?.walletFileName - // Check if a session is active const sessionQuery = useQuery({ ...sessionOptions({ client }), enabled: !!walletFileName, diff --git a/src/index.css b/src/index.css index 3ca0bd72..8e448e4a 100644 --- a/src/index.css +++ b/src/index.css @@ -227,3 +227,8 @@ color: hsl(221 95% 85%); opacity: 0.8; } + +html { + overflow: -moz-scrollbars-vertical; + overflow-y: scroll; +} diff --git a/src/lib/jm-api/generated/client/types.gen.ts b/src/lib/jm-api/generated/client/types.gen.ts index 928a3e53..6ff04b68 100644 --- a/src/lib/jm-api/generated/client/types.gen.ts +++ b/src/lib/jm-api/generated/client/types.gen.ts @@ -83,7 +83,9 @@ export type StartMakerRequest = { minsize: string; }; -export type GetAddressResponse = string; +export type GetAddressResponse = { + address: string; +}; export type ListWalletsResponse = { wallets?: Array; diff --git a/src/lib/session.ts b/src/lib/session.ts index 946a4c95..6447b2ea 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -8,7 +8,6 @@ interface SessionData { } } -// Function to save session after successful login export const setSession = (session: Partial) => { sessionStorage.setItem( 'joinmarket', @@ -17,15 +16,22 @@ export const setSession = (session: Partial) => { ...session, }), ) + + dispatchEvent('sessionUpdate') } -// Function to get current session export const getSession = (): Partial | null => { const session = sessionStorage.getItem('joinmarket') return session ? JSON.parse(session) : null } -// Function to clear session on logout export const clearSession = () => { sessionStorage.removeItem('joinmarket') + + dispatchEvent('sessionUpdate') +} + +// Dispatch custom event to notify components of session change +function dispatchEvent(eventName: string) { + window.dispatchEvent(new CustomEvent(eventName)) } diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 00000000..0d1a03dd --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { cn, formatWalletName, JM_WALLET_FILE_EXTENSION, setIntervalDebounced, satsToBtc, btcToSats } from './utils' + +describe('cn', () => { + it('should merge class names correctly', () => { + expect(cn('text-red-500', 'bg-blue-500')).toBe('text-red-500 bg-blue-500') + expect(cn('text-red-500', { 'bg-blue-500': true })).toBe('text-red-500 bg-blue-500') + expect(cn('text-red-500', { 'bg-blue-500': false })).toBe('text-red-500') + }) + + it('should handle conflicting classes with tailwind-merge', () => { + expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500') + expect(cn('p-4 px-8')).toBe('p-4 px-8') + }) +}) + +describe('formatWalletName', () => { + it('should remove .jmdat extension from wallet names', () => { + expect(formatWalletName('mywallet.jmdat')).toBe('mywallet') + expect(formatWalletName('test_wallet.jmdat')).toBe('test_wallet') + }) + + it('should return the same string if no .jmdat extension is present', () => { + expect(formatWalletName('mywallet')).toBe('mywallet') + expect(formatWalletName('wallet.txt')).toBe('wallet.txt') + }) + + it('should use the correct wallet file extension', () => { + expect(JM_WALLET_FILE_EXTENSION).toBe('.jmdat') + expect('wallet' + JM_WALLET_FILE_EXTENSION).toBe('wallet.jmdat') + }) +}) + +describe('setIntervalDebounced', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.resetAllMocks() + vi.useRealTimers() + }) + + it('should call the callback function after the specified delay', async () => { + const callback = vi.fn().mockResolvedValue(undefined) + const onTimerIdChanged = vi.fn() + + setIntervalDebounced(callback, 1000, onTimerIdChanged) + + expect(callback).not.toHaveBeenCalled() + expect(onTimerIdChanged).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1000) + + expect(callback).toHaveBeenCalledTimes(1) + expect(onTimerIdChanged).toHaveBeenCalledTimes(2) + }) + + it('should call the onError handler when the callback throws an error', async () => { + const error = new Error('Test error') + const callback = vi.fn().mockRejectedValue(error) + const onTimerIdChanged = vi.fn() + const onError = vi.fn() + + setIntervalDebounced(callback, 1000, onTimerIdChanged, onError) + + await vi.advanceTimersByTimeAsync(1000) + + expect(callback).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(error, expect.any(Function)) + }) + + it('should continue the loop when onError calls the loop function', async () => { + const error = new Error('Test error') + const callback = vi.fn().mockRejectedValueOnce(error).mockResolvedValueOnce(undefined) + const onTimerIdChanged = vi.fn() + const onError = vi.fn((_, loop) => loop()) + + setIntervalDebounced(callback, 1000, onTimerIdChanged, onError) + + await vi.advanceTimersByTimeAsync(1000) + + expect(callback).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(error, expect.any(Function)) + + await vi.advanceTimersByTimeAsync(1000) + + expect(callback).toHaveBeenCalledTimes(2) + }) +}) + +describe('satsToBtc', () => { + it('should correctly convert satoshis to BTC', () => { + expect(satsToBtc('100000000')).toBe(1) + expect(satsToBtc('50000000')).toBe(0.5) + expect(satsToBtc('1000')).toBe(0.00001) + expect(satsToBtc('0')).toBe(0) + expect(satsToBtc('123456789')).toBe(1.23456789) + }) +}) + +describe('btcToSats', () => { + it('should correctly convert BTC to satoshis', () => { + expect(btcToSats('1')).toBe(100000000) + expect(btcToSats('0.5')).toBe(50000000) + expect(btcToSats('0.00001')).toBe(1000) + expect(btcToSats('0')).toBe(0) + }) + + it('should handle rounding', () => { + expect(btcToSats('0.123456789')).toBe(12345679) // Rounds to nearest integer + expect(btcToSats('0.000000004')).toBe(0) // Less than 0.5 sats rounds down + expect(btcToSats('0.000000005')).toBe(1) // 0.5 sats or more rounds up + }) +}) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 13ea979c..c95dea84 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,18 +5,15 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } +export const JM_WALLET_FILE_EXTENSION = '.jmdat' + /** * Formats a wallet name by removing the .jmdat extension for UI display * @param walletName - The full wallet name with .jmdat extension * @returns The wallet name without the .jmdat extension */ -export function formatWalletName(walletName: string | null | undefined): string { - if (!walletName || walletName === 'None') { - return 'None' - } - - // Remove .jmdat extension if present - return walletName.endsWith('.jmdat') ? walletName.slice(0, '.jmdat'.length * -1) : walletName +export function formatWalletName(walletName: string): string { + return walletName.replace(JM_WALLET_FILE_EXTENSION, '') } export const setIntervalDebounced = ( @@ -38,3 +35,7 @@ export const setIntervalDebounced = ( ) })() } + +export const satsToBtc = (value: string) => parseInt(value, 10) / 100000000 + +export const btcToSats = (value: string) => Math.round(parseFloat(value) * 100000000) diff --git a/src/stories/Accordion.stories.tsx b/src/stories/Accordion.stories.tsx new file mode 100644 index 00000000..4295c063 --- /dev/null +++ b/src/stories/Accordion.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' + +const meta: Meta = { + title: 'Core/Accordion', + component: Accordion, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'A vertically stacked set of interactive headings that each reveal a section of content.', + }, + }, + }, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + Example use case + + This is an accordion component that can be used to display content in a collapsible format. + + + + ), +} + +export const Multiple: Story = { + render: () => ( + + + Is it accessible? + Yes. It adheres to the WAI-ARIA design pattern. + + + Is it styled? + + Yes. It comes with default styles that match the other components' aesthetic. + + + + Is it animated? + Yes. It's animated by default, but you can disable it if you prefer. + + + ), +} + +export const DefaultOpen: Story = { + render: () => ( + + + First section + This section is closed by default. + + + Second section + This section is open by default because we set defaultValue="item-2". + + + Third section + This section is closed by default. + + + ), +} + +export const CustomStyling: Story = { + render: () => ( + + + Custom styled accordion + + This accordion has custom styling applied to demonstrate the flexibility of the component. + + + + Another custom section + + You can customize each accordion item independently. + + + + ), +} diff --git a/src/stories/BitcoinAmountInput.stories.tsx b/src/stories/BitcoinAmountInput.stories.tsx new file mode 100644 index 00000000..df9526da --- /dev/null +++ b/src/stories/BitcoinAmountInput.stories.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { BitcoinAmountInput } from '@/components/receive/BitcoinAmountInput' + +const meta: Meta = { + title: 'Receive/BitcoinAmountInput', + component: BitcoinAmountInput, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'An input component for entering Bitcoin amounts with the ability to toggle between BTC and sats display modes.', + }, + }, + }, +} + +export default meta + +type Story = StoryObj + +// Wrapper component to handle state for the storybook examples +const BitcoinAmountInputWrapper = ({ + initialMode = 'sats', + initialAmount = '', + disabled = false, +}: { + initialMode?: 'sats' | 'btc' + initialAmount?: string + disabled?: boolean +}) => { + const [amountDisplayMode, setAmountDisplayMode] = useState<'sats' | 'btc'>(initialMode) + const [amount, setAmount] = useState(initialAmount) + + const toggleDisplayMode = () => { + setAmountDisplayMode((prev) => (prev === 'sats' ? 'btc' : 'sats')) + } + + const handleAmountChange = (e: React.ChangeEvent) => { + setAmount(e.target.value) + } + + return ( + + ) +} + +export const Default: Story = { + render: () => , +} + +export const BTCMode: Story = { + render: () => , +} + +export const WithInitialAmount: Story = { + render: () => , +} + +export const DisabledState: Story = { + render: () => , +} diff --git a/src/stories/Navbar.stories.tsx b/src/stories/Navbar.stories.tsx index 932e061b..eb95e160 100644 --- a/src/stories/Navbar.stories.tsx +++ b/src/stories/Navbar.stories.tsx @@ -31,7 +31,6 @@ export const Default: Story = { args: { theme: 'light', toggleTheme: () => alert('Theme toggled!'), - toggleDisplayMode: () => alert('Display mode toggled!'), formatAmount: mockFormatAmount, getLogo: mockGetLogo, jars: mockJars, diff --git a/src/stories/SelectableJar.stories.tsx b/src/stories/SelectableJar.stories.tsx new file mode 100644 index 00000000..f74fd044 --- /dev/null +++ b/src/stories/SelectableJar.stories.tsx @@ -0,0 +1,198 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { SelectableJar } from '../components/ui/SelectableJar' + +const meta = { + title: 'Components/SelectableJar', + component: SelectableJar, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'A selectable jar component used for Bitcoin wallet account selection. The jar visually represents a savings account with its fill level indicating the balance relative to the total wallet balance.', + }, + }, + }, + argTypes: { + name: { + description: 'The display name of the jar account', + control: 'text', + }, + color: { + description: 'The color of the jar, affects fill color', + control: 'color', + }, + balance: { + description: 'Current balance of the jar in sats', + control: { type: 'number', min: 0, step: 1000 }, + }, + totalBalance: { + description: 'Total wallet balance in sats, used to calculate fill percentage', + control: { type: 'number', min: 1, step: 1000 }, + }, + isSelected: { + description: 'Whether the jar is currently selected', + control: 'boolean', + }, + onClick: { + description: 'Callback function triggered when jar is clicked', + action: 'clicked', + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +// Add a decorator for all stories +const withPadding = (Story: React.ComponentType) => ( +
+ +
+) + +export const Selected: Story = { + decorators: [withPadding], + args: { + name: 'Spending', + color: '#3498db', // Blue + balance: 150000, + totalBalance: 500000, + isSelected: true, + onClick: () => console.log('Selected jar clicked'), + }, + parameters: { + docs: { + description: { + story: 'A jar in selected state. Notice the highlighted indicator dot and slight scale increase.', + }, + }, + }, +} + +export const Unselected: Story = { + decorators: [withPadding], + args: { + name: 'Savings', + color: '#27ae60', // Green + balance: 250000, + totalBalance: 500000, + isSelected: false, + onClick: () => console.log('Unselected jar clicked'), + }, + parameters: { + docs: { + description: { + story: 'A jar in unselected state. The indicator dot is dimmed and the jar is slightly smaller.', + }, + }, + }, +} + +export const EmptyJar: Story = { + decorators: [withPadding], + args: { + name: 'Empty', + color: '#e74c3c', // Red + balance: 0, + totalBalance: 500000, + isSelected: false, + onClick: () => console.log('Empty jar clicked'), + }, + parameters: { + docs: { + description: { + story: 'A jar with zero balance. The jar appears empty regardless of the color assigned.', + }, + }, + }, +} + +export const FullJar: Story = { + decorators: [withPadding], + args: { + name: 'Full', + color: '#f39c12', // Orange + balance: 500000, + totalBalance: 500000, + isSelected: false, + onClick: () => console.log('Full jar clicked'), + }, + parameters: { + docs: { + description: { + story: 'A jar containing the entire wallet balance. The jar is completely filled with the specified color.', + }, + }, + }, +} + +// Story that shows all jars in a grid layout +export const JarGrid: Story = { + args: { + name: 'Spending', + color: '#3498db', + balance: 150000, + totalBalance: 500000, + isSelected: true, + onClick: () => console.log('Jar clicked'), + }, + parameters: { + docs: { + description: { + story: + 'A collection of jars displayed in a grid layout, demonstrating how they would appear in the actual application.', + }, + }, + }, + render: () => { + const jars = [ + { name: 'Spending', color: '#3498db', balance: 150000, isSelected: true }, + { name: 'Savings', color: '#27ae60', balance: 250000, isSelected: false }, + { name: 'Emergency', color: '#e74c3c', balance: 50000, isSelected: false }, + { name: 'Travel', color: '#f39c12', balance: 100000, isSelected: false }, + { name: 'Gift', color: '#9b59b6', balance: 0, isSelected: false }, + ] + + const totalBalance = jars.reduce((total, jar) => total + jar.balance, 0) + + return ( +
+ {jars.map((jar) => ( + console.log(`Clicked on ${jar.name}`)} + /> + ))} +
+ ) + }, +} + +// Interactive example with controls +export const Interactive: Story = { + decorators: [withPadding], + args: { + name: 'Customize Me', + color: '#2ecc71', + balance: 100000, + totalBalance: 500000, + isSelected: false, + onClick: () => console.log('Interactive jar clicked'), + }, + parameters: { + docs: { + description: { + story: + 'An interactive example where you can modify all properties through the Controls panel to see how they affect the jar appearance.', + }, + }, + }, +} diff --git a/vite.config.ts b/vite.config.ts index 1d57f9d0..85cdfc11 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,6 +56,15 @@ export default defineConfig((): UserConfig => { setupFiles: ['.storybook/vitest.setup.ts'], }, }, + { + test: { + name: 'unit', + globals: true, + environment: 'node', + include: ['**/*.test.{ts,tsx}'], + exclude: ['node_modules', '.storybook'], + }, + }, ], }, }