mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-20 13:28:21 +02:00
feat: add receive page with amount input, selectable jar and QR code
This commit is contained in:
parent
e2c8e3081e
commit
054f5fcd63
28 changed files with 1477 additions and 144 deletions
357
package-lock.json
generated
357
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
33
src/App.tsx
33
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}</> : <Navigate to="/login" replace />
|
||||
const ProtectedRoute = ({ children, authenticated }: { children: React.ReactNode; authenticated: boolean }) => {
|
||||
return authenticated ? <>{children}</> : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
function App() {
|
||||
const session = useSession()
|
||||
const authenticated = useMemo(() => session?.auth?.token !== undefined, [session])
|
||||
const walletFileName = useMemo(() => session?.walletFileName || '', [session])
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark" enableSystem>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RefreshApiToken />
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={isAuthenticated() ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/create-wallet" element={isAuthenticated() ? <Navigate to="/" replace /> : <CreateWallet />} />
|
||||
<Route path="/login" element={authenticated ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/create-wallet" element={authenticated ? <Navigate to="/" replace /> : <CreateWallet />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProtectedRoute authenticated={authenticated}>
|
||||
<Layout>
|
||||
<JamLanding />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/receive"
|
||||
element={
|
||||
<ProtectedRoute authenticated={authenticated}>
|
||||
<Layout>
|
||||
<Receive walletFileName={walletFileName} />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
<Toaster closeButton />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,20 +5,19 @@ type DisplayLogoProps = {
|
|||
|
||||
export function DisplayLogo({ displayMode, size = 'lg' }: DisplayLogoProps) {
|
||||
if (displayMode === 'btc') {
|
||||
return <span className={size === 'sm' ? 'ml-1 text-lg' : 'ml-1 text-4xl'}>₿</span>
|
||||
return <span className={`px-1 ${size === 'sm' ? 'text-md' : 'text-4xl'}`}>₿</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size === 'sm' ? '18px' : '40px'}
|
||||
width={size === 'sm' ? '16px' : '30px'}
|
||||
height={size === 'sm' ? '18px' : '40px'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{
|
||||
display: 'inline',
|
||||
verticalAlign: 'middle',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
<path d="M7 7.90906H17" stroke="currentColor" />
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function JamLanding() {
|
|||
<span
|
||||
onClick={toggleDisplayMode}
|
||||
title="Click to toggle sats/bitcoin"
|
||||
className="min-w-[250px] text-center tabular-nums"
|
||||
className="text-center tabular-nums"
|
||||
>
|
||||
{formatAmount(totalBalance)}{' '}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<header className="flex items-center justify-between bg-gray-100 px-6 py-4 text-black transition-colors duration-300 dark:bg-[#23262b] dark:text-white">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Wallet className="mr-3" strokeWidth={1} />
|
||||
<div className="relative flex flex-col">
|
||||
<span className="-mb-1 flex font-thin">Satoshi</span>
|
||||
<Badge className="absolute -top-1 right-3 z-10 -translate-x-1/2" variant="dev">
|
||||
dev
|
||||
</Badge>
|
||||
<div className="flex min-h-[40px] items-center text-lg font-light tracking-wider">
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="cursor-pointer text-center tabular-nums select-none"
|
||||
onClick={toggleDisplayMode}
|
||||
title="Click to toggle sats/bitcoin"
|
||||
>
|
||||
{formatAmount(totalBalance)}
|
||||
</span>
|
||||
<span className="flex min-h-[32px] items-center">{getLogo('sm')}</span>
|
||||
</>
|
||||
)}
|
||||
<Link to={'/'} className="flex items-center gap-2 text-lg font-semibold">
|
||||
<Wallet className="mr-3" strokeWidth={1} />
|
||||
<div className="relative flex flex-col">
|
||||
<span className="-mb-1 flex font-thin">Satoshi</span>
|
||||
<Badge className={`absolute -top-1 left-20 z-10 -translate-x-1/2`} variant="dev">
|
||||
dev
|
||||
</Badge>
|
||||
<div className="flex min-h-[40px] items-center text-lg font-light tracking-wider">
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
) : (
|
||||
<>
|
||||
<span className="cursor-pointer text-center tabular-nums select-none">
|
||||
{formatAmount(totalBalance)}
|
||||
</span>
|
||||
<span className="flex min-h-[32px] items-center">{getLogo('sm')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-8 text-sm">
|
||||
<span className="cursor-pointer opacity-70 hover:underline">Receive</span>
|
||||
<span className="relative cursor-pointer opacity-70 hover:underline">
|
||||
<Link to="/receive" className="cursor-pointer opacity-70 hover:underline">
|
||||
Receive
|
||||
</Link>
|
||||
<Link to="/earn" className="relative cursor-pointer opacity-70 hover:underline">
|
||||
<span>Earn</span>
|
||||
<span className="absolute -top-1 -right-2 text-[8px] text-[#6ee7b7]">●</span>
|
||||
</span>
|
||||
<span className="cursor-pointer opacity-70 hover:underline">Send</span>
|
||||
</Link>
|
||||
<Link to="/send" className="cursor-pointer opacity-70 hover:underline">
|
||||
Send
|
||||
</Link>
|
||||
<span className="text-gray-400 dark:text-gray-600">|</span>
|
||||
<span className="cursor-pointer opacity-70 hover:underline">Sweep</span>
|
||||
<Link to="/sweep" className="cursor-pointer opacity-70 hover:underline">
|
||||
Sweep
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { DisplayLogo } from '../DisplayLogo'
|
||||
import { JarIcon } from '../ui/JarIcon'
|
||||
|
||||
interface JarProps {
|
||||
name: string
|
||||
amount: number
|
||||
|
|
@ -6,8 +9,6 @@ interface JarProps {
|
|||
totalBalance?: number
|
||||
}
|
||||
|
||||
type JarFillLevel = 0 | 1 | 2 | 3
|
||||
|
||||
function formatAmount(amount: number, displayMode: 'sats' | 'btc') {
|
||||
if (displayMode === 'btc') {
|
||||
return (amount / 100_000_000).toLocaleString(undefined, {
|
||||
|
|
@ -17,80 +18,15 @@ function formatAmount(amount: number, displayMode: 'sats' | 'btc') {
|
|||
return amount.toLocaleString()
|
||||
}
|
||||
|
||||
function getLogo(displayMode: 'sats' | 'btc') {
|
||||
return displayMode === 'btc' ? (
|
||||
'₿'
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16px"
|
||||
height="16px"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ display: 'inline', verticalAlign: 'middle', marginLeft: 2 }}
|
||||
>
|
||||
<path d="M7 7.90906H17" stroke="currentColor" />
|
||||
<path d="M12 5.45454V3" stroke="currentColor" />
|
||||
<path d="M12 20.9999V18.5454" stroke="currentColor" />
|
||||
<path d="M7 12H17" stroke="currentColor" />
|
||||
<path d="M7 16.0909H17" stroke="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Function to calculate the jar fill level based on jar balance relative to total balance
|
||||
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
|
||||
}
|
||||
|
||||
// Convert fill level to percentage for the visual display
|
||||
const fillLevelToPercent = (fillLevel: JarFillLevel): number => {
|
||||
switch (fillLevel) {
|
||||
case 0:
|
||||
return 0
|
||||
case 1:
|
||||
return 30
|
||||
case 2:
|
||||
return 60
|
||||
case 3:
|
||||
return 90
|
||||
}
|
||||
}
|
||||
|
||||
export function Jar({ name, amount, color, displayMode, totalBalance = 0 }: JarProps) {
|
||||
const fillLevel = calculateJarFillLevel(amount, totalBalance)
|
||||
const fillPercent = fillLevelToPercent(fillLevel)
|
||||
return (
|
||||
<div className="flex cursor-pointer flex-col items-center transition-all duration-300 hover:scale-105">
|
||||
<div className="mb-2">
|
||||
<div className="relative flex h-20 w-12 flex-col items-center">
|
||||
{/* Jar body */}
|
||||
<div className="absolute top-3 left-0 flex h-4/5 w-full items-end">
|
||||
<div className="bg-opacity-60 flex h-full w-full items-end overflow-hidden rounded-t-[16px] rounded-b-[10px] border-2 border-gray-400 bg-white dark:bg-gray-300">
|
||||
{/* Fill */}
|
||||
<div
|
||||
className="w-full rounded-b-[8px] transition-all duration-500"
|
||||
style={{
|
||||
height: `${fillPercent}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Jar neck */}
|
||||
<div className="absolute top-0 left-2 z-10 h-3.5 w-8 rounded-t-[8px] border-2 border-b-0 border-gray-400 bg-white dark:bg-gray-300" />
|
||||
{/* Jar lid */}
|
||||
<div className="absolute top-0 left-1 z-20 h-2 w-10 rounded-t-[8px] rounded-b-[8px] bg-gray-500" />
|
||||
</div>
|
||||
<JarIcon amount={amount} totalBalance={totalBalance} color={color} />
|
||||
</div>
|
||||
<p className="text-center text-sm">{name}</p>
|
||||
<p className="min-w-[110px] text-center text-xs tabular-nums">
|
||||
{formatAmount(amount, displayMode)} {getLogo(displayMode)}
|
||||
{formatAmount(amount, displayMode)} <DisplayLogo displayMode={displayMode} size="sm" />
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export function Layout({ children }: LayoutProps) {
|
|||
<Navbar
|
||||
theme={resolvedTheme || 'dark'}
|
||||
toggleTheme={toggleTheme}
|
||||
toggleDisplayMode={toggleDisplayMode}
|
||||
formatAmount={formatAmount}
|
||||
getLogo={(size) => <DisplayLogo displayMode={displayMode} size={size} />}
|
||||
jars={jars}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
|
|||
|
||||
export type JarColor = '#e2b86a' | '#3b5ba9' | '#c94f7c' | '#a67c52' | '#7c3fa6'
|
||||
|
||||
// Define jar templates - without balances
|
||||
export const jarTemplates: Array<{
|
||||
name: string
|
||||
color: JarColor
|
||||
|
|
|
|||
54
src/components/receive/BitcoinAmountInput.tsx
Normal file
54
src/components/receive/BitcoinAmountInput.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { ArrowUpDown } from 'lucide-react'
|
||||
import { DisplayLogo } from '../DisplayLogo'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
|
||||
type DisplayMode = 'sats' | 'btc'
|
||||
|
||||
interface BitcoinAmountInputProps extends Omit<React.ComponentProps<'input'>, 'type'> {
|
||||
amountDisplayMode: DisplayMode
|
||||
toggleDisplayMode: () => void
|
||||
}
|
||||
|
||||
export const BitcoinAmountInput = ({
|
||||
amountDisplayMode,
|
||||
onChange,
|
||||
value,
|
||||
toggleDisplayMode,
|
||||
disabled,
|
||||
...inputProps
|
||||
}: BitcoinAmountInputProps) => {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2 text-sm">Amount</p>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<div onClick={toggleDisplayMode} className="absolute inset-y-0 left-0 flex items-center px-1">
|
||||
<span className="px-1 text-gray-500">
|
||||
<DisplayLogo displayMode={amountDisplayMode} size="sm" />
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`Enter Amount in ${amountDisplayMode}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="pl-8"
|
||||
step={amountDisplayMode === 'btc' ? '0.00000001' : '1'}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
aria-label={`Amount in ${amountDisplayMode}`}
|
||||
autoComplete="off"
|
||||
autoFocus={true}
|
||||
disabled={disabled}
|
||||
{...inputProps}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="py-4 whitespace-nowrap" onClick={toggleDisplayMode}>
|
||||
{amountDisplayMode === 'sats' ? 'BTC' : 'Sats'}
|
||||
<ArrowUpDown />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
80
src/components/receive/BitcoinQR.tsx
Normal file
80
src/components/receive/BitcoinQR.tsx
Normal file
|
|
@ -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<string>()
|
||||
const [image, setImage] = useState<string>()
|
||||
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 (
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{ height: width, width: width }}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
{image && (
|
||||
<>
|
||||
<img
|
||||
src={image}
|
||||
alt={data}
|
||||
title={data}
|
||||
className={`transition-all duration-100 ${isHovering ? 'blur-[2px]' : ''}`}
|
||||
/>
|
||||
{isHovering && (
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
className="absolute flex cursor-pointer items-center justify-center"
|
||||
onClick={downloadQR}
|
||||
aria-label="Download QR Code"
|
||||
>
|
||||
<Download size={18} />
|
||||
Download QR
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
src/components/receive/Receive.tsx
Normal file
225
src/components/receive/Receive.tsx
Normal file
|
|
@ -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<number | undefined>()
|
||||
const [bitcoinAddress, setBitcoinAddress] = useState<string | undefined>()
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 pt-6">
|
||||
<h1 className="mb-2 text-left text-2xl font-bold">Receive</h1>
|
||||
<p className="text-muted-foreground mb-4">No wallet available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!jars || jars.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 pt-6">
|
||||
<h1 className="mb-2 text-left text-2xl font-bold">Receive</h1>
|
||||
<p className="text-muted-foreground mb-4">Loading wallet data...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 pt-6">
|
||||
<h1 className="mb-2 text-left text-2xl font-bold">Receive</h1>
|
||||
<p className="text-muted-foreground mb-4 text-sm">Send sats to the address below to fund your wallet.</p>
|
||||
|
||||
<div className="flex w-full max-w-xl flex-col items-center justify-center rounded-lg border p-8">
|
||||
{isQrLoading ? (
|
||||
<Skeleton className="h-[260px] w-[260px]" />
|
||||
) : bitcoinAddress ? (
|
||||
<BitcoinQR address={bitcoinAddress} amount={amount} width={260} />
|
||||
) : (
|
||||
<div className="flex h-[260px] w-[260px] animate-pulse items-center justify-center border text-gray-500">
|
||||
No address available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isQrLoading ? (
|
||||
<Skeleton className="mt-4 h-4 w-[50%]" />
|
||||
) : (
|
||||
<p className="mt-4 text-center text-xs break-all select-all">{bitcoinAddress}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={getNewAddress}>
|
||||
<RefreshCw className="mr-1" />
|
||||
Get new address
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={copyToClipboard}>
|
||||
{copied ? <CopyCheck /> : <Copy />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
|
||||
{'share' in navigator && (
|
||||
<Button variant="outline" size="sm" onClick={shareAddress}>
|
||||
<Share />
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full max-w-xl">
|
||||
<AccordionItem value="options" className="border-0">
|
||||
<AccordionTrigger className="text-muted-foreground text-md rounded-none border-b p-4">
|
||||
Receive options
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="w-full py-4">
|
||||
<p className="mb-2 text-sm">Receive to</p>
|
||||
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{jars.map((jar, index) => (
|
||||
<SelectableJar
|
||||
key={jar.name}
|
||||
name={jar.name}
|
||||
color={jar.color}
|
||||
balance={jar.balance || 0}
|
||||
isSelected={selectedJarIndex === index}
|
||||
totalBalance={totalBalance}
|
||||
onClick={() => selectJar(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mx-1 mt-4">
|
||||
<BitcoinAmountInput
|
||||
amountDisplayMode={amountDisplayMode}
|
||||
value={getDisplayAmount()}
|
||||
onChange={handleAmountChange}
|
||||
toggleDisplayMode={toggleDisplayMode}
|
||||
disabled={isQrLoading || !bitcoinAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
src/components/ui/JarIcon.tsx
Normal file
86
src/components/ui/JarIcon.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-20 w-12 flex-col items-center transition-transform duration-200 ease-in-out',
|
||||
className,
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Jar body */}
|
||||
<div className={`absolute top-3 left-0 flex h-4/5 w-full items-end`}>
|
||||
<div className="bg-opacity-60 flex h-full w-full items-end overflow-hidden rounded-t-[16px] rounded-b-[10px] border-2 border-gray-400 bg-white dark:bg-gray-300">
|
||||
{/* Fill */}
|
||||
<div
|
||||
className="w-full rounded-b-[8px] transition-all duration-500"
|
||||
style={{
|
||||
height: `${fillPercent}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Jar neck */}
|
||||
<div className="absolute top-0 left-2 z-10 mt-2 h-2 w-8 border-2 border-y-0 border-gray-400 bg-white dark:bg-gray-300" />
|
||||
{/* Jar lid */}
|
||||
<div
|
||||
className="absolute z-20 h-2 w-10 rounded-t-[8px] rounded-b-[8px] bg-gray-500 transition-transform duration-200 ease-in-out"
|
||||
style={{
|
||||
top: isLidOpen ? '-5px' : '0px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/ui/SelectableJar.tsx
Normal file
36
src/components/ui/SelectableJar.tsx
Normal file
|
|
@ -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 (
|
||||
<button className="flex flex-col items-center" onClick={onClick}>
|
||||
<JarIcon
|
||||
color={color}
|
||||
amount={balance || 0}
|
||||
isSelected={isSelected}
|
||||
totalBalance={totalBalance}
|
||||
className={`cursor-pointer ${isSelected ? 'scale-[0.70]' : 'scale-[0.65]'}`}
|
||||
/>
|
||||
|
||||
<span className="text-xs">{name}</span>
|
||||
<div className="flex items-center font-mono text-[10px] text-gray-500">
|
||||
<DisplayLogo displayMode="sats" size="sm" />
|
||||
<span>{balance.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`light:border-black mx-0.5 mt-1 h-2 w-2 rounded-full border border-white ${!isSelected ? 'light:bg-gray-200 bg-gray-800' : 'light:bg-gray-800 bg-gray-200'}`}
|
||||
></div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
50
src/components/ui/accordion.tsx
Normal file
50
src/components/ui/accordion.tsx
Normal file
|
|
@ -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<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn('border-b last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
21
src/hooks/useSession.ts
Normal file
21
src/hooks/useSession.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -227,3 +227,8 @@
|
|||
color: hsl(221 95% 85%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,9 @@ export type StartMakerRequest = {
|
|||
minsize: string;
|
||||
};
|
||||
|
||||
export type GetAddressResponse = string;
|
||||
export type GetAddressResponse = {
|
||||
address: string;
|
||||
};
|
||||
|
||||
export type ListWalletsResponse = {
|
||||
wallets?: Array<string>;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ interface SessionData {
|
|||
}
|
||||
}
|
||||
|
||||
// Function to save session after successful login
|
||||
export const setSession = (session: Partial<SessionData>) => {
|
||||
sessionStorage.setItem(
|
||||
'joinmarket',
|
||||
|
|
@ -17,15 +16,22 @@ export const setSession = (session: Partial<SessionData>) => {
|
|||
...session,
|
||||
}),
|
||||
)
|
||||
|
||||
dispatchEvent('sessionUpdate')
|
||||
}
|
||||
|
||||
// Function to get current session
|
||||
export const getSession = (): Partial<SessionData> | 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))
|
||||
}
|
||||
|
|
|
|||
115
src/lib/utils.test.ts
Normal file
115
src/lib/utils.test.ts
Normal file
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
90
src/stories/Accordion.stories.tsx
Normal file
90
src/stories/Accordion.stories.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
|
||||
const meta: Meta<typeof Accordion> = {
|
||||
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<typeof Accordion>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Example use case</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
This is an accordion component that can be used to display content in a collapsible format.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
export const Multiple: Story = {
|
||||
render: () => (
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Is it accessible?</AccordionTrigger>
|
||||
<AccordionContent>Yes. It adheres to the WAI-ARIA design pattern.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Is it styled?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It comes with default styles that match the other components' aesthetic.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Is it animated?</AccordionTrigger>
|
||||
<AccordionContent>Yes. It's animated by default, but you can disable it if you prefer.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
export const DefaultOpen: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" defaultValue="item-2" className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>First section</AccordionTrigger>
|
||||
<AccordionContent>This section is closed by default.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Second section</AccordionTrigger>
|
||||
<AccordionContent>This section is open by default because we set defaultValue="item-2".</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Third section</AccordionTrigger>
|
||||
<AccordionContent>This section is closed by default.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1" className="mb-2 rounded-md border border-gray-200 px-4">
|
||||
<AccordionTrigger className="font-bold text-blue-500">Custom styled accordion</AccordionTrigger>
|
||||
<AccordionContent className="text-gray-600">
|
||||
This accordion has custom styling applied to demonstrate the flexibility of the component.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2" className="rounded-md border border-gray-200 px-4">
|
||||
<AccordionTrigger className="font-bold text-blue-500">Another custom section</AccordionTrigger>
|
||||
<AccordionContent className="text-gray-600">
|
||||
You can customize each accordion item independently.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
}
|
||||
69
src/stories/BitcoinAmountInput.stories.tsx
Normal file
69
src/stories/BitcoinAmountInput.stories.tsx
Normal file
|
|
@ -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<typeof BitcoinAmountInput> = {
|
||||
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<typeof BitcoinAmountInput>
|
||||
|
||||
// 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<HTMLInputElement>) => {
|
||||
setAmount(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<BitcoinAmountInput
|
||||
amountDisplayMode={amountDisplayMode}
|
||||
value={amount}
|
||||
onChange={handleAmountChange}
|
||||
toggleDisplayMode={toggleDisplayMode}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <BitcoinAmountInputWrapper />,
|
||||
}
|
||||
|
||||
export const BTCMode: Story = {
|
||||
render: () => <BitcoinAmountInputWrapper initialMode="btc" />,
|
||||
}
|
||||
|
||||
export const WithInitialAmount: Story = {
|
||||
render: () => <BitcoinAmountInputWrapper initialAmount="1000" initialMode="sats" />,
|
||||
}
|
||||
|
||||
export const DisabledState: Story = {
|
||||
render: () => <BitcoinAmountInputWrapper disabled={true} />,
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
198
src/stories/SelectableJar.stories.tsx
Normal file
198
src/stories/SelectableJar.stories.tsx
Normal file
|
|
@ -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<typeof SelectableJar>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Add a decorator for all stories
|
||||
const withPadding = (Story: React.ComponentType) => (
|
||||
<div className="p-6">
|
||||
<Story />
|
||||
</div>
|
||||
)
|
||||
|
||||
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 (
|
||||
<div className="grid grid-cols-5 gap-4 rounded-lg border p-4">
|
||||
{jars.map((jar) => (
|
||||
<SelectableJar
|
||||
key={jar.name}
|
||||
name={jar.name}
|
||||
color={jar.color}
|
||||
balance={jar.balance}
|
||||
totalBalance={totalBalance}
|
||||
isSelected={jar.isSelected}
|
||||
onClick={() => console.log(`Clicked on ${jar.name}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// 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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue